diff --git a/catalog/components.yml b/catalog/components.yml index e79089e..96d8e75 100644 --- a/catalog/components.yml +++ b/catalog/components.yml @@ -262,27 +262,27 @@ components: # Data engines. A project may select at most one provider for each role. - {id: sqlite, name: SQLite, layer: data, tier: stable, roles: [sql], python: ">=3.11,<3.15"} - - {id: postgresql, name: PostgreSQL, layer: data, tier: stable, roles: [sql], python: ">=3.11,<3.15"} - - {id: mysql, name: MySQL, layer: data, tier: stable, roles: [sql], python: ">=3.11,<3.15"} - - {id: duckdb, name: DuckDB, layer: data, tier: stable, roles: [sql], python: ">=3.11,<3.15"} - - {id: supabase, name: Supabase, layer: data, tier: platform, roles: [sql, document], python: ">=3.11,<3.15"} - - {id: mongodb, name: MongoDB, layer: data, tier: stable, roles: [document], python: ">=3.11,<3.15"} - - {id: beaver, name: Beaver, layer: data, tier: stable, roles: [document, vector, graph, cache], python: ">=3.12,<3.15"} - - {id: pgvector, name: pgvector, layer: data, tier: stable, roles: [vector], python: ">=3.11,<3.15"} - - {id: qdrant, name: Qdrant, layer: data, tier: stable, roles: [vector], python: ">=3.11,<3.15"} - - {id: chroma, name: Chroma, layer: data, tier: stable, roles: [vector], python: ">=3.11,<3.15"} - - {id: lancedb, name: LanceDB, layer: data, tier: stable, roles: [vector], python: ">=3.11,<3.15"} - - {id: weaviate, name: Weaviate, layer: data, tier: platform, roles: [vector], python: ">=3.11,<3.15"} - - {id: pinecone, name: Pinecone, layer: data, tier: platform, roles: [vector], python: ">=3.11,<3.15"} - - {id: milvus, name: Milvus, layer: data, tier: stable, roles: [vector], python: ">=3.11,<3.15"} - - {id: neo4j, name: Neo4j, layer: data, tier: stable, roles: [graph], python: ">=3.11,<3.15"} - - {id: redis, name: Redis, layer: data, tier: stable, roles: [cache], python: ">=3.11,<3.15"} + - {id: postgresql, name: PostgreSQL, layer: data, tier: stable, roles: [sql], python: ">=3.11,<3.15", packages: ["psycopg[binary]>=3.3.4,<4"]} + - {id: mysql, name: MySQL, layer: data, tier: stable, roles: [sql], python: ">=3.11,<3.15", packages: ["pymysql>=1.2.0,<2"]} + - {id: duckdb, name: DuckDB, layer: data, tier: stable, roles: [sql], python: ">=3.11,<3.15", packages: ["duckdb>=1.5.5,<2", "duckdb-engine>=0.17.0,<1"]} + - {id: supabase, name: Supabase, layer: data, tier: platform, roles: [sql, document], python: ">=3.11,<3.15", packages: ["supabase>=2.31.0,<3"]} + - {id: mongodb, name: MongoDB, layer: data, tier: stable, roles: [document], python: ">=3.11,<3.15", packages: ["pymongo>=4.17.0,<5"]} + - {id: beaver, name: Beaver, layer: data, tier: stable, roles: [document, vector, graph, cache], python: ">=3.12,<3.15", packages: ["beaver-db>=2.3.0,<3"]} + - {id: pgvector, name: pgvector, layer: data, tier: stable, roles: [vector], python: ">=3.11,<3.15", packages: ["pgvector>=0.5.0,<1"]} + - {id: qdrant, name: Qdrant, layer: data, tier: stable, roles: [vector], python: ">=3.11,<3.15", packages: ["qdrant-client>=1.19.0,<2"]} + - {id: chroma, name: Chroma, layer: data, tier: stable, roles: [vector], python: ">=3.11,<3.15", packages: ["chromadb>=1.5.9,<2"]} + - {id: lancedb, name: LanceDB, layer: data, tier: stable, roles: [vector], python: ">=3.11,<3.15", packages: ["lancedb>=0.36.0,<1"]} + - {id: weaviate, name: Weaviate, layer: data, tier: platform, roles: [vector], python: ">=3.11,<3.15", packages: ["weaviate-client>=4.22.0,<5"]} + - {id: pinecone, name: Pinecone, layer: data, tier: platform, roles: [vector], python: ">=3.11,<3.15", packages: ["pinecone>=9.1.0,<10"]} + - {id: milvus, name: Milvus, layer: data, tier: stable, roles: [vector], python: ">=3.11,<3.15", packages: ["pymilvus>=3.0.1,<4"]} + - {id: neo4j, name: Neo4j, layer: data, tier: stable, roles: [graph], python: ">=3.11,<3.15", packages: ["neo4j>=6.2.0,<7"]} + - {id: redis, name: Redis, layer: data, tier: stable, roles: [cache], python: ">=3.11,<3.15", packages: ["redis>=8.1.0,<9"]} - - {id: sqlmodel, name: SQLModel, layer: sql_abstraction, tier: stable, python: ">=3.11,<3.15"} - - {id: sqlalchemy, name: SQLAlchemy, layer: sql_abstraction, tier: stable, python: ">=3.11,<3.15"} + - {id: sqlmodel, name: SQLModel, layer: sql_abstraction, tier: stable, python: ">=3.11,<3.15", packages: ["sqlmodel>=0.0.39,<0.1", "sqlalchemy>=2.0.51,<3", "alembic>=1.19.1,<2"]} + - {id: sqlalchemy, name: SQLAlchemy, layer: sql_abstraction, tier: stable, python: ">=3.11,<3.15", packages: ["sqlalchemy>=2.0.51,<3", "alembic>=1.19.1,<2"]} - {id: api-key, name: API key, layer: auth, tier: stable, python: ">=3.11,<3.15"} - - {id: oidc, name: OAuth / OIDC, layer: auth, tier: platform, python: ">=3.11,<3.15"} - - {id: supabase-auth, name: Supabase Auth, layer: auth, tier: platform, python: ">=3.11,<3.15"} + - {id: oidc, name: OAuth / OIDC, layer: auth, tier: platform, python: ">=3.11,<3.15", packages: ["authlib>=1.7.2,<2"]} + - {id: supabase-auth, name: Supabase Auth, layer: auth, tier: platform, python: ">=3.11,<3.15", packages: ["supabase>=2.31.0,<3"]} - {id: bentoml, name: BentoML, layer: serving, tier: stable, python: ">=3.11,<3.15"} - {id: litellm, name: LiteLLM, layer: serving, tier: stable, python: ">=3.11,<3.15"} diff --git a/catalog/generated/catalog.json b/catalog/generated/catalog.json index 5126a8c..664f85c 100644 --- a/catalog/generated/catalog.json +++ b/catalog/generated/catalog.json @@ -781,7 +781,9 @@ "roles": [ "sql" ], - "packages": [] + "packages": [ + "psycopg[binary]>=3.3.4,<4" + ] }, { "id": "mysql", @@ -793,7 +795,9 @@ "roles": [ "sql" ], - "packages": [] + "packages": [ + "pymysql>=1.2.0,<2" + ] }, { "id": "duckdb", @@ -805,7 +809,10 @@ "roles": [ "sql" ], - "packages": [] + "packages": [ + "duckdb>=1.5.5,<2", + "duckdb-engine>=0.17.0,<1" + ] }, { "id": "supabase", @@ -818,7 +825,9 @@ "sql", "document" ], - "packages": [] + "packages": [ + "supabase>=2.31.0,<3" + ] }, { "id": "mongodb", @@ -830,7 +839,9 @@ "roles": [ "document" ], - "packages": [] + "packages": [ + "pymongo>=4.17.0,<5" + ] }, { "id": "beaver", @@ -845,7 +856,9 @@ "graph", "cache" ], - "packages": [] + "packages": [ + "beaver-db>=2.3.0,<3" + ] }, { "id": "pgvector", @@ -857,7 +870,9 @@ "roles": [ "vector" ], - "packages": [] + "packages": [ + "pgvector>=0.5.0,<1" + ] }, { "id": "qdrant", @@ -869,7 +884,9 @@ "roles": [ "vector" ], - "packages": [] + "packages": [ + "qdrant-client>=1.19.0,<2" + ] }, { "id": "chroma", @@ -881,7 +898,9 @@ "roles": [ "vector" ], - "packages": [] + "packages": [ + "chromadb>=1.5.9,<2" + ] }, { "id": "lancedb", @@ -893,7 +912,9 @@ "roles": [ "vector" ], - "packages": [] + "packages": [ + "lancedb>=0.36.0,<1" + ] }, { "id": "weaviate", @@ -905,7 +926,9 @@ "roles": [ "vector" ], - "packages": [] + "packages": [ + "weaviate-client>=4.22.0,<5" + ] }, { "id": "pinecone", @@ -917,7 +940,9 @@ "roles": [ "vector" ], - "packages": [] + "packages": [ + "pinecone>=9.1.0,<10" + ] }, { "id": "milvus", @@ -929,7 +954,9 @@ "roles": [ "vector" ], - "packages": [] + "packages": [ + "pymilvus>=3.0.1,<4" + ] }, { "id": "neo4j", @@ -941,7 +968,9 @@ "roles": [ "graph" ], - "packages": [] + "packages": [ + "neo4j>=6.2.0,<7" + ] }, { "id": "redis", @@ -953,7 +982,9 @@ "roles": [ "cache" ], - "packages": [] + "packages": [ + "redis>=8.1.0,<9" + ] }, { "id": "sqlmodel", @@ -963,7 +994,11 @@ "python": ">=3.11,<3.15", "workloads": [], "roles": [], - "packages": [] + "packages": [ + "sqlmodel>=0.0.39,<0.1", + "sqlalchemy>=2.0.51,<3", + "alembic>=1.19.1,<2" + ] }, { "id": "sqlalchemy", @@ -973,7 +1008,10 @@ "python": ">=3.11,<3.15", "workloads": [], "roles": [], - "packages": [] + "packages": [ + "sqlalchemy>=2.0.51,<3", + "alembic>=1.19.1,<2" + ] }, { "id": "api-key", @@ -993,7 +1031,9 @@ "python": ">=3.11,<3.15", "workloads": [], "roles": [], - "packages": [] + "packages": [ + "authlib>=1.7.2,<2" + ] }, { "id": "supabase-auth", @@ -1003,7 +1043,9 @@ "python": ">=3.11,<3.15", "workloads": [], "roles": [], - "packages": [] + "packages": [ + "supabase>=2.31.0,<3" + ] }, { "id": "bentoml", diff --git a/copier.yml b/copier.yml index 9713cb5..115e936 100644 --- a/copier.yml +++ b/copier.yml @@ -330,7 +330,7 @@ sql_abstraction: choices: SQLModel + Alembic: sqlmodel SQLAlchemy + Alembic: sqlalchemy - when: "{{ sql_store != 'none' }}" + when: "{{ sql_store not in ['none', 'supabase'] }}" document_store: type: str @@ -381,6 +381,12 @@ cache_store: Beaver local bundle: beaver Redis: redis +data_config: + type: bool + default: >- + {{ sql_store != 'none' or document_store != 'none' or vector_store != 'none' or graph_store != 'none' or cache_store != 'none' }} + when: false + auth: type: str help: Authentication strategy @@ -392,6 +398,14 @@ auth: Supabase Auth: supabase-auth when: "{{ workload in ['api', 'web', 'mcp', 'agent', 'rag', 'inference', 'hybrid'] }}" +# Internal derived switch used in template paths. Keeping this expression out +# of filenames avoids the Windows 260-character checkout limit. +runtime_config: + type: bool + default: >- + {{ ai_capabilities != 'none' or sql_store != 'none' or document_store != 'none' or vector_store != 'none' or graph_store != 'none' or cache_store != 'none' or auth != 'none' }} + when: false + serving: type: str help: Model serving engine diff --git a/scripts/compile_catalog.py b/scripts/compile_catalog.py index 1a4c332..ce9707f 100644 --- a/scripts/compile_catalog.py +++ b/scripts/compile_catalog.py @@ -163,7 +163,8 @@ def build_artifacts( " + ([model_provider] if ai_capabilities in ['agents', 'rag', 'inference'] else [])", " + ([embedding_provider] if ai_capabilities == 'rag' else [])", " + [sql_store, document_store, vector_store, graph_store, cache_store]", - " + [sql_abstraction | default('none', true), auth | default('none', true)]", + " + ([sql_abstraction | default('none', true)] if sql_store not in ['none', 'supabase'] else [])", + " + [auth | default('none', true)]", " + [serving | default('none', true)]", " + (training_extensions | default([]))", " + (mlops_tools | default([]))", @@ -177,7 +178,7 @@ def build_artifacts( "{%- endif -%}", "{%- endfor -%}", "{%- endfor %}", - "{%- if ai_capabilities != 'none' or sql_store != 'none' or document_store != 'none' or auth | default('none', true) != 'none' %}", + "{%- if ai_capabilities != 'none' or sql_store != 'none' or document_store != 'none' or vector_store != 'none' or graph_store != 'none' or cache_store != 'none' or auth | default('none', true) != 'none' %}", ' "pydantic>=2.13.4,<3",', ' "pydantic-settings>=2.15.0,<3",', "{%- endif %}", diff --git a/template/_catalog_dependencies.jinja b/template/_catalog_dependencies.jinja index 06573ce..8ed2dda 100644 --- a/template/_catalog_dependencies.jinja +++ b/template/_catalog_dependencies.jinja @@ -1,11 +1,12 @@ {# Generated by scripts/compile_catalog.py. Do not edit. #} -{%- set component_packages = {"ag2": ["ag2>=1.0.1,<2"], "agno": ["agno>=2.8.7,<3"], "anthropic": ["anthropic>=0.121.0,<1"], "autogen": ["autogen-agentchat>=0.7.5,<1"], "azure-openai": ["openai>=2.53.0,<3"], "bedrock": ["boto3>=1.43.67,<2"], "chainlit": ["chainlit>=2.11.1,<3"], "cohere": ["cohere>=7.0.8,<8"], "crewai": ["crewai>=1.15.13,<2"], "deepseek": ["openai>=2.53.0,<3"], "dspy": ["dspy>=3.3.0,<4"], "fastapi": ["fastapi[standard]>=0.141.1,<1", "pydantic>=2.13.4,<3", "uvicorn>=0.52.1,<1"], "fasthtml": ["python-fasthtml>=0.14.11,<0.15"], "fastmcp": ["fastmcp>=3.4.6,<4"], "fireworks": ["fireworks-ai>=1.2.6,<2"], "flask": ["flask>=3.1.2,<4"], "gemini": ["google-genai>=2.17.0,<3"], "google-adk": ["google-adk>=2.6.3,<3"], "gradio": ["gradio>=6.22.0,<7"], "groq": ["groq>=1.6.0,<2"], "haystack": ["haystack-ai>=3.0.0,<4"], "huggingface": ["huggingface-hub>=1.27.0,<2"], "jupyterlab": ["jupyterlab>=4.6.2,<5"], "langchain": ["langchain>=1.3.14,<2"], "langgraph": ["langgraph>=1.2.10,<2"], "lingo": ["lingo-ai>=2.1.0,<3"], "llamaindex": ["llama-index>=0.14.23,<0.15"], "mistral": ["mistralai>=2.9.1,<3"], "nicegui": ["nicegui>=3.15.0,<4"], "ollama": ["ollama>=0.6.2,<1"], "openai": ["openai>=2.53.0,<3"], "openai-agents": ["openai-agents>=0.19.4,<1"], "openai-compatible": ["openai>=2.53.0,<3"], "openrouter": ["openrouter>=1.1.37,<2"], "pydantic-ai": ["pydantic-ai>=2.27.0,<3"], "sentence-transformers": ["sentence-transformers>=5.7.0,<6"], "smolagents": ["smolagents>=1.26.0,<2"], "strands-agents": ["strands-agents>=1.51.0,<2"], "streamlit": ["streamlit>=1.61.1,<2"], "textual": ["textual>=8.2.8,<9"], "together": ["together>=2.30.0,<3"], "typer": ["typer>=0.27.1,<1"], "violetear": ["violetear[server]>=1.4.1,<2"], "xai": ["xai-sdk>=1.17.0,<2"]} -%} +{%- set component_packages = {"ag2": ["ag2>=1.0.1,<2"], "agno": ["agno>=2.8.7,<3"], "anthropic": ["anthropic>=0.121.0,<1"], "autogen": ["autogen-agentchat>=0.7.5,<1"], "azure-openai": ["openai>=2.53.0,<3"], "beaver": ["beaver-db>=2.3.0,<3"], "bedrock": ["boto3>=1.43.67,<2"], "chainlit": ["chainlit>=2.11.1,<3"], "chroma": ["chromadb>=1.5.9,<2"], "cohere": ["cohere>=7.0.8,<8"], "crewai": ["crewai>=1.15.13,<2"], "deepseek": ["openai>=2.53.0,<3"], "dspy": ["dspy>=3.3.0,<4"], "duckdb": ["duckdb>=1.5.5,<2", "duckdb-engine>=0.17.0,<1"], "fastapi": ["fastapi[standard]>=0.141.1,<1", "pydantic>=2.13.4,<3", "uvicorn>=0.52.1,<1"], "fasthtml": ["python-fasthtml>=0.14.11,<0.15"], "fastmcp": ["fastmcp>=3.4.6,<4"], "fireworks": ["fireworks-ai>=1.2.6,<2"], "flask": ["flask>=3.1.2,<4"], "gemini": ["google-genai>=2.17.0,<3"], "google-adk": ["google-adk>=2.6.3,<3"], "gradio": ["gradio>=6.22.0,<7"], "groq": ["groq>=1.6.0,<2"], "haystack": ["haystack-ai>=3.0.0,<4"], "huggingface": ["huggingface-hub>=1.27.0,<2"], "jupyterlab": ["jupyterlab>=4.6.2,<5"], "lancedb": ["lancedb>=0.36.0,<1"], "langchain": ["langchain>=1.3.14,<2"], "langgraph": ["langgraph>=1.2.10,<2"], "lingo": ["lingo-ai>=2.1.0,<3"], "llamaindex": ["llama-index>=0.14.23,<0.15"], "milvus": ["pymilvus>=3.0.1,<4"], "mistral": ["mistralai>=2.9.1,<3"], "mongodb": ["pymongo>=4.17.0,<5"], "mysql": ["pymysql>=1.2.0,<2"], "neo4j": ["neo4j>=6.2.0,<7"], "nicegui": ["nicegui>=3.15.0,<4"], "oidc": ["authlib>=1.7.2,<2"], "ollama": ["ollama>=0.6.2,<1"], "openai": ["openai>=2.53.0,<3"], "openai-agents": ["openai-agents>=0.19.4,<1"], "openai-compatible": ["openai>=2.53.0,<3"], "openrouter": ["openrouter>=1.1.37,<2"], "pgvector": ["pgvector>=0.5.0,<1"], "pinecone": ["pinecone>=9.1.0,<10"], "postgresql": ["psycopg[binary]>=3.3.4,<4"], "pydantic-ai": ["pydantic-ai>=2.27.0,<3"], "qdrant": ["qdrant-client>=1.19.0,<2"], "redis": ["redis>=8.1.0,<9"], "sentence-transformers": ["sentence-transformers>=5.7.0,<6"], "smolagents": ["smolagents>=1.26.0,<2"], "sqlalchemy": ["sqlalchemy>=2.0.51,<3", "alembic>=1.19.1,<2"], "sqlmodel": ["sqlmodel>=0.0.39,<0.1", "sqlalchemy>=2.0.51,<3", "alembic>=1.19.1,<2"], "strands-agents": ["strands-agents>=1.51.0,<2"], "streamlit": ["streamlit>=1.61.1,<2"], "supabase": ["supabase>=2.31.0,<3"], "supabase-auth": ["supabase>=2.31.0,<3"], "textual": ["textual>=8.2.8,<9"], "together": ["together>=2.30.0,<3"], "typer": ["typer>=0.27.1,<1"], "violetear": ["violetear[server]>=1.4.1,<2"], "weaviate": ["weaviate-client>=4.22.0,<5"], "xai": ["xai-sdk>=1.17.0,<2"]} -%} {%- set selected_components = [framework] + (interfaces | default([])) + ([model_provider] if ai_capabilities in ['agents', 'rag', 'inference'] else []) + ([embedding_provider] if ai_capabilities == 'rag' else []) + [sql_store, document_store, vector_store, graph_store, cache_store] - + [sql_abstraction | default('none', true), auth | default('none', true)] + + ([sql_abstraction | default('none', true)] if sql_store not in ['none', 'supabase'] else []) + + [auth | default('none', true)] + [serving | default('none', true)] + (training_extensions | default([])) + (mlops_tools | default([])) @@ -19,7 +20,7 @@ {%- endif -%} {%- endfor -%} {%- endfor %} -{%- if ai_capabilities != 'none' or sql_store != 'none' or document_store != 'none' or auth | default('none', true) != 'none' %} +{%- if ai_capabilities != 'none' or sql_store != 'none' or document_store != 'none' or vector_store != 'none' or graph_store != 'none' or cache_store != 'none' or auth | default('none', true) != 'none' %} "pydantic>=2.13.4,<3", "pydantic-settings>=2.15.0,<3", {%- endif %} diff --git a/template/src/{{ module_name }}/{% if ai_capabilities in ['agents', 'rag', 'inference'] %}providers.py{% endif %}.jinja b/template/src/{{ module_name }}/{% if ai_capabilities in ['agents', 'rag', 'inference'] %}providers.py{% endif %}.jinja new file mode 100644 index 0000000..c662a5f --- /dev/null +++ b/template/src/{{ module_name }}/{% if ai_capabilities in ['agents', 'rag', 'inference'] %}providers.py{% endif %}.jinja @@ -0,0 +1,65 @@ +"""SDK modules for the selected model and embedding providers.""" + +from typing import Any + +{% if model_provider in ['openai', 'azure-openai', 'openai-compatible', 'deepseek'] -%} +import openai as model_sdk_module +{%- elif model_provider == 'anthropic' -%} +import anthropic as model_sdk_module +{%- elif model_provider == 'gemini' -%} +from google import genai as model_sdk_module +{%- elif model_provider == 'bedrock' -%} +import boto3 as model_sdk_module +{%- elif model_provider == 'openrouter' -%} +import openrouter as model_sdk_module +{%- elif model_provider == 'groq' -%} +import groq as model_sdk_module +{%- elif model_provider == 'mistral' -%} +import mistralai as model_sdk_module +{%- elif model_provider == 'cohere' -%} +import cohere as model_sdk_module +{%- elif model_provider == 'huggingface' -%} +import huggingface_hub as model_sdk_module +{%- elif model_provider == 'ollama' -%} +import ollama as model_sdk_module +{%- elif model_provider == 'xai' -%} +import xai_sdk as model_sdk_module +{%- elif model_provider == 'together' -%} +import together as model_sdk_module +{%- elif model_provider == 'fireworks' -%} +import fireworks as model_sdk_module +{%- endif %} +{%- if ai_capabilities == 'rag' and embedding_provider != model_provider -%} +{%- if embedding_provider == 'sentence-transformers' -%} +import sentence_transformers as embedding_sdk_module +{%- elif embedding_provider == 'openai' -%} +import openai as embedding_sdk_module +{%- elif embedding_provider == 'gemini' -%} +from google import genai as embedding_sdk_module +{%- elif embedding_provider == 'cohere' -%} +import cohere as embedding_sdk_module +{%- elif embedding_provider == 'mistral' -%} +import mistralai as embedding_sdk_module +{%- elif embedding_provider == 'huggingface' -%} +import huggingface_hub as embedding_sdk_module +{%- elif embedding_provider == 'ollama' -%} +import ollama as embedding_sdk_module +{%- endif %} +{%- endif %} + + +def model_sdk() -> Any: + """Return the configured model provider SDK module.""" + return model_sdk_module + +{%- if ai_capabilities == 'rag' %} + + +def embedding_sdk() -> Any: + """Return the configured embedding provider SDK module.""" +{%- if embedding_provider == model_provider %} + return model_sdk_module +{%- else %} + return embedding_sdk_module +{%- endif %} +{%- endif %} diff --git a/template/src/{{ module_name }}/{% if auth != 'none' %}auth.py{% endif %}.jinja b/template/src/{{ module_name }}/{% if auth != 'none' %}auth.py{% endif %}.jinja new file mode 100644 index 0000000..873e349 --- /dev/null +++ b/template/src/{{ module_name }}/{% if auth != 'none' %}auth.py{% endif %}.jinja @@ -0,0 +1,38 @@ +"""Authentication helpers for the selected strategy.""" + +{% if auth == 'api-key' -%} +import secrets +{%- elif auth == 'oidc' -%} +from authlib.jose import JsonWebToken +{%- elif auth == 'supabase-auth' -%} +from supabase import Client as SupabaseClient +{%- endif %} + +from {{ module_name }}.settings import settings + +AUTH_STRATEGY = "{{ auth }}" + +{%- if auth == 'api-key' %} + + +def authenticate_api_key(candidate: str) -> bool: + """Compare an API key without leaking timing information.""" + expected = settings.api_key + return expected is not None and secrets.compare_digest( + candidate, expected.get_secret_value() + ) +{%- elif auth == 'oidc' %} + + +def oidc_decoder() -> JsonWebToken: + """Build the JWT decoder; fetch and pass the issuer JWKS at the boundary.""" + assert settings.oidc_issuer and settings.oidc_audience + return JsonWebToken(["RS256", "ES256"]) +{%- elif auth == 'supabase-auth' %} + + +def auth_client_type() -> type[SupabaseClient]: + """Return the configured Supabase client type without opening a connection.""" + assert settings.supabase_url + return SupabaseClient +{%- endif %} diff --git a/template/src/{{ module_name }}/{% if data_config %}data.py{% endif %}.jinja b/template/src/{{ module_name }}/{% if data_config %}data.py{% endif %}.jinja new file mode 100644 index 0000000..87b211c --- /dev/null +++ b/template/src/{{ module_name }}/{% if data_config %}data.py{% endif %}.jinja @@ -0,0 +1,129 @@ +"""Configured data engines without eager network connections.""" + +from __future__ import annotations + +from typing import Any + +{% if sql_store not in ['none', 'supabase'] -%} +import alembic +from sqlalchemy import Engine, create_engine +{% if sql_abstraction == 'sqlmodel' -%} +from sqlmodel import SQLModel +{% else -%} +from sqlalchemy.orm import DeclarativeBase +{% endif -%} +{% endif -%} +{%- if sql_store == 'postgresql' -%} +import psycopg +{%- elif sql_store == 'mysql' -%} +import pymysql +{%- elif sql_store == 'duckdb' -%} +import duckdb +import duckdb_engine +{%- endif %} +{%- if 'supabase' in [sql_store, document_store] -%} +from supabase import Client as SupabaseClient +{%- endif %} +{%- if document_store == 'mongodb' -%} +from pymongo import MongoClient +{%- endif %} +{%- if 'beaver' in [document_store, vector_store, graph_store, cache_store] -%} +from beaver import BeaverDB +{%- endif %} +{%- if vector_store == 'pgvector' -%} +from pgvector.sqlalchemy import Vector +{%- elif vector_store == 'qdrant' -%} +from qdrant_client import QdrantClient +{%- elif vector_store == 'chroma' -%} +import chromadb +{%- elif vector_store == 'lancedb' -%} +import lancedb +{%- elif vector_store == 'weaviate' -%} +import weaviate +{%- elif vector_store == 'pinecone' -%} +from pinecone import Pinecone +{%- elif vector_store == 'milvus' -%} +from pymilvus import MilvusClient +{%- endif %} +{%- if graph_store == 'neo4j' -%} +from neo4j import GraphDatabase +{%- endif %} +{%- if cache_store == 'redis' -%} +from redis import Redis +{%- endif %} +{{ "\n" if sql_store in ['none', 'supabase'] else "" -}} +STORES = { + "sql": "{{ sql_store }}", + "document": "{{ document_store }}", + "vector": "{{ vector_store }}", + "graph": "{{ graph_store }}", + "cache": "{{ cache_store }}", +} + + +def installed_clients() -> tuple[Any, ...]: + """Return imported client types/modules for the selected engines.""" + clients: list[Any] = [] +{%- if sql_store == 'postgresql' %} + clients.extend((alembic, psycopg)) +{%- elif sql_store == 'mysql' %} + clients.extend((alembic, pymysql)) +{%- elif sql_store == 'duckdb' %} + clients.extend((alembic, duckdb, duckdb_engine)) +{%- elif sql_store == 'sqlite' %} + clients.append(alembic) +{%- endif %} +{%- if 'supabase' in [sql_store, document_store] %} + clients.append(SupabaseClient) +{%- endif %} +{%- if document_store == 'mongodb' %} + clients.append(MongoClient) +{%- endif %} +{%- if 'beaver' in [document_store, vector_store, graph_store, cache_store] %} + clients.append(BeaverDB) +{%- endif %} +{%- if vector_store == 'pgvector' %} + clients.append(Vector) +{%- elif vector_store == 'qdrant' %} + clients.append(QdrantClient) +{%- elif vector_store == 'chroma' %} + clients.append(chromadb) +{%- elif vector_store == 'lancedb' %} + clients.append(lancedb) +{%- elif vector_store == 'weaviate' %} + clients.append(weaviate) +{%- elif vector_store == 'pinecone' %} + clients.append(Pinecone) +{%- elif vector_store == 'milvus' %} + clients.append(MilvusClient) +{%- endif %} +{%- if graph_store == 'neo4j' %} + clients.append(GraphDatabase) +{%- endif %} +{%- if cache_store == 'redis' %} + clients.append(Redis) +{%- endif %} + return tuple(clients) + +{%- if sql_store not in ['none', 'supabase'] %} + + +def create_sql_engine(url: str | None = None) -> Engine: + """Create the selected SQLAlchemy-compatible engine.""" + from {{ module_name }}.settings import settings + + return create_engine(url or settings.database_url) + + +def create_schema(engine: Engine) -> None: + """Create tables registered in the selected abstraction metadata.""" +{%- if sql_abstraction == 'sqlmodel' %} + SQLModel.metadata.create_all(engine) +{%- else %} + Base.metadata.create_all(engine) + + +class Base(DeclarativeBase): + """Base class for generated SQLAlchemy models.""" +{%- endif %} +{%- endif %} diff --git a/template/src/{{ module_name }}/{% if ai_capabilities != 'none' %}settings.py{% endif %}.jinja b/template/src/{{ module_name }}/{% if runtime_config %}settings.py{% endif %}.jinja similarity index 61% rename from template/src/{{ module_name }}/{% if ai_capabilities != 'none' %}settings.py{% endif %}.jinja rename to template/src/{{ module_name }}/{% if runtime_config %}settings.py{% endif %}.jinja index d38d8d8..59900f8 100644 --- a/template/src/{{ module_name }}/{% if ai_capabilities != 'none' %}settings.py{% endif %}.jinja +++ b/template/src/{{ module_name }}/{% if runtime_config %}settings.py{% endif %}.jinja @@ -8,6 +8,7 @@ class Settings(BaseSettings): """Configuration loaded from environment variables or a local .env file.""" model_config = SettingsConfigDict(env_file=".env", extra="ignore") +{%- if ai_capabilities != 'none' %} model_provider: str = "{{ model_provider | default('none', true) }}" model_name: str = "{% if model_provider | default('none', true) == 'openai' %}gpt-5-mini{% elif model_provider | default('none', true) == 'anthropic' %}claude-sonnet-4-5{% elif model_provider | default('none', true) == 'gemini' %}gemini-2.5-flash{% elif model_provider | default('none', true) == 'bedrock' %}amazon.nova-lite-v1:0{% elif model_provider | default('none', true) == 'ollama' %}qwen3:4b{% else %}configure-me{% endif %}" @@ -17,6 +18,21 @@ class Settings(BaseSettings): embedding_provider: str = "{{ embedding_provider }}" embedding_model: str = "{% if embedding_provider == 'sentence-transformers' %}sentence-transformers/all-MiniLM-L6-v2{% else %}configure-me{% endif %}" {%- endif %} +{%- endif %} +{%- if sql_store != 'none' %} + + database_url: str = "{% if sql_store == 'sqlite' %}sqlite:///app.db{% elif sql_store == 'postgresql' %}postgresql+psycopg://postgres:postgres@localhost:5432/app{% elif sql_store == 'mysql' %}mysql+pymysql://root:password@localhost:3306/app{% elif sql_store == 'duckdb' %}duckdb:///app.duckdb{% else %}configure-me{% endif %}" +{%- endif %} +{%- if 'supabase' in [sql_store, document_store] or auth == 'supabase-auth' %} + supabase_url: str = "http://127.0.0.1:54321" + supabase_key: SecretStr | None = None +{%- endif %} +{%- if auth == 'api-key' %} + api_key: SecretStr | None = None +{%- elif auth == 'oidc' %} + oidc_issuer: str = "https://issuer.example.com" + oidc_audience: str = "{{ project_slug }}" +{%- endif %} settings = Settings() diff --git a/template/src/{{ module_name }}/{% if workload == 'agent' %}agent.py{% endif %}.jinja b/template/src/{{ module_name }}/{% if workload == 'agent' %}agent.py{% endif %}.jinja index 6cd89ed..bec7305 100644 --- a/template/src/{{ module_name }}/{% if workload == 'agent' %}agent.py{% endif %}.jinja +++ b/template/src/{{ module_name }}/{% if workload == 'agent' %}agent.py{% endif %}.jinja @@ -2,28 +2,44 @@ from __future__ import annotations -import importlib from dataclasses import dataclass from typing import Any +{% if framework == 'pydantic-ai' and pydantic_ai_harness | default([]) -%} +import pydantic_ai as framework_module +import pydantic_ai_harness +{%- elif framework == 'pydantic-ai' -%} +import pydantic_ai as framework_module +{%- elif framework == 'langgraph' -%} +import langgraph as framework_module +{%- elif framework == 'langchain' -%} +import langchain as framework_module +{%- elif framework == 'llamaindex' -%} +import llama_index as framework_module +{%- elif framework == 'crewai' -%} +import crewai as framework_module +{%- elif framework == 'smolagents' -%} +import smolagents as framework_module +{%- elif framework == 'dspy' -%} +import dspy as framework_module +{%- elif framework == 'openai-agents' -%} +import agents as framework_module +{%- elif framework == 'google-adk' -%} +import google.adk as framework_module +{%- elif framework == 'strands-agents' -%} +import strands as framework_module +{%- elif framework == 'autogen' -%} +import autogen_agentchat as framework_module +{%- elif framework == 'ag2' -%} +import autogen as framework_module +{%- elif framework == 'agno' -%} +import agno as framework_module +{%- elif framework == 'lingo' -%} +import lingo as framework_module +{%- endif %} + FRAMEWORK = "{{ framework }}" PROVIDER = "{{ model_provider }}" -FRAMEWORK_MODULES = { - "pydantic-ai": "pydantic_ai", - "langgraph": "langgraph", - "langchain": "langchain", - "llamaindex": "llama_index", - "crewai": "crewai", - "smolagents": "smolagents", - "dspy": "dspy", - "openai-agents": "agents", - "google-adk": "google.adk", - "strands-agents": "strands", - "autogen": "autogen_agentchat", - "ag2": "autogen", - "agno": "agno", - "lingo": "lingo", -} @dataclass(frozen=True) @@ -40,7 +56,7 @@ class LocalAgent: def configured_framework() -> Any: """Import and return the configured framework's public module.""" - return importlib.import_module(FRAMEWORK_MODULES[FRAMEWORK]) + return framework_module def build_agent() -> LocalAgent: @@ -51,6 +67,9 @@ def build_agent() -> LocalAgent: def harness_capabilities() -> tuple[str, ...]: """Return the selected optional PydanticAI Harness integrations.""" +{%- if framework == 'pydantic-ai' and pydantic_ai_harness | default([]) %} + assert pydantic_ai_harness is not None +{%- endif %} return ({% for capability in pydantic_ai_harness | default([]) %}"{{ capability }}"{% if not loop.last %}, {% endif %}{% endfor %}{% if pydantic_ai_harness | default([]) | length == 1 %},{% endif %}) diff --git a/template/src/{{ module_name }}/{% if workload == 'rag' %}rag.py{% endif %}.jinja b/template/src/{{ module_name }}/{% if workload == 'rag' %}rag.py{% endif %}.jinja index a40cc34..d88edc1 100644 --- a/template/src/{{ module_name }}/{% if workload == 'rag' %}rag.py{% endif %}.jinja +++ b/template/src/{{ module_name }}/{% if workload == 'rag' %}rag.py{% endif %}.jinja @@ -2,15 +2,15 @@ from __future__ import annotations -import importlib from dataclasses import dataclass, field -FRAMEWORK = "{{ framework }}" -FRAMEWORK_MODULES = { - "langchain": "langchain", - "llamaindex": "llama_index", - "haystack": "haystack", -} +{% if framework == 'langchain' -%} +import langchain as framework_module +{%- elif framework == 'llamaindex' -%} +import llama_index as framework_module +{%- elif framework == 'haystack' -%} +import haystack as framework_module +{%- endif %} @dataclass @@ -36,7 +36,7 @@ class InMemoryRAG: def build_rag() -> InMemoryRAG: """Validate the selected framework and create the offline retriever.""" - importlib.import_module(FRAMEWORK_MODULES[FRAMEWORK]) + assert framework_module is not None return InMemoryRAG() diff --git a/template/tests/{% if auth != 'none' %}test_auth.py{% endif %}.jinja b/template/tests/{% if auth != 'none' %}test_auth.py{% endif %}.jinja new file mode 100644 index 0000000..18e03b4 --- /dev/null +++ b/template/tests/{% if auth != 'none' %}test_auth.py{% endif %}.jinja @@ -0,0 +1,7 @@ +"""Tests for the selected authentication strategy.""" + +from {{ module_name }}.auth import AUTH_STRATEGY + + +def test_auth_strategy_is_recorded() -> None: + assert AUTH_STRATEGY == "{{ auth }}" diff --git a/template/tests/{% if data_config %}test_data.py{% endif %}.jinja b/template/tests/{% if data_config %}test_data.py{% endif %}.jinja new file mode 100644 index 0000000..e7bace1 --- /dev/null +++ b/template/tests/{% if data_config %}test_data.py{% endif %}.jinja @@ -0,0 +1,14 @@ +"""Tests for the selected data engines.""" + +from {{ module_name }}.data import STORES, installed_clients + + +def test_selected_data_engines_are_recorded() -> None: + assert STORES == { + "sql": "{{ sql_store }}", + "document": "{{ document_store }}", + "vector": "{{ vector_store }}", + "graph": "{{ graph_store }}", + "cache": "{{ cache_store }}", + } + assert isinstance(installed_clients(), tuple) diff --git a/template/{% if ai_capabilities != 'none' %}.env.example{% endif %}.jinja b/template/{% if runtime_config %}.env.example{% endif %}.jinja similarity index 57% rename from template/{% if ai_capabilities != 'none' %}.env.example{% endif %}.jinja rename to template/{% if runtime_config %}.env.example{% endif %}.jinja index 3578891..abae979 100644 --- a/template/{% if ai_capabilities != 'none' %}.env.example{% endif %}.jinja +++ b/template/{% if runtime_config %}.env.example{% endif %}.jinja @@ -1,4 +1,5 @@ # Copy this file to .env. Never commit real credentials. +{%- if ai_capabilities != 'none' %} MODEL_PROVIDER={{ model_provider | default('none', true) }} MODEL_NAME={% if model_provider | default('none', true) == 'openai' %}gpt-5-mini{% elif model_provider | default('none', true) == 'anthropic' %}claude-sonnet-4-5{% elif model_provider | default('none', true) == 'gemini' %}gemini-2.5-flash{% elif model_provider | default('none', true) == 'bedrock' %}amazon.nova-lite-v1:0{% elif model_provider | default('none', true) == 'ollama' %}qwen3:4b{% else %}configure-me{% endif %} MODEL_API_KEY= @@ -7,3 +8,17 @@ MODEL_BASE_URL={% if model_provider | default('none', true) == 'ollama' %}http:/ EMBEDDING_PROVIDER={{ embedding_provider }} EMBEDDING_MODEL={% if embedding_provider == 'sentence-transformers' %}sentence-transformers/all-MiniLM-L6-v2{% else %}configure-me{% endif %} {%- endif %} +{%- endif %} +{%- if sql_store != 'none' %} +DATABASE_URL={% if sql_store == 'sqlite' %}sqlite:///app.db{% elif sql_store == 'postgresql' %}postgresql+psycopg://postgres:postgres@localhost:5432/app{% elif sql_store == 'mysql' %}mysql+pymysql://root:password@localhost:3306/app{% elif sql_store == 'duckdb' %}duckdb:///app.duckdb{% endif %} +{%- endif %} +{%- if 'supabase' in [sql_store, document_store] or auth == 'supabase-auth' %} +SUPABASE_URL=http://127.0.0.1:54321 +SUPABASE_KEY= +{%- endif %} +{%- if auth == 'api-key' %} +API_KEY= +{%- elif auth == 'oidc' %} +OIDC_ISSUER=https://issuer.example.com +OIDC_AUDIENCE={{ project_slug }} +{%- endif %} diff --git a/template/{% if sql_store not in ['none', 'supabase'] %}alembic.ini{% endif %}.jinja b/template/{% if sql_store not in ['none', 'supabase'] %}alembic.ini{% endif %}.jinja new file mode 100644 index 0000000..8b35aea --- /dev/null +++ b/template/{% if sql_store not in ['none', 'supabase'] %}alembic.ini{% endif %}.jinja @@ -0,0 +1,37 @@ +[alembic] +script_location = migrations +prepend_sys_path = . +sqlalchemy.url = {% if sql_store == 'sqlite' %}sqlite:///app.db{% elif sql_store == 'postgresql' %}postgresql+psycopg://postgres:postgres@localhost:5432/app{% elif sql_store == 'mysql' %}mysql+pymysql://root:password@localhost:3306/app{% elif sql_store == 'duckdb' %}duckdb:///app.duckdb{% endif %} + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s diff --git a/template/{% if sql_store not in ['none', 'supabase'] %}migrations{% endif %}/env.py.jinja b/template/{% if sql_store not in ['none', 'supabase'] %}migrations{% endif %}/env.py.jinja new file mode 100644 index 0000000..b3c1dea --- /dev/null +++ b/template/{% if sql_store not in ['none', 'supabase'] %}migrations{% endif %}/env.py.jinja @@ -0,0 +1,40 @@ +"""Alembic environment for the generated SQL layer.""" + +from logging.config import fileConfig + +from alembic import context + +from {{ module_name }}.data import {% if sql_abstraction == 'sqlmodel' %}SQLModel{% else %}Base{% endif %} +from {{ module_name }}.settings import settings + +config = context.config +config.set_main_option("sqlalchemy.url", settings.database_url) +if config.config_file_name: + fileConfig(config.config_file_name) + +target_metadata = {% if sql_abstraction == 'sqlmodel' %}SQLModel.metadata{% else %}Base.metadata{% endif %} + + +def run_migrations_offline() -> None: + context.configure( + url=settings.database_url, + target_metadata=target_metadata, + literal_binds=True, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + from {{ module_name }}.data import create_sql_engine + + with create_sql_engine().connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/template/{% if sql_store not in ['none', 'supabase'] %}migrations{% endif %}/script.py.mako.jinja b/template/{% if sql_store not in ['none', 'supabase'] %}migrations{% endif %}/script.py.mako.jinja new file mode 100644 index 0000000..5d6b69d --- /dev/null +++ b/template/{% if sql_store not in ['none', 'supabase'] %}migrations{% endif %}/script.py.mako.jinja @@ -0,0 +1,25 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +revision: str = ${repr(up_revision)} +down_revision: str | Sequence[str] | None = ${repr(down_revision)} +branch_labels: str | Sequence[str] | None = ${repr(branch_labels)} +depends_on: str | Sequence[str] | None = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/template/{% if sql_store not in ['none', 'supabase'] %}migrations{% endif %}/versions/.gitkeep.jinja b/template/{% if sql_store not in ['none', 'supabase'] %}migrations{% endif %}/versions/.gitkeep.jinja new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_ai_presets.py b/tests/test_ai_presets.py index c199839..beb71ba 100644 --- a/tests/test_ai_presets.py +++ b/tests/test_ai_presets.py @@ -16,10 +16,12 @@ ], "llamaindex-rag": [ "src/demo_project/rag.py", + "src/demo_project/data.py", "src/demo_project/interfaces/gradio_app.py", ], "local-lingo-app": [ "src/demo_project/agent.py", + "src/demo_project/data.py", "src/demo_project/interfaces/violetear_app.py", ], } @@ -50,4 +52,33 @@ def test_ai_preset_vertical_slice( run([uv, "run", "ruff", "format", "--check", "."], project), "ruff format", ) + assert_ok(run([uv, "run", "deptry", "src"], project), "deptry") + assert_ok(run([uv, "run", "pytest", "-q"], project), "pytest") + + +@pytest.mark.preset +def test_data_and_auth_vertical_slice(copie, uv: str) -> None: + """The SQLModel, Alembic and API-key layers pass the generated gate.""" + result = copie.copy( + extra_answers=answers( + preset="custom", + workload="api", + framework="fastapi", + sql_store="sqlite", + sql_abstraction="sqlmodel", + auth="api-key", + use_docs=False, + use_codeql=False, + use_docker=False, + ) + ) + assert result.exception is None, result.exception + project = result.project_dir + + assert_ok(run([uv, "run", "ruff", "check", "."], project), "ruff check") + assert_ok( + run([uv, "run", "ruff", "format", "--check", "."], project), + "ruff format", + ) + assert_ok(run([uv, "run", "deptry", "src"], project), "deptry") assert_ok(run([uv, "run", "pytest", "-q"], project), "pytest") diff --git a/tests/test_catalog.py b/tests/test_catalog.py index a180356..dbdf896 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py @@ -55,3 +55,12 @@ def test_compiled_dependency_template_contains_every_catalog_package() -> None: for component in source["components"]: for package in component.get("packages", []): assert package in compiled, (component["id"], package) + + +def test_template_paths_fit_windows_checkout_limits() -> None: + longest = max( + (path.relative_to(REPO).as_posix() for path in (REPO / "template").rglob("*")), + key=len, + ) + # Hosted Actions adds roughly 40 characters before the repository path. + assert len(longest) < 200, longest diff --git a/tests/test_generation.py b/tests/test_generation.py index becbe3b..c815e0c 100644 --- a/tests/test_generation.py +++ b/tests/test_generation.py @@ -108,6 +108,29 @@ def test_flask_is_available_for_a_simple_api(copie) -> None: assert "app.test_client()" in tests +def test_sql_and_auth_layers_generate_together(copie) -> None: + project = copie.copy( + extra_answers=answers( + preset="custom", + workload="api", + framework="fastapi", + sql_store="sqlite", + sql_abstraction="sqlmodel", + auth="api-key", + ) + ).project_dir + + package = project / "src" / "demo_project" + pyproject = (project / "pyproject.toml").read_text() + assert (package / "data.py").is_file() + assert (package / "auth.py").is_file() + assert (package / "settings.py").is_file() + assert (project / "alembic.ini").is_file() + assert (project / "migrations" / "env.py").is_file() + assert '"sqlmodel>=0.0.39,<0.1"' in pyproject + assert '"alembic>=1.19.1,<2"' in pyproject + + def test_optional_features_are_omitted(copie) -> None: """Turning features off removes their files entirely.""" result = copie.copy(