From 81f27d3a016e2bc1a84bfde7aebcc347aa2b6be0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leynier=20Guti=C3=A9rrez=20Gonz=C3=A1lez?= Date: Sat, 8 Aug 2026 17:12:14 -0600 Subject: [PATCH] feat: add AI frameworks and interfaces --- .github/workflows/ci.yml | 23 ++- catalog/components.yml | 69 +++++--- catalog/generated/catalog.json | 167 +++++++++++++----- copier.yml | 4 + docs/reference/components.md | 4 +- pyproject.toml | 1 + scripts/compile_catalog.py | 41 +++++ template/_catalog_dependencies.jinja | 28 +++ template/pyproject.toml.jinja | 34 ++-- ...erfaces %}chainlit_app.py{% endif %}.jinja | 9 + ...terfaces %}fastapi_app.py{% endif %}.jinja | 42 +++++ ...erfaces %}fasthtml_app.py{% endif %}.jinja | 20 +++ ...interfaces %}flask_app.py{% endif %}.jinja | 34 ++++ ...nterfaces %}gradio_app.py{% endif %}.jinja | 17 ++ ...terfaces %}nicegui_app.py{% endif %}.jinja | 19 ++ ...rfaces %}streamlit_app.py{% endif %}.jinja | 13 ++ ...terfaces %}textual_app.py{% endif %}.jinja | 22 +++ ...rfaces %}violetear_app.py{% endif %}.jinja | 26 +++ ...s != 'none' %}settings.py{% endif %}.jinja | 22 +++ .../__init__.py.jinja | 5 + ...oad == 'agent' %}agent.py{% endif %}.jinja | 63 +++++++ ...orkload == 'api' %}api.py{% endif %}.jinja | 12 ++ ...orkload == 'mcp' %}mcp.py{% endif %}.jinja | 20 +++ ...orkload == 'rag' %}rag.py{% endif %}.jinja | 51 ++++++ .../tests/test_{{ module_name }}.py.jinja | 31 ++++ ...aces %}test_interfaces.py{% endif %}.jinja | 7 + ... != 'none' %}.env.example{% endif %}.jinja | 9 + tests/test_ai_presets.py | 53 ++++++ tests/test_catalog.py | 9 + tests/test_generation.py | 6 +- 30 files changed, 774 insertions(+), 87 deletions(-) create mode 100644 template/_catalog_dependencies.jinja create mode 100644 template/src/{{ module_name }}/{% if 'chainlit' in interfaces %}interfaces{% endif %}/{% if 'chainlit' in interfaces %}chainlit_app.py{% endif %}.jinja create mode 100644 template/src/{{ module_name }}/{% if 'fastapi' in interfaces %}interfaces{% endif %}/{% if 'fastapi' in interfaces %}fastapi_app.py{% endif %}.jinja create mode 100644 template/src/{{ module_name }}/{% if 'fasthtml' in interfaces %}interfaces{% endif %}/{% if 'fasthtml' in interfaces %}fasthtml_app.py{% endif %}.jinja create mode 100644 template/src/{{ module_name }}/{% if 'flask' in interfaces %}interfaces{% endif %}/{% if 'flask' in interfaces %}flask_app.py{% endif %}.jinja create mode 100644 template/src/{{ module_name }}/{% if 'gradio' in interfaces %}interfaces{% endif %}/{% if 'gradio' in interfaces %}gradio_app.py{% endif %}.jinja create mode 100644 template/src/{{ module_name }}/{% if 'nicegui' in interfaces %}interfaces{% endif %}/{% if 'nicegui' in interfaces %}nicegui_app.py{% endif %}.jinja create mode 100644 template/src/{{ module_name }}/{% if 'streamlit' in interfaces %}interfaces{% endif %}/{% if 'streamlit' in interfaces %}streamlit_app.py{% endif %}.jinja create mode 100644 template/src/{{ module_name }}/{% if 'textual' in interfaces %}interfaces{% endif %}/{% if 'textual' in interfaces %}textual_app.py{% endif %}.jinja create mode 100644 template/src/{{ module_name }}/{% if 'violetear' in interfaces %}interfaces{% endif %}/{% if 'violetear' in interfaces %}violetear_app.py{% endif %}.jinja create mode 100644 template/src/{{ module_name }}/{% if ai_capabilities != 'none' %}settings.py{% endif %}.jinja create mode 100644 template/src/{{ module_name }}/{% if interfaces %}interfaces{% endif %}/__init__.py.jinja create mode 100644 template/src/{{ module_name }}/{% if workload == 'agent' %}agent.py{% endif %}.jinja create mode 100644 template/src/{{ module_name }}/{% if workload == 'mcp' %}mcp.py{% endif %}.jinja create mode 100644 template/src/{{ module_name }}/{% if workload == 'rag' %}rag.py{% endif %}.jinja create mode 100644 template/tests/{% if interfaces %}test_interfaces.py{% endif %}.jinja create mode 100644 template/{% if ai_capabilities != 'none' %}.env.example{% endif %}.jinja create mode 100644 tests/test_ai_presets.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 21e11eb..7d60f72 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,7 +58,26 @@ jobs: - run: uv sync --locked --all-groups - name: Fast tests - run: uv run pytest -m "not slow" -n auto + run: uv run pytest -m "not slow and not preset" -n auto + + presets: + name: AI preset vertical slices + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - uses: astral-sh/setup-uv@v9.0.0 + with: + python-version: "3.13" + enable-cache: true + + - run: uv sync --locked --all-groups + + - name: Generate and test affected presets offline + run: uv run pytest -m preset -n auto --basetemp="${{ runner.temp }}/pytest-presets" toolchain: name: Generated project / ${{ matrix.os }} @@ -112,7 +131,7 @@ jobs: ci: name: CI runs-on: ubuntu-latest - needs: [lint, structure, toolchain, zizmor] + needs: [lint, structure, presets, toolchain, zizmor] if: always() steps: - name: Verify every job succeeded diff --git a/catalog/components.yml b/catalog/components.yml index f4c1352..e79089e 100644 --- a/catalog/components.yml +++ b/catalog/components.yml @@ -73,7 +73,7 @@ components: tier: stable workloads: [api] python: ">=3.11,<3.15" - packages: ["fastapi[standard]>=0.141.1,<1", "pydantic>=2.13.4,<3"] + packages: ["fastapi[standard]>=0.141.1,<1", "pydantic>=2.13.4,<3", "uvicorn>=0.52.1,<1"] - id: flask name: Flask layer: framework @@ -94,90 +94,105 @@ components: tier: stable workloads: [agent] python: ">=3.11,<3.15" + packages: ["pydantic-ai>=2.27.0,<3"] - id: langgraph name: LangGraph layer: framework tier: stable workloads: [agent] python: ">=3.11,<3.15" + packages: ["langgraph>=1.2.10,<2"] - id: langchain name: LangChain layer: framework tier: stable workloads: [agent, rag] python: ">=3.11,<3.15" + packages: ["langchain>=1.3.14,<2"] - id: llamaindex name: LlamaIndex layer: framework tier: stable workloads: [agent, rag] python: ">=3.11,<3.15" + packages: ["llama-index>=0.14.23,<0.15"] - id: crewai name: CrewAI layer: framework tier: stable workloads: [agent] - python: ">=3.11,<3.15" + python: ">=3.11,<3.14" + packages: ["crewai>=1.15.13,<2"] - id: smolagents name: Hugging Face smolagents layer: framework tier: stable workloads: [agent] python: ">=3.11,<3.15" + packages: ["smolagents>=1.26.0,<2"] - id: dspy name: DSPy layer: framework tier: stable workloads: [agent] python: ">=3.11,<3.15" + packages: ["dspy>=3.3.0,<4"] - id: openai-agents name: OpenAI Agents SDK layer: framework tier: platform workloads: [agent] python: ">=3.11,<3.15" + packages: ["openai-agents>=0.19.4,<1"] - id: google-adk name: Google ADK layer: framework tier: platform workloads: [agent] python: ">=3.11,<3.15" + packages: ["google-adk>=2.6.3,<3"] - id: strands-agents name: Strands Agents layer: framework tier: platform workloads: [agent] python: ">=3.11,<3.15" + packages: ["strands-agents>=1.51.0,<2"] - id: autogen name: Microsoft AutoGen layer: framework tier: stable workloads: [agent] python: ">=3.11,<3.15" + packages: ["autogen-agentchat>=0.7.5,<1"] - id: ag2 name: AG2 layer: framework tier: stable workloads: [agent] python: ">=3.11,<3.15" + packages: ["ag2>=1.0.1,<2"] - id: agno name: Agno layer: framework tier: stable workloads: [agent] python: ">=3.11,<3.15" + packages: ["agno>=2.8.7,<3"] - id: lingo name: Lingo layer: framework tier: stable workloads: [agent] python: ">=3.12,<3.15" + packages: ["lingo-ai>=2.1.0,<3"] - id: haystack name: Haystack layer: framework tier: stable workloads: [rag] python: ">=3.11,<3.15" + packages: ["haystack-ai>=3.0.0,<4"] - id: pytorch name: PyTorch layer: framework @@ -217,33 +232,33 @@ components: # Interfaces are composable and can expose the same application through # multiple entrypoints. - - {id: streamlit, name: Streamlit, layer: interface, tier: stable, python: ">=3.11,<3.15"} - - {id: gradio, name: Gradio, layer: interface, tier: stable, python: ">=3.11,<3.15"} - - {id: chainlit, name: Chainlit, layer: interface, tier: stable, python: ">=3.11,<3.15"} - - {id: textual, name: Textual, layer: interface, tier: stable, python: ">=3.11,<3.15"} - - {id: nicegui, name: NiceGUI, layer: interface, tier: stable, python: ">=3.11,<3.15"} - - {id: fasthtml, name: FastHTML, layer: interface, tier: stable, python: ">=3.11,<3.15"} - - {id: violetear, name: Violetear, layer: interface, tier: stable, python: ">=3.12,<3.15"} - - {id: jupyterlab, name: JupyterLab, layer: interface, tier: stable, python: ">=3.11,<3.15"} + - {id: streamlit, name: Streamlit, layer: interface, tier: stable, python: ">=3.11,<3.15", packages: ["streamlit>=1.61.1,<2"]} + - {id: gradio, name: Gradio, layer: interface, tier: stable, python: ">=3.11,<3.15", packages: ["gradio>=6.22.0,<7"]} + - {id: chainlit, name: Chainlit, layer: interface, tier: stable, python: ">=3.11,<3.14", packages: ["chainlit>=2.11.1,<3"]} + - {id: textual, name: Textual, layer: interface, tier: stable, python: ">=3.11,<3.15", packages: ["textual>=8.2.8,<9"]} + - {id: nicegui, name: NiceGUI, layer: interface, tier: stable, python: ">=3.11,<3.15", packages: ["nicegui>=3.15.0,<4"]} + - {id: fasthtml, name: FastHTML, layer: interface, tier: stable, python: ">=3.11,<3.15", packages: ["python-fasthtml>=0.14.11,<0.15"]} + - {id: violetear, name: Violetear, layer: interface, tier: stable, python: ">=3.12,<3.15", packages: ["violetear[server]>=1.4.1,<2"]} + - {id: jupyterlab, name: JupyterLab, layer: interface, tier: stable, python: ">=3.11,<3.15", packages: ["jupyterlab>=4.6.2,<5"]} # Providers are intentionally independent for generation and embeddings. - - {id: openai, name: OpenAI, layer: model_provider, tier: platform, python: ">=3.11,<3.15"} - - {id: anthropic, name: Anthropic, layer: model_provider, tier: platform, python: ">=3.11,<3.15"} - - {id: gemini, name: Google Gemini, layer: model_provider, tier: platform, python: ">=3.11,<3.15"} - - {id: azure-openai, name: Azure OpenAI, layer: model_provider, tier: platform, python: ">=3.11,<3.15"} - - {id: bedrock, name: AWS Bedrock, layer: model_provider, tier: platform, python: ">=3.11,<3.15"} - - {id: openrouter, name: OpenRouter, layer: model_provider, tier: platform, python: ">=3.11,<3.15"} - - {id: groq, name: Groq, layer: model_provider, tier: platform, python: ">=3.11,<3.15"} - - {id: mistral, name: Mistral, layer: model_provider, tier: platform, python: ">=3.11,<3.15"} - - {id: cohere, name: Cohere, layer: model_provider, tier: platform, python: ">=3.11,<3.15"} - - {id: huggingface, name: Hugging Face, layer: model_provider, tier: platform, python: ">=3.11,<3.15"} - - {id: ollama, name: Ollama, layer: model_provider, tier: stable, python: ">=3.11,<3.15"} - - {id: openai-compatible, name: OpenAI-compatible endpoint, layer: model_provider, tier: platform, python: ">=3.11,<3.15"} - - {id: xai, name: xAI, layer: model_provider, tier: platform, python: ">=3.11,<3.15"} - - {id: deepseek, name: DeepSeek, layer: model_provider, tier: platform, python: ">=3.11,<3.15"} - - {id: together, name: Together AI, layer: model_provider, tier: platform, python: ">=3.11,<3.15"} - - {id: fireworks, name: Fireworks AI, layer: model_provider, tier: platform, python: ">=3.11,<3.15"} - - {id: sentence-transformers, name: Sentence Transformers, layer: embedding_provider, tier: stable, python: ">=3.11,<3.15"} + - {id: openai, name: OpenAI, layer: model_provider, tier: platform, python: ">=3.11,<3.15", packages: ["openai>=2.53.0,<3"]} + - {id: anthropic, name: Anthropic, layer: model_provider, tier: platform, python: ">=3.11,<3.15", packages: ["anthropic>=0.121.0,<1"]} + - {id: gemini, name: Google Gemini, layer: model_provider, tier: platform, python: ">=3.11,<3.15", packages: ["google-genai>=2.17.0,<3"]} + - {id: azure-openai, name: Azure OpenAI, layer: model_provider, tier: platform, python: ">=3.11,<3.15", packages: ["openai>=2.53.0,<3"]} + - {id: bedrock, name: AWS Bedrock, layer: model_provider, tier: platform, python: ">=3.11,<3.15", packages: ["boto3>=1.43.67,<2"]} + - {id: openrouter, name: OpenRouter, layer: model_provider, tier: platform, python: ">=3.11,<3.15", packages: ["openrouter>=1.1.37,<2"]} + - {id: groq, name: Groq, layer: model_provider, tier: platform, python: ">=3.11,<3.15", packages: ["groq>=1.6.0,<2"]} + - {id: mistral, name: Mistral, layer: model_provider, tier: platform, python: ">=3.11,<3.15", packages: ["mistralai>=2.9.1,<3"]} + - {id: cohere, name: Cohere, layer: model_provider, tier: platform, python: ">=3.11,<3.15", packages: ["cohere>=7.0.8,<8"]} + - {id: huggingface, name: Hugging Face, layer: model_provider, tier: platform, python: ">=3.11,<3.15", packages: ["huggingface-hub>=1.27.0,<2"]} + - {id: ollama, name: Ollama, layer: model_provider, tier: stable, python: ">=3.11,<3.15", packages: ["ollama>=0.6.2,<1"]} + - {id: openai-compatible, name: OpenAI-compatible endpoint, layer: model_provider, tier: platform, python: ">=3.11,<3.15", packages: ["openai>=2.53.0,<3"]} + - {id: xai, name: xAI, layer: model_provider, tier: platform, python: ">=3.11,<3.15", packages: ["xai-sdk>=1.17.0,<2"]} + - {id: deepseek, name: DeepSeek, layer: model_provider, tier: platform, python: ">=3.11,<3.15", packages: ["openai>=2.53.0,<3"]} + - {id: together, name: Together AI, layer: model_provider, tier: platform, python: ">=3.11,<3.15", packages: ["together>=2.30.0,<3"]} + - {id: fireworks, name: Fireworks AI, layer: model_provider, tier: platform, python: ">=3.11,<3.15", packages: ["fireworks-ai>=1.2.6,<2"]} + - {id: sentence-transformers, name: Sentence Transformers, layer: embedding_provider, tier: stable, python: ">=3.11,<3.15", packages: ["sentence-transformers>=5.7.0,<6"]} # 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"} diff --git a/catalog/generated/catalog.json b/catalog/generated/catalog.json index 8e431d5..5126a8c 100644 --- a/catalog/generated/catalog.json +++ b/catalog/generated/catalog.json @@ -137,7 +137,8 @@ "roles": [], "packages": [ "fastapi[standard]>=0.141.1,<1", - "pydantic>=2.13.4,<3" + "pydantic>=2.13.4,<3", + "uvicorn>=0.52.1,<1" ] }, { @@ -178,7 +179,9 @@ "agent" ], "roles": [], - "packages": [] + "packages": [ + "pydantic-ai>=2.27.0,<3" + ] }, { "id": "langgraph", @@ -190,7 +193,9 @@ "agent" ], "roles": [], - "packages": [] + "packages": [ + "langgraph>=1.2.10,<2" + ] }, { "id": "langchain", @@ -203,7 +208,9 @@ "rag" ], "roles": [], - "packages": [] + "packages": [ + "langchain>=1.3.14,<2" + ] }, { "id": "llamaindex", @@ -216,19 +223,23 @@ "rag" ], "roles": [], - "packages": [] + "packages": [ + "llama-index>=0.14.23,<0.15" + ] }, { "id": "crewai", "name": "CrewAI", "layer": "framework", "tier": "stable", - "python": ">=3.11,<3.15", + "python": ">=3.11,<3.14", "workloads": [ "agent" ], "roles": [], - "packages": [] + "packages": [ + "crewai>=1.15.13,<2" + ] }, { "id": "smolagents", @@ -240,7 +251,9 @@ "agent" ], "roles": [], - "packages": [] + "packages": [ + "smolagents>=1.26.0,<2" + ] }, { "id": "dspy", @@ -252,7 +265,9 @@ "agent" ], "roles": [], - "packages": [] + "packages": [ + "dspy>=3.3.0,<4" + ] }, { "id": "openai-agents", @@ -264,7 +279,9 @@ "agent" ], "roles": [], - "packages": [] + "packages": [ + "openai-agents>=0.19.4,<1" + ] }, { "id": "google-adk", @@ -276,7 +293,9 @@ "agent" ], "roles": [], - "packages": [] + "packages": [ + "google-adk>=2.6.3,<3" + ] }, { "id": "strands-agents", @@ -288,7 +307,9 @@ "agent" ], "roles": [], - "packages": [] + "packages": [ + "strands-agents>=1.51.0,<2" + ] }, { "id": "autogen", @@ -300,7 +321,9 @@ "agent" ], "roles": [], - "packages": [] + "packages": [ + "autogen-agentchat>=0.7.5,<1" + ] }, { "id": "ag2", @@ -312,7 +335,9 @@ "agent" ], "roles": [], - "packages": [] + "packages": [ + "ag2>=1.0.1,<2" + ] }, { "id": "agno", @@ -324,7 +349,9 @@ "agent" ], "roles": [], - "packages": [] + "packages": [ + "agno>=2.8.7,<3" + ] }, { "id": "lingo", @@ -336,7 +363,9 @@ "agent" ], "roles": [], - "packages": [] + "packages": [ + "lingo-ai>=2.1.0,<3" + ] }, { "id": "haystack", @@ -348,7 +377,9 @@ "rag" ], "roles": [], - "packages": [] + "packages": [ + "haystack-ai>=3.0.0,<4" + ] }, { "id": "pytorch", @@ -436,7 +467,9 @@ "python": ">=3.11,<3.15", "workloads": [], "roles": [], - "packages": [] + "packages": [ + "streamlit>=1.61.1,<2" + ] }, { "id": "gradio", @@ -446,17 +479,21 @@ "python": ">=3.11,<3.15", "workloads": [], "roles": [], - "packages": [] + "packages": [ + "gradio>=6.22.0,<7" + ] }, { "id": "chainlit", "name": "Chainlit", "layer": "interface", "tier": "stable", - "python": ">=3.11,<3.15", + "python": ">=3.11,<3.14", "workloads": [], "roles": [], - "packages": [] + "packages": [ + "chainlit>=2.11.1,<3" + ] }, { "id": "textual", @@ -466,7 +503,9 @@ "python": ">=3.11,<3.15", "workloads": [], "roles": [], - "packages": [] + "packages": [ + "textual>=8.2.8,<9" + ] }, { "id": "nicegui", @@ -476,7 +515,9 @@ "python": ">=3.11,<3.15", "workloads": [], "roles": [], - "packages": [] + "packages": [ + "nicegui>=3.15.0,<4" + ] }, { "id": "fasthtml", @@ -486,7 +527,9 @@ "python": ">=3.11,<3.15", "workloads": [], "roles": [], - "packages": [] + "packages": [ + "python-fasthtml>=0.14.11,<0.15" + ] }, { "id": "violetear", @@ -496,7 +539,9 @@ "python": ">=3.12,<3.15", "workloads": [], "roles": [], - "packages": [] + "packages": [ + "violetear[server]>=1.4.1,<2" + ] }, { "id": "jupyterlab", @@ -506,7 +551,9 @@ "python": ">=3.11,<3.15", "workloads": [], "roles": [], - "packages": [] + "packages": [ + "jupyterlab>=4.6.2,<5" + ] }, { "id": "openai", @@ -516,7 +563,9 @@ "python": ">=3.11,<3.15", "workloads": [], "roles": [], - "packages": [] + "packages": [ + "openai>=2.53.0,<3" + ] }, { "id": "anthropic", @@ -526,7 +575,9 @@ "python": ">=3.11,<3.15", "workloads": [], "roles": [], - "packages": [] + "packages": [ + "anthropic>=0.121.0,<1" + ] }, { "id": "gemini", @@ -536,7 +587,9 @@ "python": ">=3.11,<3.15", "workloads": [], "roles": [], - "packages": [] + "packages": [ + "google-genai>=2.17.0,<3" + ] }, { "id": "azure-openai", @@ -546,7 +599,9 @@ "python": ">=3.11,<3.15", "workloads": [], "roles": [], - "packages": [] + "packages": [ + "openai>=2.53.0,<3" + ] }, { "id": "bedrock", @@ -556,7 +611,9 @@ "python": ">=3.11,<3.15", "workloads": [], "roles": [], - "packages": [] + "packages": [ + "boto3>=1.43.67,<2" + ] }, { "id": "openrouter", @@ -566,7 +623,9 @@ "python": ">=3.11,<3.15", "workloads": [], "roles": [], - "packages": [] + "packages": [ + "openrouter>=1.1.37,<2" + ] }, { "id": "groq", @@ -576,7 +635,9 @@ "python": ">=3.11,<3.15", "workloads": [], "roles": [], - "packages": [] + "packages": [ + "groq>=1.6.0,<2" + ] }, { "id": "mistral", @@ -586,7 +647,9 @@ "python": ">=3.11,<3.15", "workloads": [], "roles": [], - "packages": [] + "packages": [ + "mistralai>=2.9.1,<3" + ] }, { "id": "cohere", @@ -596,7 +659,9 @@ "python": ">=3.11,<3.15", "workloads": [], "roles": [], - "packages": [] + "packages": [ + "cohere>=7.0.8,<8" + ] }, { "id": "huggingface", @@ -606,7 +671,9 @@ "python": ">=3.11,<3.15", "workloads": [], "roles": [], - "packages": [] + "packages": [ + "huggingface-hub>=1.27.0,<2" + ] }, { "id": "ollama", @@ -616,7 +683,9 @@ "python": ">=3.11,<3.15", "workloads": [], "roles": [], - "packages": [] + "packages": [ + "ollama>=0.6.2,<1" + ] }, { "id": "openai-compatible", @@ -626,7 +695,9 @@ "python": ">=3.11,<3.15", "workloads": [], "roles": [], - "packages": [] + "packages": [ + "openai>=2.53.0,<3" + ] }, { "id": "xai", @@ -636,7 +707,9 @@ "python": ">=3.11,<3.15", "workloads": [], "roles": [], - "packages": [] + "packages": [ + "xai-sdk>=1.17.0,<2" + ] }, { "id": "deepseek", @@ -646,7 +719,9 @@ "python": ">=3.11,<3.15", "workloads": [], "roles": [], - "packages": [] + "packages": [ + "openai>=2.53.0,<3" + ] }, { "id": "together", @@ -656,7 +731,9 @@ "python": ">=3.11,<3.15", "workloads": [], "roles": [], - "packages": [] + "packages": [ + "together>=2.30.0,<3" + ] }, { "id": "fireworks", @@ -666,7 +743,9 @@ "python": ">=3.11,<3.15", "workloads": [], "roles": [], - "packages": [] + "packages": [ + "fireworks-ai>=1.2.6,<2" + ] }, { "id": "sentence-transformers", @@ -676,7 +755,9 @@ "python": ">=3.11,<3.15", "workloads": [], "roles": [], - "packages": [] + "packages": [ + "sentence-transformers>=5.7.0,<6" + ] }, { "id": "sqlite", diff --git a/copier.yml b/copier.yml index fac2e2f..9713cb5 100644 --- a/copier.yml +++ b/copier.yml @@ -4,6 +4,8 @@ _templates_suffix: .jinja _exclude: - "copier.yml" + - "_catalog_dependencies" + - "_catalog_dependencies.jinja" # Source texts for the LICENSE file. They are pulled in with {% include %} # by the LICENSE template, so they must never be copied on their own. - "licenses" @@ -502,6 +504,8 @@ python_version: validator: >- {% if (framework in ['lingo'] or 'violetear' in interfaces or 'beaver' in [document_store, vector_store, graph_store, cache_store]) and python_version == '3.11' %} The selected stack requires Python 3.12 or newer + {% elif (framework == 'crewai' or 'chainlit' in interfaces) and python_version == '3.14' %} + The selected stack currently requires Python 3.13 or older {% endif %} license: diff --git a/docs/reference/components.md b/docs/reference/components.md index 0b8dab4..85d2985 100644 --- a/docs/reference/components.md +++ b/docs/reference/components.md @@ -23,7 +23,7 @@ This reference is generated from `catalog/components.yml`. Do not edit it by han | `framework` | LangGraph (`langgraph`) | `stable` | `>=3.11,<3.15` | | `framework` | LangChain (`langchain`) | `stable` | `>=3.11,<3.15` | | `framework` | LlamaIndex (`llamaindex`) | `stable` | `>=3.11,<3.15` | -| `framework` | CrewAI (`crewai`) | `stable` | `>=3.11,<3.15` | +| `framework` | CrewAI (`crewai`) | `stable` | `>=3.11,<3.14` | | `framework` | Hugging Face smolagents (`smolagents`) | `stable` | `>=3.11,<3.15` | | `framework` | DSPy (`dspy`) | `stable` | `>=3.11,<3.15` | | `framework` | OpenAI Agents SDK (`openai-agents`) | `platform` | `>=3.11,<3.15` | @@ -42,7 +42,7 @@ This reference is generated from `catalog/components.yml`. Do not edit it by han | `framework` | XGBoost (`xgboost`) | `stable` | `>=3.11,<3.15` | | `interface` | Streamlit (`streamlit`) | `stable` | `>=3.11,<3.15` | | `interface` | Gradio (`gradio`) | `stable` | `>=3.11,<3.15` | -| `interface` | Chainlit (`chainlit`) | `stable` | `>=3.11,<3.15` | +| `interface` | Chainlit (`chainlit`) | `stable` | `>=3.11,<3.14` | | `interface` | Textual (`textual`) | `stable` | `>=3.11,<3.15` | | `interface` | NiceGUI (`nicegui`) | `stable` | `>=3.11,<3.15` | | `interface` | FastHTML (`fasthtml`) | `stable` | `>=3.11,<3.15` | diff --git a/pyproject.toml b/pyproject.toml index 85e003d..abb1b1b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ testpaths = ["tests"] addopts = "--strict-markers --strict-config" markers = [ "slow: generates a project and runs its full toolchain (deselect with '-m \"not slow\"')", + "preset: generates and validates an integration-heavy v0.5 preset on Linux", ] [tool.ruff] diff --git a/scripts/compile_catalog.py b/scripts/compile_catalog.py index b7e63cd..1a4c332 100644 --- a/scripts/compile_catalog.py +++ b/scripts/compile_catalog.py @@ -147,6 +147,46 @@ def build_artifacts( layer: {component.name: component.id for component in items} for layer, items in sorted(by_layer.items()) } + package_map = { + component.id: component.packages + for component in components.components + if component.packages + } + dependency_template = "\n".join( + [ + "{# Generated by scripts/compile_catalog.py. Do not edit. #}", + "{%- set component_packages = " + + json.dumps(package_map, sort_keys=True) + + " -%}", + "{%- 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)]", + " + [serving | default('none', true)]", + " + (training_extensions | default([]))", + " + (mlops_tools | default([]))", + " + (quality_tools | default([])) -%}", + "{%- set emitted_dependencies = [] -%}", + "{%- for component_id in selected_components -%}", + "{%- for dependency in component_packages.get(component_id, []) -%}", + "{%- if dependency not in emitted_dependencies %}", + ' "{{ dependency }}",', + "{%- set _ = emitted_dependencies.append(dependency) -%}", + "{%- endif -%}", + "{%- endfor -%}", + "{%- endfor %}", + "{%- if ai_capabilities != 'none' or sql_store != 'none' or document_store != 'none' or auth | default('none', true) != 'none' %}", + ' "pydantic>=2.13.4,<3",', + ' "pydantic-settings>=2.15.0,<3",', + "{%- endif %}", + "{%- if framework == 'pydantic-ai' and pydantic_ai_harness | default([]) %}", + " \"pydantic-ai-harness[{{ pydantic_ai_harness | join(',') }}]>=0.18.0,<0.19\",", + "{%- endif %}", + "", + ] + ) rows = [ "# Component catalog", @@ -180,6 +220,7 @@ def build_artifacts( ), ROOT / "docs" / "reference" / "components.md": "\n".join(rows) + "\n", ROOT / "docs" / "reference" / "presets.md": "\n".join(preset_rows) + "\n", + ROOT / "template" / "_catalog_dependencies.jinja": dependency_template, } diff --git a/template/_catalog_dependencies.jinja b/template/_catalog_dependencies.jinja new file mode 100644 index 0000000..06573ce --- /dev/null +++ b/template/_catalog_dependencies.jinja @@ -0,0 +1,28 @@ +{# 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 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)] + + [serving | default('none', true)] + + (training_extensions | default([])) + + (mlops_tools | default([])) + + (quality_tools | default([])) -%} +{%- set emitted_dependencies = [] -%} +{%- for component_id in selected_components -%} +{%- for dependency in component_packages.get(component_id, []) -%} +{%- if dependency not in emitted_dependencies %} + "{{ dependency }}", +{%- set _ = emitted_dependencies.append(dependency) -%} +{%- endif -%} +{%- endfor -%} +{%- endfor %} +{%- if ai_capabilities != 'none' or sql_store != 'none' or document_store != 'none' or auth | default('none', true) != 'none' %} + "pydantic>=2.13.4,<3", + "pydantic-settings>=2.15.0,<3", +{%- endif %} +{%- if framework == 'pydantic-ai' and pydantic_ai_harness | default([]) %} + "pydantic-ai-harness[{{ pydantic_ai_harness | join(',') }}]>=0.18.0,<0.19", +{%- endif %} diff --git a/template/pyproject.toml.jinja b/template/pyproject.toml.jinja index 967ee42..84395c2 100644 --- a/template/pyproject.toml.jinja +++ b/template/pyproject.toml.jinja @@ -22,16 +22,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ -{%- if framework == 'typer' %} - "typer>=0.27.1", -{%- elif framework == 'fastapi' %} - "fastapi[standard]>=0.141.1", - # Imported directly by the app, so declared directly rather than relying - # on it being a transitive dependency of FastAPI. - "pydantic>=2.13.4", -{%- elif framework == 'flask' %} - "flask>=3.1.2", -{%- endif %} +{% include "template/_catalog_dependencies.jinja" %} ] [project.urls] @@ -42,9 +33,28 @@ Issues = "https://github.com/{{ github_username }}/{{ github_repo }}/issues" Documentation = "https://{{ github_username }}.github.io/{{ github_repo }}" {%- endif %} -{% if workload == 'cli' -%} +{% if workload in ['cli', 'api', 'mcp', 'agent', 'rag'] or interfaces | default([]) -%} [project.scripts] +{%- if workload == 'cli' %} {{ project_slug }} = "{{ module_name }}.cli:app" +{%- elif workload == 'api' %} +{{ project_slug }} = "{{ module_name }}.api:main" +{%- elif workload == 'mcp' %} +{{ project_slug }} = "{{ module_name }}.mcp:main" +{%- elif workload == 'agent' %} +{{ project_slug }} = "{{ module_name }}.agent:main" +{%- elif workload == 'rag' %} +{{ project_slug }} = "{{ module_name }}.rag:main" +{%- endif %} +{%- if 'fastapi' in interfaces | default([]) %} +{{ project_slug }}-fastapi = "{{ module_name }}.interfaces.fastapi_app:main" +{%- endif %} +{%- if 'flask' in interfaces | default([]) %} +{{ project_slug }}-flask = "{{ module_name }}.interfaces.flask_app:main" +{%- endif %} +{%- for interface in interfaces | default([]) if interface in ['streamlit', 'gradio', 'textual', 'nicegui', 'fasthtml', 'violetear'] %} +{{ project_slug }}-{{ interface }} = "{{ module_name }}.interfaces.{{ interface }}_app:main" +{%- endfor %} {% endif -%} [build-system] @@ -62,7 +72,7 @@ dev = [ "pre-commit>=4.6.1", "pytest>=9.1.1", "pytest-cov>=7.1.0", -{%- if framework == 'fastapi' %} +{%- if framework == 'fastapi' or 'fastapi' in interfaces | default([]) %} "httpx>=0.28.1", {%- endif %} "ruff>=0.16.2", diff --git a/template/src/{{ module_name }}/{% if 'chainlit' in interfaces %}interfaces{% endif %}/{% if 'chainlit' in interfaces %}chainlit_app.py{% endif %}.jinja b/template/src/{{ module_name }}/{% if 'chainlit' in interfaces %}interfaces{% endif %}/{% if 'chainlit' in interfaces %}chainlit_app.py{% endif %}.jinja new file mode 100644 index 0000000..7675246 --- /dev/null +++ b/template/src/{{ module_name }}/{% if 'chainlit' in interfaces %}interfaces{% endif %}/{% if 'chainlit' in interfaces %}chainlit_app.py{% endif %}.jinja @@ -0,0 +1,9 @@ +"""Chainlit interface.""" + +import chainlit as cl + + +@cl.on_message +async def on_message(message: cl.Message) -> None: + """Echo through the initial offline vertical slice.""" + await cl.Message(content=f"{{ project_name }}: {message.content}").send() diff --git a/template/src/{{ module_name }}/{% if 'fastapi' in interfaces %}interfaces{% endif %}/{% if 'fastapi' in interfaces %}fastapi_app.py{% endif %}.jinja b/template/src/{{ module_name }}/{% if 'fastapi' in interfaces %}interfaces{% endif %}/{% if 'fastapi' in interfaces %}fastapi_app.py{% endif %}.jinja new file mode 100644 index 0000000..b9cd0b9 --- /dev/null +++ b/template/src/{{ module_name }}/{% if 'fastapi' in interfaces %}interfaces{% endif %}/{% if 'fastapi' in interfaces %}fastapi_app.py{% endif %}.jinja @@ -0,0 +1,42 @@ +"""FastAPI interface for the configured AI workload.""" + +from fastapi import FastAPI +from pydantic import BaseModel + +{% if workload == 'agent' -%} +from {{ module_name }}.agent import build_agent +{%- else -%} +from {{ module_name }}.rag import build_rag +{%- endif %} + +app = FastAPI(title="{{ project_name }}") + + +class Prompt(BaseModel): + """Input payload.""" + + text: str + + +@app.get("/health") +async def health() -> dict[str, str]: + """Report service health.""" + return {"status": "ok"} + + +@app.post("/run") +async def run(payload: Prompt) -> dict[str, str]: + """Run the configured local agent or retriever.""" + {% if workload == 'agent' -%} + output = build_agent().run(payload.text) + {%- else -%} + output = build_rag().query(payload.text) + {%- endif %} + return {"output": output} + + +def main() -> None: + """Run the ASGI application.""" + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/template/src/{{ module_name }}/{% if 'fasthtml' in interfaces %}interfaces{% endif %}/{% if 'fasthtml' in interfaces %}fasthtml_app.py{% endif %}.jinja b/template/src/{{ module_name }}/{% if 'fasthtml' in interfaces %}interfaces{% endif %}/{% if 'fasthtml' in interfaces %}fasthtml_app.py{% endif %}.jinja new file mode 100644 index 0000000..1eb2119 --- /dev/null +++ b/template/src/{{ module_name }}/{% if 'fasthtml' in interfaces %}interfaces{% endif %}/{% if 'fasthtml' in interfaces %}fasthtml_app.py{% endif %}.jinja @@ -0,0 +1,20 @@ +"""FastHTML interface.""" + +from fasthtml.common import FastHTML, H1, P, serve + +app = FastHTML() + + +@app.get("/") +def home(): # type: ignore[no-untyped-def] + """Render the home page.""" + return H1("{{ project_name }}"), P("{{ description or project_name }}") + + +def main() -> None: + """Run the FastHTML application.""" + serve() + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/template/src/{{ module_name }}/{% if 'flask' in interfaces %}interfaces{% endif %}/{% if 'flask' in interfaces %}flask_app.py{% endif %}.jinja b/template/src/{{ module_name }}/{% if 'flask' in interfaces %}interfaces{% endif %}/{% if 'flask' in interfaces %}flask_app.py{% endif %}.jinja new file mode 100644 index 0000000..cd453a6 --- /dev/null +++ b/template/src/{{ module_name }}/{% if 'flask' in interfaces %}interfaces{% endif %}/{% if 'flask' in interfaces %}flask_app.py{% endif %}.jinja @@ -0,0 +1,34 @@ +"""Flask interface for the configured AI workload.""" + +from flask import Flask, jsonify, request + +{% if workload == 'agent' -%} +from {{ module_name }}.agent import build_agent +{%- else -%} +from {{ module_name }}.rag import build_rag +{%- endif %} + +app = Flask(__name__) + + +@app.get("/health") +def health(): # type: ignore[no-untyped-def] + """Report service health.""" + return jsonify(status="ok") + + +@app.post("/run") +def run(): # type: ignore[no-untyped-def] + """Run the configured local agent or retriever.""" + text = str((request.get_json(silent=True) or {}).get("text", "")) + {% if workload == 'agent' -%} + output = build_agent().run(text) + {%- else -%} + output = build_rag().query(text) + {%- endif %} + return jsonify(output=output) + + +def main() -> None: + """Run the WSGI application.""" + app.run(host="0.0.0.0", port=8000) diff --git a/template/src/{{ module_name }}/{% if 'gradio' in interfaces %}interfaces{% endif %}/{% if 'gradio' in interfaces %}gradio_app.py{% endif %}.jinja b/template/src/{{ module_name }}/{% if 'gradio' in interfaces %}interfaces{% endif %}/{% if 'gradio' in interfaces %}gradio_app.py{% endif %}.jinja new file mode 100644 index 0000000..b0d023a --- /dev/null +++ b/template/src/{{ module_name }}/{% if 'gradio' in interfaces %}interfaces{% endif %}/{% if 'gradio' in interfaces %}gradio_app.py{% endif %}.jinja @@ -0,0 +1,17 @@ +"""Gradio interface.""" + +import gradio as gr + + +def create_app() -> gr.Interface: + """Build the Gradio application without starting a server.""" + return gr.Interface(lambda text: f"{{ project_name }}: {text}", "text", "text") + + +def main() -> None: + """Launch the Gradio application.""" + create_app().launch() + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/template/src/{{ module_name }}/{% if 'nicegui' in interfaces %}interfaces{% endif %}/{% if 'nicegui' in interfaces %}nicegui_app.py{% endif %}.jinja b/template/src/{{ module_name }}/{% if 'nicegui' in interfaces %}interfaces{% endif %}/{% if 'nicegui' in interfaces %}nicegui_app.py{% endif %}.jinja new file mode 100644 index 0000000..e100ff4 --- /dev/null +++ b/template/src/{{ module_name }}/{% if 'nicegui' in interfaces %}interfaces{% endif %}/{% if 'nicegui' in interfaces %}nicegui_app.py{% endif %}.jinja @@ -0,0 +1,19 @@ +"""NiceGUI interface.""" + +from nicegui import ui + + +def build_page() -> None: + """Build the page without starting the server.""" + ui.label("{{ project_name }}") + ui.markdown("{{ description or 'Composable Python and AI application' }}") + + +def main() -> None: + """Build and run the NiceGUI application.""" + build_page() + ui.run(title="{{ project_name }}") + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/template/src/{{ module_name }}/{% if 'streamlit' in interfaces %}interfaces{% endif %}/{% if 'streamlit' in interfaces %}streamlit_app.py{% endif %}.jinja b/template/src/{{ module_name }}/{% if 'streamlit' in interfaces %}interfaces{% endif %}/{% if 'streamlit' in interfaces %}streamlit_app.py{% endif %}.jinja new file mode 100644 index 0000000..20fb8de --- /dev/null +++ b/template/src/{{ module_name }}/{% if 'streamlit' in interfaces %}interfaces{% endif %}/{% if 'streamlit' in interfaces %}streamlit_app.py{% endif %}.jinja @@ -0,0 +1,13 @@ +"""Streamlit interface.""" + +import streamlit as st + + +def main() -> None: + """Render the Streamlit application.""" + st.title("{{ project_name }}") + st.write("{{ description or 'Composable Python and AI application' }}") + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/template/src/{{ module_name }}/{% if 'textual' in interfaces %}interfaces{% endif %}/{% if 'textual' in interfaces %}textual_app.py{% endif %}.jinja b/template/src/{{ module_name }}/{% if 'textual' in interfaces %}interfaces{% endif %}/{% if 'textual' in interfaces %}textual_app.py{% endif %}.jinja new file mode 100644 index 0000000..781e0ba --- /dev/null +++ b/template/src/{{ module_name }}/{% if 'textual' in interfaces %}interfaces{% endif %}/{% if 'textual' in interfaces %}textual_app.py{% endif %}.jinja @@ -0,0 +1,22 @@ +"""Textual terminal interface.""" + +from textual.app import App, ComposeResult +from textual.widgets import Footer, Header, Static + + +class ProjectApp(App[None]): + """Minimal Textual application.""" + + def compose(self) -> ComposeResult: + yield Header() + yield Static("{{ description or project_name }}") + yield Footer() + + +def main() -> None: + """Run the terminal interface.""" + ProjectApp().run() + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/template/src/{{ module_name }}/{% if 'violetear' in interfaces %}interfaces{% endif %}/{% if 'violetear' in interfaces %}violetear_app.py{% endif %}.jinja b/template/src/{{ module_name }}/{% if 'violetear' in interfaces %}interfaces{% endif %}/{% if 'violetear' in interfaces %}violetear_app.py{% endif %}.jinja new file mode 100644 index 0000000..cac6084 --- /dev/null +++ b/template/src/{{ module_name }}/{% if 'violetear' in interfaces %}interfaces{% endif %}/{% if 'violetear' in interfaces %}violetear_app.py{% endif %}.jinja @@ -0,0 +1,26 @@ +"""Violetear full-stack Python interface.""" + +from violetear import App +from violetear.markup import HTML, Document + +app = App(title="{{ project_name }}", version="0.1.0") + + +@app.view("/") +def index() -> Document: + """Render the home page.""" + document = Document(title="{{ project_name }}") + document.body.add( + HTML.h1(text="{{ project_name }}"), + HTML.p(text="{{ description or 'Composable Python and AI application' }}"), + ) + return document + + +def main() -> None: + """Run the Violetear application.""" + app.run() + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/template/src/{{ module_name }}/{% if ai_capabilities != 'none' %}settings.py{% endif %}.jinja b/template/src/{{ module_name }}/{% if ai_capabilities != 'none' %}settings.py{% endif %}.jinja new file mode 100644 index 0000000..d38d8d8 --- /dev/null +++ b/template/src/{{ module_name }}/{% if ai_capabilities != 'none' %}settings.py{% endif %}.jinja @@ -0,0 +1,22 @@ +"""Environment-backed application settings.""" + +from pydantic import SecretStr +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + """Configuration loaded from environment variables or a local .env file.""" + + model_config = SettingsConfigDict(env_file=".env", extra="ignore") + + 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 %}" + model_api_key: SecretStr | None = None + model_base_url: str | None = {% if model_provider | default('none', true) == 'ollama' %}"http://127.0.0.1:11434/v1"{% else %}None{% endif %} +{%- if ai_capabilities == 'rag' %} + embedding_provider: str = "{{ embedding_provider }}" + embedding_model: str = "{% if embedding_provider == 'sentence-transformers' %}sentence-transformers/all-MiniLM-L6-v2{% else %}configure-me{% endif %}" +{%- endif %} + + +settings = Settings() diff --git a/template/src/{{ module_name }}/{% if interfaces %}interfaces{% endif %}/__init__.py.jinja b/template/src/{{ module_name }}/{% if interfaces %}interfaces{% endif %}/__init__.py.jinja new file mode 100644 index 0000000..b84f800 --- /dev/null +++ b/template/src/{{ module_name }}/{% if interfaces %}interfaces{% endif %}/__init__.py.jinja @@ -0,0 +1,5 @@ +"""Selected user-facing interfaces for {{ project_name }}.""" + +AVAILABLE = ({% for interface in interfaces %}"{{ interface }}"{% if not loop.last %}, {% endif %}{% endfor %}{% if interfaces | length == 1 %},{% endif %}) + +__all__ = ["AVAILABLE"] 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 new file mode 100644 index 0000000..6cd89ed --- /dev/null +++ b/template/src/{{ module_name }}/{% if workload == 'agent' %}agent.py{% endif %}.jinja @@ -0,0 +1,63 @@ +"""Offline-testable {{ framework }} agent scaffold.""" + +from __future__ import annotations + +import importlib +from dataclasses import dataclass +from typing import Any + +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) +class LocalAgent: + """Deterministic local runner used by tests and initial development.""" + + framework: str + provider: str + + def run(self, prompt: str) -> str: + """Return an offline response without contacting the configured provider.""" + return f"[{self.framework}/{self.provider}] {prompt.strip()}" + + +def configured_framework() -> Any: + """Import and return the configured framework's public module.""" + return importlib.import_module(FRAMEWORK_MODULES[FRAMEWORK]) + + +def build_agent() -> LocalAgent: + """Validate the framework installation and build the local runner.""" + configured_framework() + return LocalAgent(framework=FRAMEWORK, provider=PROVIDER) + + +def harness_capabilities() -> tuple[str, ...]: + """Return the selected optional PydanticAI Harness integrations.""" + return ({% for capability in pydantic_ai_harness | default([]) %}"{{ capability }}"{% if not loop.last %}, {% endif %}{% endfor %}{% if pydantic_ai_harness | default([]) | length == 1 %},{% endif %}) + + +def main() -> None: + """Run one deterministic prompt; replace with provider I/O when configured.""" + print(build_agent().run("Hello from {{ project_name }}")) + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/template/src/{{ module_name }}/{% if workload == 'api' %}api.py{% endif %}.jinja b/template/src/{{ module_name }}/{% if workload == 'api' %}api.py{% endif %}.jinja index 2086769..9e4f9cb 100644 --- a/template/src/{{ module_name }}/{% if workload == 'api' %}api.py{% endif %}.jinja +++ b/template/src/{{ module_name }}/{% if workload == 'api' %}api.py{% endif %}.jinja @@ -33,6 +33,13 @@ async def health() -> Health: async def greet(name: str) -> Greeting: """Greet the given name.""" return Greeting(message=f"Hello, {name}!") + + +def main() -> None: + """Run the ASGI application.""" + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=8000) {%- elif framework == 'flask' -%} """Flask application for {{ project_name }}.""" @@ -51,4 +58,9 @@ def health(): # type: ignore[no-untyped-def] def greet(name: str): # type: ignore[no-untyped-def] """Greet the given name.""" return jsonify(message=f"Hello, {name}!") + + +def main() -> None: + """Run the development WSGI server.""" + app.run(host="0.0.0.0", port=8000) {%- endif %} diff --git a/template/src/{{ module_name }}/{% if workload == 'mcp' %}mcp.py{% endif %}.jinja b/template/src/{{ module_name }}/{% if workload == 'mcp' %}mcp.py{% endif %}.jinja new file mode 100644 index 0000000..f343c06 --- /dev/null +++ b/template/src/{{ module_name }}/{% if workload == 'mcp' %}mcp.py{% endif %}.jinja @@ -0,0 +1,20 @@ +"""FastMCP server for {{ project_name }}.""" + +from fastmcp import FastMCP + +mcp = FastMCP("{{ project_name }}") + + +@mcp.tool +def greet(name: str = "world") -> str: + """Return a friendly greeting.""" + return f"Hello, {name}!" + + +def main() -> None: + """Run the MCP server over the default stdio transport.""" + mcp.run() + + +if __name__ == "__main__": # pragma: no cover + main() 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 new file mode 100644 index 0000000..a40cc34 --- /dev/null +++ b/template/src/{{ module_name }}/{% if workload == 'rag' %}rag.py{% endif %}.jinja @@ -0,0 +1,51 @@ +"""Small offline RAG vertical slice for {{ project_name }}.""" + +from __future__ import annotations + +import importlib +from dataclasses import dataclass, field + +FRAMEWORK = "{{ framework }}" +FRAMEWORK_MODULES = { + "langchain": "langchain", + "llamaindex": "llama_index", + "haystack": "haystack", +} + + +@dataclass +class InMemoryRAG: + """Deterministic retriever used until an external vector store is configured.""" + + documents: list[str] = field(default_factory=list) + + def add(self, *documents: str) -> None: + """Add documents to the local collection.""" + self.documents.extend(documents) + + def query(self, question: str) -> str: + """Return the document with the largest word overlap.""" + words = set(question.lower().split()) + if not self.documents: + return "No documents indexed." + return max( + self.documents, + key=lambda document: len(words & set(document.lower().split())), + ) + + +def build_rag() -> InMemoryRAG: + """Validate the selected framework and create the offline retriever.""" + importlib.import_module(FRAMEWORK_MODULES[FRAMEWORK]) + return InMemoryRAG() + + +def main() -> None: + """Run the local demonstration.""" + rag = build_rag() + rag.add("{{ project_name }} is ready for retrieval augmented generation.") + print(rag.query("What is ready for generation?")) + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/template/tests/test_{{ module_name }}.py.jinja b/template/tests/test_{{ module_name }}.py.jinja index 7d4b66f..4159172 100644 --- a/template/tests/test_{{ module_name }}.py.jinja +++ b/template/tests/test_{{ module_name }}.py.jinja @@ -73,6 +73,37 @@ def test_greet() -> None: response = app.test_client().get("/greet/world") assert response.status_code == 200 assert response.get_json() == {"message": "Hello, world!"} +{%- elif workload == 'mcp' -%} +"""Tests for the {{ module_name }} FastMCP server.""" + +from {{ module_name }}.mcp import greet, mcp + + +def test_mcp_server_and_tool() -> None: + assert mcp.name == "{{ project_name }}" + assert greet("Ada") == "Hello, Ada!" +{%- elif workload == 'agent' -%} +"""Offline tests for the {{ module_name }} agent.""" + +from {{ module_name }}.agent import build_agent, harness_capabilities + + +def test_agent_runs_without_provider_credentials() -> None: + agent = build_agent() + assert agent.run("hello") == "[{{ framework }}/{{ model_provider }}] hello" + assert isinstance(harness_capabilities(), tuple) +{%- elif workload == 'rag' -%} +"""Offline tests for the {{ module_name }} RAG application.""" + +from {{ module_name }}.rag import build_rag + + +def test_rag_retrieves_locally_without_credentials() -> None: + rag = build_rag() + rag.add("Python projects are composable.", "Bananas are yellow.") + assert rag.query("Are Python projects composable?") == ( + "Python projects are composable." + ) {%- else -%} """Smoke tests for {{ module_name }}.""" diff --git a/template/tests/{% if interfaces %}test_interfaces.py{% endif %}.jinja b/template/tests/{% if interfaces %}test_interfaces.py{% endif %}.jinja new file mode 100644 index 0000000..15ffab7 --- /dev/null +++ b/template/tests/{% if interfaces %}test_interfaces.py{% endif %}.jinja @@ -0,0 +1,7 @@ +"""Tests for the selected interfaces.""" + +from {{ module_name }}.interfaces import AVAILABLE + + +def test_selected_interfaces_are_recorded() -> None: + assert AVAILABLE == ({% for interface in interfaces %}"{{ interface }}"{% if not loop.last %}, {% endif %}{% endfor %}{% if interfaces | length == 1 %},{% endif %}) diff --git a/template/{% if ai_capabilities != 'none' %}.env.example{% endif %}.jinja b/template/{% if ai_capabilities != 'none' %}.env.example{% endif %}.jinja new file mode 100644 index 0000000..3578891 --- /dev/null +++ b/template/{% if ai_capabilities != 'none' %}.env.example{% endif %}.jinja @@ -0,0 +1,9 @@ +# Copy this file to .env. Never commit real credentials. +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= +MODEL_BASE_URL={% if model_provider | default('none', true) == 'ollama' %}http://127.0.0.1:11434/v1{% endif %} +{%- if ai_capabilities == 'rag' %} +EMBEDDING_PROVIDER={{ embedding_provider }} +EMBEDDING_MODEL={% if embedding_provider == 'sentence-transformers' %}sentence-transformers/all-MiniLM-L6-v2{% else %}configure-me{% endif %} +{%- endif %} diff --git a/tests/test_ai_presets.py b/tests/test_ai_presets.py new file mode 100644 index 0000000..c199839 --- /dev/null +++ b/tests/test_ai_presets.py @@ -0,0 +1,53 @@ +"""Linux integration tests for the agent, MCP, RAG and interface presets.""" + +from pathlib import Path + +import pytest +from conftest import answers, assert_ok, run + +PRESETS = { + "fastmcp-server": ["src/demo_project/mcp.py"], + "pydantic-ai-openai": ["src/demo_project/agent.py", ".env.example"], + "google-adk-gemini": ["src/demo_project/agent.py", ".env.example"], + "strands-bedrock": ["src/demo_project/agent.py", ".env.example"], + "langgraph-anthropic-api": [ + "src/demo_project/agent.py", + "src/demo_project/interfaces/fastapi_app.py", + ], + "llamaindex-rag": [ + "src/demo_project/rag.py", + "src/demo_project/interfaces/gradio_app.py", + ], + "local-lingo-app": [ + "src/demo_project/agent.py", + "src/demo_project/interfaces/violetear_app.py", + ], +} + + +@pytest.mark.preset +@pytest.mark.parametrize(("preset", "expected_files"), PRESETS.items()) +def test_ai_preset_vertical_slice( + copie, uv: str, preset: str, expected_files: list[str] +) -> None: + """Each affected preset installs, imports and passes its offline tests.""" + result = copie.copy( + extra_answers=answers( + preset=preset, + use_docs=False, + use_codeql=False, + use_docker=False, + ) + ) + assert result.exception is None, result.exception + project = result.project_dir + + for relative in expected_files: + assert (project / Path(relative)).is_file(), relative + + 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", "pytest", "-q"], project), "pytest") diff --git a/tests/test_catalog.py b/tests/test_catalog.py index 8ee55b2..a180356 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py @@ -46,3 +46,12 @@ def test_every_preset_is_exposed_by_copier() -> None: preset_ids = {preset["id"] for preset in source["presets"]} assert preset_choices == preset_ids | {"custom"} + + +def test_compiled_dependency_template_contains_every_catalog_package() -> None: + source = yaml.safe_load((REPO / "catalog" / "components.yml").read_text()) + compiled = (REPO / "template" / "_catalog_dependencies.jinja").read_text() + + for component in source["components"]: + for package in component.get("packages", []): + assert package in compiled, (component["id"], package) diff --git a/tests/test_generation.py b/tests/test_generation.py index 899ce1b..becbe3b 100644 --- a/tests/test_generation.py +++ b/tests/test_generation.py @@ -28,6 +28,7 @@ def test_core_layout(copie, workload: str) -> None: assert (project / "src" / "demo_project" / "py.typed").is_file() assert (project / "tests" / "test_demo_project.py").is_file() assert (project / "pyproject.toml").is_file() + assert not (project / "_catalog_dependencies").exists() # Without this file `copier update` cannot work at all. assert (project / ".copier-answers.yml").is_file() @@ -69,6 +70,9 @@ def test_entrypoints_per_type(copie) -> None: assert (api / "src" / "demo_project" / "api.py").is_file() assert not (api / "src" / "demo_project" / "cli.py").exists() + assert ( + 'demo-project = "demo_project.api:main"' in (api / "pyproject.toml").read_text() + ) def test_simple_presets_remain_first_class(copie) -> None: @@ -100,7 +104,7 @@ def test_flask_is_available_for_a_simple_api(copie) -> None: pyproject = (project / "pyproject.toml").read_text() tests = (project / "tests" / "test_demo_project.py").read_text() assert "from flask import Flask" in api - assert '"flask>=3.1.2"' in pyproject + assert '"flask>=3.1.2,<4"' in pyproject assert "app.test_client()" in tests