diff --git a/catalog/components.yml b/catalog/components.yml index ff580a6..beb2ad5 100644 --- a/catalog/components.yml +++ b/catalog/components.yml @@ -328,9 +328,9 @@ components: - {id: vercel, name: Vercel, layer: deploy, tier: platform, python: ">=3.11,<3.15"} - {id: railway, name: Railway, layer: deploy, tier: platform, python: ">=3.11,<3.15"} - {id: hf-spaces, name: Hugging Face Spaces, layer: deploy, tier: platform, python: ">=3.11,<3.15"} - - {id: modal, name: Modal, layer: deploy, tier: platform, python: ">=3.11,<3.15"} - - {id: runpod, name: RunPod, layer: deploy, tier: platform, python: ">=3.11,<3.15"} - - {id: bentocloud, name: BentoCloud, layer: deploy, tier: platform, python: ">=3.11,<3.15"} + - {id: modal, name: Modal, layer: deploy, tier: platform, python: ">=3.11,<3.15", packages: ["modal>=1.5.3,<2"]} + - {id: runpod, name: RunPod, layer: deploy, tier: platform, python: ">=3.11,<3.15", packages: ["runpod>=1.11.0,<2"]} + - {id: bentocloud, name: BentoCloud, layer: deploy, tier: platform, python: ">=3.11,<3.15", packages: ["bentoml>=1.4.39,<2"]} - {id: aws-ecs, name: AWS ECS, layer: deploy, tier: platform, python: ">=3.11,<3.15"} - {id: aws-sagemaker, name: AWS SageMaker, layer: deploy, tier: platform, python: ">=3.11,<3.15"} - {id: cloud-run, name: Google Cloud Run, layer: deploy, tier: platform, python: ">=3.11,<3.15"} diff --git a/catalog/generated/catalog.json b/catalog/generated/catalog.json index 0f9a8fd..a875365 100644 --- a/catalog/generated/catalog.json +++ b/catalog/generated/catalog.json @@ -1477,7 +1477,9 @@ "python": ">=3.11,<3.15", "workloads": [], "roles": [], - "packages": [] + "packages": [ + "modal>=1.5.3,<2" + ] }, { "id": "runpod", @@ -1487,7 +1489,9 @@ "python": ">=3.11,<3.15", "workloads": [], "roles": [], - "packages": [] + "packages": [ + "runpod>=1.11.0,<2" + ] }, { "id": "bentocloud", @@ -1497,7 +1501,9 @@ "python": ">=3.11,<3.15", "workloads": [], "roles": [], - "packages": [] + "packages": [ + "bentoml>=1.4.39,<2" + ] }, { "id": "aws-ecs", diff --git a/copier.yml b/copier.yml index b6dad62..6b6e782 100644 --- a/copier.yml +++ b/copier.yml @@ -496,6 +496,16 @@ deploy_target: Vertex AI: vertex-ai Azure Container Apps: azure-container-apps Azure ML: azure-ml + validator: >- + {% if workload == 'library' and deploy_target != 'none' %} + Libraries are published as packages; choose no deployment target + {% elif deploy_target in ['runpod', 'aws-sagemaker', 'vertex-ai', 'azure-ml'] and workload not in ['inference', 'hybrid'] %} + The selected ML platform requires an inference or hybrid workload + {% elif deploy_target in ['vercel', 'modal'] and workload in ['mcp', 'training'] %} + The selected serverless platform requires an HTTP-oriented workload + {% elif deploy_target == 'vercel' and workload == 'hybrid' %} + Vercel functions are not suitable for a hybrid training workspace + {% endif %} iac_provider: type: str @@ -505,7 +515,7 @@ iac_provider: None: none Pulumi with Python: pulumi Terraform: terraform - when: "{{ deploy_target not in ['none', 'docker', 'vercel', 'hf-spaces'] }}" + when: "{{ deploy_target in ['aws-ecs', 'aws-sagemaker', 'cloud-run', 'vertex-ai', 'azure-container-apps', 'azure-ml'] }}" python_version: type: str @@ -565,7 +575,7 @@ publish_to_pypi: use_docker: type: bool help: Include a multi-stage Dockerfile? - default: "{{ workload not in ['library', 'cli'] }}" + default: "{{ deploy_target != 'none' or workload not in ['library', 'cli'] }}" use_devcontainer: type: bool diff --git a/pyproject.toml b/pyproject.toml index abb1b1b..acf46f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ package = false dev = [ "copier>=9.17.1", "pydantic>=2.13.4,<3", + "python-hcl2>=8.1.2,<9", "pyyaml>=6.0.3,<7", "pytest>=9.1.1", "pytest-copie>=0.3.1", diff --git a/scripts/compile_catalog.py b/scripts/compile_catalog.py index 1396202..7f33b91 100644 --- a/scripts/compile_catalog.py +++ b/scripts/compile_catalog.py @@ -166,6 +166,7 @@ def build_artifacts( " + ([sql_abstraction | default('none', true)] if sql_store not in ['none', 'supabase'] else [])", " + [auth | default('none', true)]", " + [serving | default('none', true)]", + " + [deploy_target | default('none', true)]", " + (training_extensions | default([]))", " + (mlops_tools | default([]))", " + (quality_tools | default([])) -%}", @@ -187,6 +188,10 @@ def build_artifacts( "{%- if framework == 'pydantic-ai' and pydantic_ai_harness | default([]) %}", " \"pydantic-ai-harness[{{ pydantic_ai_harness | join(',') }}]>=0.18.0,<0.19\",", "{%- endif %}", + "{%- if deploy_target | default('none', true) != 'none' and workload not in ['api', 'mcp', 'training'] and 'fastapi' not in interfaces | default([]) %}", + ' "fastapi[standard]>=0.141.1,<1",', + ' "uvicorn>=0.52.1,<1",', + "{%- endif %}", "", ] ) diff --git a/template/_catalog_dependencies.jinja b/template/_catalog_dependencies.jinja index 64fea12..e9712a4 100644 --- a/template/_catalog_dependencies.jinja +++ b/template/_catalog_dependencies.jinja @@ -1,5 +1,5 @@ {# Generated by scripts/compile_catalog.py. Do not edit. #} -{%- set component_packages = {"accelerate": ["accelerate>=1.14.0,<2"], "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"], "bentoml": ["bentoml>=1.4.39,<2"], "celery": ["celery>=5.6.3,<6"], "chainlit": ["chainlit>=2.11.1,<3"], "chroma": ["chromadb>=1.5.9,<2"], "cohere": ["cohere>=7.0.8,<8"], "crewai": ["crewai>=1.15.13,<2"], "dagster": ["dagster>=1.13.17,<2"], "datasets": ["datasets>=5.0.1,<6"], "deepeval": ["deepeval>=4.1.5,<5"], "deepseek": ["openai>=2.53.0,<3"], "dspy": ["dspy>=3.3.0,<4"], "duckdb": ["duckdb>=1.5.5,<2", "duckdb-engine>=0.17.0,<1"], "dvc": ["dvc>=3.67.1,<4"], "evidently": ["evidently>=0.7.21,<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"], "feast": ["feast>=0.65.0,<1"], "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"], "jax": ["jax>=0.11.0,<0.12"], "jupyterlab": ["jupyterlab>=4.6.2,<5"], "keras": ["keras>=3.15.1,<4", "tensorflow>=2.21.0,<3"], "lancedb": ["lancedb>=0.36.0,<1"], "langchain": ["langchain>=1.3.14,<2"], "langfuse": ["langfuse>=4.14.3,<5"], "langgraph": ["langgraph>=1.2.10,<2"], "langsmith": ["langsmith>=0.10.17,<0.11"], "lightning": ["lightning>=2.6.5,<3"], "lingo": ["lingo-ai>=2.1.0,<3"], "litellm": ["litellm>=1.95.0,<2"], "llamaindex": ["llama-index>=0.14.23,<0.15"], "milvus": ["pymilvus>=3.0.1,<4"], "mistral": ["mistralai>=2.9.1,<3"], "mlflow": ["mlflow>=3.15.1,<4"], "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"], "ollama-serving": ["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"], "opentelemetry": ["opentelemetry-sdk>=1.44.0,<2"], "optuna": ["optuna>=4.9.0,<5"], "pandera": ["pandera>=0.32.1,<0.33"], "peft": ["peft>=0.20.0,<0.21"], "pgvector": ["pgvector>=0.5.0,<1"], "phoenix": ["arize-phoenix>=19.19.1,<20"], "pinecone": ["pinecone>=9.1.0,<10"], "polars": ["polars>=1.43.2,<2"], "postgresql": ["psycopg[binary]>=3.3.4,<4"], "prefect": ["prefect>=3.8.2,<4"], "pydantic-ai": ["pydantic-ai>=2.27.0,<3"], "pydantic-evals": ["pydantic-evals>=2.27.0,<3"], "pytorch": ["torch>=2.13.0,<3"], "qdrant": ["qdrant-client>=1.19.0,<2"], "ragas": ["ragas>=0.4.3,<0.5"], "ray-serve": ["ray[serve]>=2.56.1,<3"], "redis": ["redis>=8.1.0,<9"], "scikit-learn": ["scikit-learn>=1.9.0,<2"], "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"], "temporal": ["temporalio>=1.31.0,<2"], "textual": ["textual>=8.2.8,<9"], "together": ["together>=2.30.0,<3"], "transformers": ["transformers>=5.14.1,<6", "torch>=2.13.0,<3"], "trl": ["trl>=1.9.2,<2"], "typer": ["typer>=0.27.1,<1"], "violetear": ["violetear[server]>=1.4.1,<2"], "vllm": ["vllm>=0.26.0,<0.27"], "wandb": ["wandb>=0.28.1,<0.29"], "weaviate": ["weaviate-client>=4.22.0,<5"], "xai": ["xai-sdk>=1.17.0,<2"], "xgboost": ["xgboost>=3.4.0,<4"]} -%} +{%- set component_packages = {"accelerate": ["accelerate>=1.14.0,<2"], "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"], "bentocloud": ["bentoml>=1.4.39,<2"], "bentoml": ["bentoml>=1.4.39,<2"], "celery": ["celery>=5.6.3,<6"], "chainlit": ["chainlit>=2.11.1,<3"], "chroma": ["chromadb>=1.5.9,<2"], "cohere": ["cohere>=7.0.8,<8"], "crewai": ["crewai>=1.15.13,<2"], "dagster": ["dagster>=1.13.17,<2"], "datasets": ["datasets>=5.0.1,<6"], "deepeval": ["deepeval>=4.1.5,<5"], "deepseek": ["openai>=2.53.0,<3"], "dspy": ["dspy>=3.3.0,<4"], "duckdb": ["duckdb>=1.5.5,<2", "duckdb-engine>=0.17.0,<1"], "dvc": ["dvc>=3.67.1,<4"], "evidently": ["evidently>=0.7.21,<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"], "feast": ["feast>=0.65.0,<1"], "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"], "jax": ["jax>=0.11.0,<0.12"], "jupyterlab": ["jupyterlab>=4.6.2,<5"], "keras": ["keras>=3.15.1,<4", "tensorflow>=2.21.0,<3"], "lancedb": ["lancedb>=0.36.0,<1"], "langchain": ["langchain>=1.3.14,<2"], "langfuse": ["langfuse>=4.14.3,<5"], "langgraph": ["langgraph>=1.2.10,<2"], "langsmith": ["langsmith>=0.10.17,<0.11"], "lightning": ["lightning>=2.6.5,<3"], "lingo": ["lingo-ai>=2.1.0,<3"], "litellm": ["litellm>=1.95.0,<2"], "llamaindex": ["llama-index>=0.14.23,<0.15"], "milvus": ["pymilvus>=3.0.1,<4"], "mistral": ["mistralai>=2.9.1,<3"], "mlflow": ["mlflow>=3.15.1,<4"], "modal": ["modal>=1.5.3,<2"], "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"], "ollama-serving": ["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"], "opentelemetry": ["opentelemetry-sdk>=1.44.0,<2"], "optuna": ["optuna>=4.9.0,<5"], "pandera": ["pandera>=0.32.1,<0.33"], "peft": ["peft>=0.20.0,<0.21"], "pgvector": ["pgvector>=0.5.0,<1"], "phoenix": ["arize-phoenix>=19.19.1,<20"], "pinecone": ["pinecone>=9.1.0,<10"], "polars": ["polars>=1.43.2,<2"], "postgresql": ["psycopg[binary]>=3.3.4,<4"], "prefect": ["prefect>=3.8.2,<4"], "pydantic-ai": ["pydantic-ai>=2.27.0,<3"], "pydantic-evals": ["pydantic-evals>=2.27.0,<3"], "pytorch": ["torch>=2.13.0,<3"], "qdrant": ["qdrant-client>=1.19.0,<2"], "ragas": ["ragas>=0.4.3,<0.5"], "ray-serve": ["ray[serve]>=2.56.1,<3"], "redis": ["redis>=8.1.0,<9"], "runpod": ["runpod>=1.11.0,<2"], "scikit-learn": ["scikit-learn>=1.9.0,<2"], "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"], "temporal": ["temporalio>=1.31.0,<2"], "textual": ["textual>=8.2.8,<9"], "together": ["together>=2.30.0,<3"], "transformers": ["transformers>=5.14.1,<6", "torch>=2.13.0,<3"], "trl": ["trl>=1.9.2,<2"], "typer": ["typer>=0.27.1,<1"], "violetear": ["violetear[server]>=1.4.1,<2"], "vllm": ["vllm>=0.26.0,<0.27"], "wandb": ["wandb>=0.28.1,<0.29"], "weaviate": ["weaviate-client>=4.22.0,<5"], "xai": ["xai-sdk>=1.17.0,<2"], "xgboost": ["xgboost>=3.4.0,<4"]} -%} {%- set selected_components = [framework] + (interfaces | default([])) + ([model_provider] if ai_capabilities in ['agents', 'rag', 'inference'] else []) @@ -8,6 +8,7 @@ + ([sql_abstraction | default('none', true)] if sql_store not in ['none', 'supabase'] else []) + [auth | default('none', true)] + [serving | default('none', true)] + + [deploy_target | default('none', true)] + (training_extensions | default([])) + (mlops_tools | default([])) + (quality_tools | default([])) -%} @@ -29,3 +30,7 @@ {%- if framework == 'pydantic-ai' and pydantic_ai_harness | default([]) %} "pydantic-ai-harness[{{ pydantic_ai_harness | join(',') }}]>=0.18.0,<0.19", {%- endif %} +{%- if deploy_target | default('none', true) != 'none' and workload not in ['api', 'mcp', 'training'] and 'fastapi' not in interfaces | default([]) %} + "fastapi[standard]>=0.141.1,<1", + "uvicorn>=0.52.1,<1", +{%- endif %} diff --git a/template/readme.md.jinja b/template/readme.md.jinja index 10ae5be..a7eea68 100644 --- a/template/readme.md.jinja +++ b/template/readme.md.jinja @@ -88,7 +88,7 @@ uv run poe --help # list every task > reaches 1.0, drop `continue-on-error` from the `quality` job in > `.github/workflows/ci.yml`. -{% if use_docker -%} +{% if use_docker or deploy_target != 'none' -%} ## Docker ```bash diff --git a/template/src/{{ module_name }}/{% if deploy_target != 'none' and workload not in ['api', 'mcp', 'training'] and 'fastapi' not in interfaces %}deployment.py{% endif %}.jinja b/template/src/{{ module_name }}/{% if deploy_target != 'none' and workload not in ['api', 'mcp', 'training'] and 'fastapi' not in interfaces %}deployment.py{% endif %}.jinja new file mode 100644 index 0000000..86e0f76 --- /dev/null +++ b/template/src/{{ module_name }}/{% if deploy_target != 'none' and workload not in ['api', 'mcp', 'training'] and 'fastapi' not in interfaces %}deployment.py{% endif %}.jinja @@ -0,0 +1,44 @@ +"""Portable HTTP entrypoint for the selected deployment target.""" + +from typing import Any + +from fastapi import FastAPI + +app = FastAPI(title="{{ project_name }}", version="0.1.0") + + +@app.get("/health") +async def health() -> dict[str, str]: + """Report deployment health without loading remote models.""" + return {"status": "ok"} + + +@app.get("/ping") +async def ping() -> dict[str, str]: + """Implement the health contract used by managed ML endpoints.""" + return {"status": "ok"} + + +@app.post("/predict") +@app.post("/invocations") +async def predict(payload: dict[str, Any]) -> dict[str, Any]: + """Echo a validated envelope until project-specific inference is connected.""" + return {"input": payload, "serving": "{{ serving }}"} + + +@app.get("/") +async def deployment_info() -> dict[str, str]: + """Describe the generated stack.""" + return { + "project": "{{ project_slug }}", + "workload": "{{ workload }}", + "framework": "{{ framework }}", + "deployment": "{{ deploy_target }}", + } + + +def main() -> None: + """Run the portable deployment entrypoint.""" + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/template/src/{{ module_name }}/{% if deploy_target == 'bentocloud' %}deploy{% endif %}/{% if deploy_target == 'bentocloud' %}bentoml_service.py{% endif %}.jinja b/template/src/{{ module_name }}/{% if deploy_target == 'bentocloud' %}deploy{% endif %}/{% if deploy_target == 'bentocloud' %}bentoml_service.py{% endif %}.jinja new file mode 100644 index 0000000..35d61ed --- /dev/null +++ b/template/src/{{ module_name }}/{% if deploy_target == 'bentocloud' %}deploy{% endif %}/{% if deploy_target == 'bentocloud' %}bentoml_service.py{% endif %}.jinja @@ -0,0 +1,12 @@ +"""BentoCloud service adapter.""" + +import bentoml + + +@bentoml.service +class ProjectService: + """Minimal online service ready for model loading at startup.""" + + @bentoml.api + def health(self) -> dict[str, str]: + return {"status": "ok", "project": "{{ project_slug }}"} diff --git a/template/src/{{ module_name }}/{% if deploy_target == 'modal' %}deploy{% endif %}/{% if deploy_target == 'modal' %}modal_app.py{% endif %}.jinja b/template/src/{{ module_name }}/{% if deploy_target == 'modal' %}deploy{% endif %}/{% if deploy_target == 'modal' %}modal_app.py{% endif %}.jinja new file mode 100644 index 0000000..8a32e2d --- /dev/null +++ b/template/src/{{ module_name }}/{% if deploy_target == 'modal' %}deploy{% endif %}/{% if deploy_target == 'modal' %}modal_app.py{% endif %}.jinja @@ -0,0 +1,20 @@ +"""Modal deployment adapter.""" + +import modal + +{% if workload == 'api' -%} +from {{ module_name }}.api import app as web_app +{%- elif 'fastapi' in interfaces | default([]) -%} +from {{ module_name }}.interfaces.fastapi_app import app as web_app +{%- else -%} +from {{ module_name }}.deployment import app as web_app +{%- endif %} + +app = modal.App("{{ project_slug }}") + + +@app.function() +@modal.asgi_app() +def asgi_app(): # type: ignore[no-untyped-def] + """Expose the generated ASGI app through Modal.""" + return web_app diff --git a/template/src/{{ module_name }}/{% if deploy_target == 'runpod' %}deploy{% endif %}/{% if deploy_target == 'runpod' %}runpod_handler.py{% endif %}.jinja b/template/src/{{ module_name }}/{% if deploy_target == 'runpod' %}deploy{% endif %}/{% if deploy_target == 'runpod' %}runpod_handler.py{% endif %}.jinja new file mode 100644 index 0000000..02fb0be --- /dev/null +++ b/template/src/{{ module_name }}/{% if deploy_target == 'runpod' %}deploy{% endif %}/{% if deploy_target == 'runpod' %}runpod_handler.py{% endif %}.jinja @@ -0,0 +1,17 @@ +"""RunPod Serverless worker implementation.""" + +from typing import Any + +import runpod + + +def handler(job: dict[str, Any]) -> dict[str, Any]: + """Return a deterministic envelope ready for model inference code.""" + return { + "input": job.get("input", {}), + "project": "{{ project_slug }}", + "serving": "{{ serving }}", + } + + +RUNPOD_START = runpod.serverless.start diff --git a/template/src/{{ module_name }}/{% if deploy_target in ['modal', 'runpod', 'bentocloud'] %}deploy{% endif %}/__init__.py.jinja b/template/src/{{ module_name }}/{% if deploy_target in ['modal', 'runpod', 'bentocloud'] %}deploy{% endif %}/__init__.py.jinja new file mode 100644 index 0000000..7c017d6 --- /dev/null +++ b/template/src/{{ module_name }}/{% if deploy_target in ['modal', 'runpod', 'bentocloud'] %}deploy{% endif %}/__init__.py.jinja @@ -0,0 +1 @@ +"""Platform-specific deployment adapters.""" diff --git a/template/tests/{% if deploy_target != 'none' and workload not in ['api', 'mcp', 'training'] and 'fastapi' not in interfaces %}test_deployment.py{% endif %}.jinja b/template/tests/{% if deploy_target != 'none' and workload not in ['api', 'mcp', 'training'] and 'fastapi' not in interfaces %}test_deployment.py{% endif %}.jinja new file mode 100644 index 0000000..bc56f82 --- /dev/null +++ b/template/tests/{% if deploy_target != 'none' and workload not in ['api', 'mcp', 'training'] and 'fastapi' not in interfaces %}test_deployment.py{% endif %}.jinja @@ -0,0 +1,13 @@ +from fastapi.testclient import TestClient + +from {{ module_name }}.deployment import app + + +def test_deployment_health() -> None: + response = TestClient(app).get("/health") + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + prediction = TestClient(app).post("/predict", json={"value": 42}) + assert prediction.status_code == 200 + assert prediction.json()["input"] == {"value": 42} diff --git a/template/{% if deploy_target != 'none' %}deploy{% endif %}/{% if deploy_target != 'none' %}README.md{% endif %}.jinja b/template/{% if deploy_target != 'none' %}deploy{% endif %}/{% if deploy_target != 'none' %}README.md{% endif %}.jinja new file mode 100644 index 0000000..d32aaed --- /dev/null +++ b/template/{% if deploy_target != 'none' %}deploy{% endif %}/{% if deploy_target != 'none' %}README.md{% endif %}.jinja @@ -0,0 +1,62 @@ +# Deployment: {{ deploy_target }} + +The checked-in `Dockerfile` is the portable deployment contract. Build and +exercise it locally before publishing it to a registry: + +```bash +docker build -t {{ project_slug }} . +docker run --rm -p 8000:8000 {{ project_slug }} +``` + +Keep credentials out of images and configuration files. Configure the values +documented in `.env.example` through the target platform's secret manager. + +{% if deploy_target == 'docker' -%} +Start the local service with `docker compose up --build`. +{%- elif deploy_target == 'render' -%} +Create a Blueprint from this GitHub repository; Render discovers `render.yaml` +and builds the root Dockerfile. +{%- elif deploy_target == 'fly' -%} +Run `fly launch --copy-config --no-deploy`, review `fly.toml`, set secrets with +`fly secrets set`, then run `fly deploy`. +{%- elif deploy_target == 'vercel' -%} +Run `vercel link` once and `vercel deploy`; `api/index.py` exports the Python +WSGI/ASGI application and `vercel.json` routes requests to it. +{%- elif deploy_target == 'railway' -%} +Create a service from the GitHub repository. Railway discovers `railway.toml` +and the Dockerfile; configure variables in the service environment. +{%- elif deploy_target == 'hf-spaces' -%} +Push the repository to a Hugging Face Space. The uppercase `README.md` contains +the required Docker Space metadata; development docs remain in `readme.md`. +{%- elif deploy_target == 'modal' -%} +Authenticate with `modal setup`, then run `modal deploy deploy/modal_app.py`. +{%- elif deploy_target == 'runpod' -%} +Build and push the image, create a Serverless endpoint from it, and set the +container command to `python handler.py`. +{%- elif deploy_target == 'bentocloud' -%} +Run `bentoml build`, test the resulting Bento, then run `bentoml deploy`. +{%- elif deploy_target == 'cloud-run' -%} +Replace `IMAGE_URI` in `service.yaml`, then run +`gcloud run services replace service.yaml`. +{%- elif deploy_target == 'aws-ecs' -%} +Push the image to ECR, replace placeholders in `deploy/task-definition.json`, +register it with `aws ecs register-task-definition`, then update your service. +{%- elif deploy_target == 'aws-sagemaker' -%} +Push an inference image to ECR, replace placeholders in +`deploy/sagemaker-model.json`, and create the model and endpoint resources. +{%- elif deploy_target == 'vertex-ai' -%} +Push the image to Artifact Registry, replace `IMAGE_URI` in +`deploy/vertex-model.json`, upload the model, and deploy it to an endpoint. +{%- elif deploy_target == 'azure-container-apps' -%} +Push the image to ACR, replace placeholders in `deploy/container-app.yaml`, and +run `az containerapp create --yaml deploy/container-app.yaml`. +{%- elif deploy_target == 'azure-ml' -%} +Replace the model/image placeholders, create `deploy/endpoint.yml`, then create +`deploy/deployment.yml` with the Azure ML CLI v2. +{%- endif %} + +{% if iac_provider != 'none' -%} +The selected `{{ iac_provider }}` project lives under `infra/{{ iac_provider }}`. +Its configuration intentionally requires registry images, roles, networks and +regions as inputs instead of creating broad privileged infrastructure silently. +{%- endif %} diff --git a/template/{% if deploy_target == 'aws-ecs' %}deploy{% endif %}/{% if deploy_target == 'aws-ecs' %}task-definition.json{% endif %}.jinja b/template/{% if deploy_target == 'aws-ecs' %}deploy{% endif %}/{% if deploy_target == 'aws-ecs' %}task-definition.json{% endif %}.jinja new file mode 100644 index 0000000..d33542e --- /dev/null +++ b/template/{% if deploy_target == 'aws-ecs' %}deploy{% endif %}/{% if deploy_target == 'aws-ecs' %}task-definition.json{% endif %}.jinja @@ -0,0 +1,16 @@ +{ + "family": "{{ project_slug }}", + "networkMode": "awsvpc", + "requiresCompatibilities": ["FARGATE"], + "cpu": "512", + "memory": "1024", + "executionRoleArn": "EXECUTION_ROLE_ARN", + "containerDefinitions": [ + { + "name": "app", + "image": "IMAGE_URI", + "essential": true, + "portMappings": [{"containerPort": 8000, "protocol": "tcp"}] + } + ] +} diff --git a/template/{% if deploy_target == 'aws-sagemaker' %}deploy{% endif %}/{% if deploy_target == 'aws-sagemaker' %}sagemaker-model.json{% endif %}.jinja b/template/{% if deploy_target == 'aws-sagemaker' %}deploy{% endif %}/{% if deploy_target == 'aws-sagemaker' %}sagemaker-model.json{% endif %}.jinja new file mode 100644 index 0000000..44e1c4b --- /dev/null +++ b/template/{% if deploy_target == 'aws-sagemaker' %}deploy{% endif %}/{% if deploy_target == 'aws-sagemaker' %}sagemaker-model.json{% endif %}.jinja @@ -0,0 +1,5 @@ +{ + "ModelName": "{{ project_slug }}", + "PrimaryContainer": {"Image": "IMAGE_URI"}, + "ExecutionRoleArn": "EXECUTION_ROLE_ARN" +} diff --git a/template/{% if deploy_target == 'azure-container-apps' %}deploy{% endif %}/{% if deploy_target == 'azure-container-apps' %}container-app.yaml{% endif %}.jinja b/template/{% if deploy_target == 'azure-container-apps' %}deploy{% endif %}/{% if deploy_target == 'azure-container-apps' %}container-app.yaml{% endif %}.jinja new file mode 100644 index 0000000..0038ffa --- /dev/null +++ b/template/{% if deploy_target == 'azure-container-apps' %}deploy{% endif %}/{% if deploy_target == 'azure-container-apps' %}container-app.yaml{% endif %}.jinja @@ -0,0 +1,15 @@ +location: LOCATION +name: {{ project_slug }} +properties: + managedEnvironmentId: MANAGED_ENVIRONMENT_ID + configuration: + ingress: + external: true + targetPort: 8000 + template: + containers: + - name: app + image: IMAGE_URI + resources: + cpu: 0.5 + memory: 1Gi diff --git a/template/{% if deploy_target == 'azure-ml' %}deploy{% endif %}/{% if deploy_target == 'azure-ml' %}deployment.yml{% endif %}.jinja b/template/{% if deploy_target == 'azure-ml' %}deploy{% endif %}/{% if deploy_target == 'azure-ml' %}deployment.yml{% endif %}.jinja new file mode 100644 index 0000000..3467dae --- /dev/null +++ b/template/{% if deploy_target == 'azure-ml' %}deploy{% endif %}/{% if deploy_target == 'azure-ml' %}deployment.yml{% endif %}.jinja @@ -0,0 +1,8 @@ +$schema: https://azuremlschemas.azureedge.net/latest/managedOnlineDeployment.schema.json +name: blue +endpoint_name: {{ project_slug }} +model: azureml:MODEL_NAME:MODEL_VERSION +environment: + image: IMAGE_URI +instance_type: Standard_DS3_v2 +instance_count: 1 diff --git a/template/{% if deploy_target == 'azure-ml' %}deploy{% endif %}/{% if deploy_target == 'azure-ml' %}endpoint.yml{% endif %}.jinja b/template/{% if deploy_target == 'azure-ml' %}deploy{% endif %}/{% if deploy_target == 'azure-ml' %}endpoint.yml{% endif %}.jinja new file mode 100644 index 0000000..9f2dd5a --- /dev/null +++ b/template/{% if deploy_target == 'azure-ml' %}deploy{% endif %}/{% if deploy_target == 'azure-ml' %}endpoint.yml{% endif %}.jinja @@ -0,0 +1,3 @@ +$schema: https://azuremlschemas.azureedge.net/latest/managedOnlineEndpoint.schema.json +name: {{ project_slug }} +auth_mode: key diff --git a/template/{% if deploy_target == 'bentocloud' %}bentofile.yaml{% endif %}.jinja b/template/{% if deploy_target == 'bentocloud' %}bentofile.yaml{% endif %}.jinja new file mode 100644 index 0000000..6664075 --- /dev/null +++ b/template/{% if deploy_target == 'bentocloud' %}bentofile.yaml{% endif %}.jinja @@ -0,0 +1,9 @@ +service: "deploy.bentoml_service:ProjectService" +description: "file: ./readme.md" +include: + - "src/" + - "deploy/" + - "pyproject.toml" +docker: + distro: debian + python_version: "{{ python_version }}" diff --git a/template/{% if deploy_target == 'bentocloud' %}deploy{% endif %}/{% if deploy_target == 'bentocloud' %}bentoml_service.py{% endif %}.jinja b/template/{% if deploy_target == 'bentocloud' %}deploy{% endif %}/{% if deploy_target == 'bentocloud' %}bentoml_service.py{% endif %}.jinja new file mode 100644 index 0000000..fa271d6 --- /dev/null +++ b/template/{% if deploy_target == 'bentocloud' %}deploy{% endif %}/{% if deploy_target == 'bentocloud' %}bentoml_service.py{% endif %}.jinja @@ -0,0 +1,5 @@ +"""BentoCloud CLI entrypoint; implementation stays inside the package.""" + +from {{ module_name }}.deploy.bentoml_service import ProjectService + +__all__ = ["ProjectService"] diff --git a/template/{% if deploy_target == 'cloud-run' %}service.yaml{% endif %}.jinja b/template/{% if deploy_target == 'cloud-run' %}service.yaml{% endif %}.jinja new file mode 100644 index 0000000..cd51e09 --- /dev/null +++ b/template/{% if deploy_target == 'cloud-run' %}service.yaml{% endif %}.jinja @@ -0,0 +1,11 @@ +apiVersion: serving.knative.dev/v1 +kind: Service +metadata: + name: {{ project_slug }} +spec: + template: + spec: + containers: + - image: IMAGE_URI + ports: + - containerPort: 8000 diff --git a/template/{% if deploy_target == 'docker' %}compose.yaml{% endif %}.jinja b/template/{% if deploy_target == 'docker' %}compose.yaml{% endif %}.jinja new file mode 100644 index 0000000..58d3e8c --- /dev/null +++ b/template/{% if deploy_target == 'docker' %}compose.yaml{% endif %}.jinja @@ -0,0 +1,8 @@ +services: + app: + build: + context: . + image: {{ project_slug }}:local + ports: + - "8000:8000" + restart: unless-stopped diff --git a/template/{% if deploy_target == 'fly' %}fly.toml{% endif %}.jinja b/template/{% if deploy_target == 'fly' %}fly.toml{% endif %}.jinja new file mode 100644 index 0000000..c0bb8f9 --- /dev/null +++ b/template/{% if deploy_target == 'fly' %}fly.toml{% endif %}.jinja @@ -0,0 +1,15 @@ +app = "{{ project_slug }}" +primary_region = "iad" + +[build] + dockerfile = "Dockerfile" + +[env] + PORT = "8000" + +[http_service] + internal_port = 8000 + force_https = true + auto_stop_machines = "stop" + auto_start_machines = true + min_machines_running = 0 diff --git a/template/{% if deploy_target == 'hf-spaces' %}README.md{% endif %}.jinja b/template/{% if deploy_target == 'hf-spaces' %}README.md{% endif %}.jinja new file mode 100644 index 0000000..848109a --- /dev/null +++ b/template/{% if deploy_target == 'hf-spaces' %}README.md{% endif %}.jinja @@ -0,0 +1,15 @@ +--- +title: {{ project_name }} +emoji: 🐍 +colorFrom: blue +colorTo: indigo +sdk: docker +app_port: 8000 +python_version: "{{ python_version }}" +short_description: {{ description or project_name }} +--- + +# {{ project_name }} + +This Space is generated from the same portable Docker image used locally. +Project development and contribution instructions remain in [`readme.md`](readme.md). diff --git a/template/{% if deploy_target == 'modal' %}deploy{% endif %}/{% if deploy_target == 'modal' %}modal_app.py{% endif %}.jinja b/template/{% if deploy_target == 'modal' %}deploy{% endif %}/{% if deploy_target == 'modal' %}modal_app.py{% endif %}.jinja new file mode 100644 index 0000000..c7fdcda --- /dev/null +++ b/template/{% if deploy_target == 'modal' %}deploy{% endif %}/{% if deploy_target == 'modal' %}modal_app.py{% endif %}.jinja @@ -0,0 +1,5 @@ +"""Modal CLI entrypoint; implementation stays inside the package.""" + +from {{ module_name }}.deploy.modal_app import app, asgi_app + +__all__ = ["app", "asgi_app"] diff --git a/template/{% if deploy_target == 'railway' %}railway.toml{% endif %}.jinja b/template/{% if deploy_target == 'railway' %}railway.toml{% endif %}.jinja new file mode 100644 index 0000000..3c7a517 --- /dev/null +++ b/template/{% if deploy_target == 'railway' %}railway.toml{% endif %}.jinja @@ -0,0 +1,11 @@ +[build] +builder = "DOCKERFILE" +dockerfilePath = "Dockerfile" + +[deploy] +{% if workload not in ['mcp', 'training'] %} +healthcheckPath = "/health" +healthcheckTimeout = 100 +{% endif %} +restartPolicyType = "ON_FAILURE" +restartPolicyMaxRetries = 3 diff --git a/template/{% if deploy_target == 'render' %}render.yaml{% endif %}.jinja b/template/{% if deploy_target == 'render' %}render.yaml{% endif %}.jinja new file mode 100644 index 0000000..f14e5d3 --- /dev/null +++ b/template/{% if deploy_target == 'render' %}render.yaml{% endif %}.jinja @@ -0,0 +1,9 @@ +services: + - type: web + name: {{ project_slug }} + runtime: docker + dockerfilePath: ./Dockerfile +{% if workload not in ['mcp', 'training'] %} + healthCheckPath: /health +{% endif %} + autoDeployTrigger: commit diff --git a/template/{% if deploy_target == 'runpod' %}handler.py{% endif %}.jinja b/template/{% if deploy_target == 'runpod' %}handler.py{% endif %}.jinja new file mode 100644 index 0000000..81d8a32 --- /dev/null +++ b/template/{% if deploy_target == 'runpod' %}handler.py{% endif %}.jinja @@ -0,0 +1,8 @@ +"""RunPod CLI entrypoint; implementation stays inside the package.""" + +import runpod + +from {{ module_name }}.deploy.runpod_handler import handler + +if __name__ == "__main__": # pragma: no cover + runpod.serverless.start({"handler": handler}) diff --git a/template/{% if deploy_target == 'vercel' %}api{% endif %}/{% if deploy_target == 'vercel' %}index.py{% endif %}.jinja b/template/{% if deploy_target == 'vercel' %}api{% endif %}/{% if deploy_target == 'vercel' %}index.py{% endif %}.jinja new file mode 100644 index 0000000..b684ba4 --- /dev/null +++ b/template/{% if deploy_target == 'vercel' %}api{% endif %}/{% if deploy_target == 'vercel' %}index.py{% endif %}.jinja @@ -0,0 +1,9 @@ +{% if workload == 'api' -%} +from {{ module_name }}.api import app +{%- elif 'fastapi' in interfaces | default([]) -%} +from {{ module_name }}.interfaces.fastapi_app import app +{%- else -%} +from {{ module_name }}.deployment import app +{%- endif %} + +__all__ = ["app"] diff --git a/template/{% if deploy_target == 'vercel' %}vercel.json{% endif %}.jinja b/template/{% if deploy_target == 'vercel' %}vercel.json{% endif %}.jinja new file mode 100644 index 0000000..72a1223 --- /dev/null +++ b/template/{% if deploy_target == 'vercel' %}vercel.json{% endif %}.jinja @@ -0,0 +1,4 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "rewrites": [{"source": "/(.*)", "destination": "/api/index"}] +} diff --git a/template/{% if deploy_target == 'vertex-ai' %}deploy{% endif %}/{% if deploy_target == 'vertex-ai' %}vertex-model.json{% endif %}.jinja b/template/{% if deploy_target == 'vertex-ai' %}deploy{% endif %}/{% if deploy_target == 'vertex-ai' %}vertex-model.json{% endif %}.jinja new file mode 100644 index 0000000..ac5fb57 --- /dev/null +++ b/template/{% if deploy_target == 'vertex-ai' %}deploy{% endif %}/{% if deploy_target == 'vertex-ai' %}vertex-model.json{% endif %}.jinja @@ -0,0 +1,9 @@ +{ + "displayName": "{{ project_slug }}", + "containerSpec": { + "imageUri": "IMAGE_URI", + "ports": [{"containerPort": 8000}], + "healthRoute": "/health", + "predictRoute": "/predict" + } +} diff --git a/template/{% if iac_provider == 'pulumi' %}infra{% endif %}/{% if iac_provider == 'pulumi' %}pulumi{% endif %}/{% if iac_provider == 'pulumi' %}Pulumi.yaml{% endif %}.jinja b/template/{% if iac_provider == 'pulumi' %}infra{% endif %}/{% if iac_provider == 'pulumi' %}pulumi{% endif %}/{% if iac_provider == 'pulumi' %}Pulumi.yaml{% endif %}.jinja new file mode 100644 index 0000000..69f6b6f --- /dev/null +++ b/template/{% if iac_provider == 'pulumi' %}infra{% endif %}/{% if iac_provider == 'pulumi' %}pulumi{% endif %}/{% if iac_provider == 'pulumi' %}Pulumi.yaml{% endif %}.jinja @@ -0,0 +1,6 @@ +name: {{ project_slug }}-infra +runtime: + name: python + options: + virtualenv: .venv +description: Bounded {{ deploy_target }} infrastructure for {{ project_name }} diff --git a/template/{% if iac_provider == 'pulumi' %}infra{% endif %}/{% if iac_provider == 'pulumi' %}pulumi{% endif %}/{% if iac_provider == 'pulumi' %}__main__.py{% endif %}.jinja b/template/{% if iac_provider == 'pulumi' %}infra{% endif %}/{% if iac_provider == 'pulumi' %}pulumi{% endif %}/{% if iac_provider == 'pulumi' %}__main__.py{% endif %}.jinja new file mode 100644 index 0000000..0e44740 --- /dev/null +++ b/template/{% if iac_provider == 'pulumi' %}infra{% endif %}/{% if iac_provider == 'pulumi' %}pulumi{% endif %}/{% if iac_provider == 'pulumi' %}__main__.py{% endif %}.jinja @@ -0,0 +1,149 @@ +"""Pulumi program for {{ deploy_target }} using existing bounded cloud primitives.""" + +{% if deploy_target == 'aws-ecs' -%} +import json + +import pulumi +import pulumi_aws as aws + +config = pulumi.Config() +image_uri = config.require("imageUri") +execution_role_arn = config.require("executionRoleArn") +subnet_ids = config.require_object("subnetIds") +security_group_ids = config.require_object("securityGroupIds") + +cluster = aws.ecs.Cluster("{{ project_slug }}") +task = aws.ecs.TaskDefinition( + "{{ project_slug }}", + family="{{ project_slug }}", + cpu="512", + memory="1024", + network_mode="awsvpc", + requires_compatibilities=["FARGATE"], + execution_role_arn=execution_role_arn, + container_definitions=json.dumps( + [{"name": "app", "image": image_uri, "essential": True, "portMappings": [{"containerPort": 8000}]}] + ), +) +service = aws.ecs.Service( + "{{ project_slug }}", + cluster=cluster.arn, + task_definition=task.arn, + desired_count=1, + launch_type="FARGATE", + network_configuration={ + "subnets": subnet_ids, + "security_groups": security_group_ids, + "assign_public_ip": True, + }, +) +pulumi.export("clusterArn", cluster.arn) +pulumi.export("serviceName", service.name) +{%- elif deploy_target == 'aws-sagemaker' -%} +import pulumi +import pulumi_aws as aws + +config = pulumi.Config() +image_uri = config.require("imageUri") +execution_role_arn = config.require("executionRoleArn") +instance_type = config.get("instanceType") or "ml.m5.large" + +model = aws.sagemaker.Model( + "{{ project_slug }}", + execution_role_arn=execution_role_arn, + primary_container={"image": image_uri}, +) +endpoint_config = aws.sagemaker.EndpointConfiguration( + "{{ project_slug }}", + production_variants=[{ + "variant_name": "AllTraffic", + "model_name": model.name, + "initial_instance_count": 1, + "instance_type": instance_type, + }], +) +endpoint = aws.sagemaker.Endpoint( + "{{ project_slug }}", endpoint_config_name=endpoint_config.name +) +pulumi.export("endpointName", endpoint.name) +{%- elif deploy_target == 'cloud-run' -%} +import pulumi +import pulumi_gcp as gcp + +config = pulumi.Config() +image_uri = config.require("imageUri") +region = config.get("region") or "us-central1" + +service = gcp.cloudrunv2.Service( + "{{ project_slug }}", + name="{{ project_slug }}", + location=region, + deletion_protection=False, + ingress="INGRESS_TRAFFIC_ALL", + template={"containers": [{"image": image_uri, "ports": {"container_port": 8000}}]}, +) +pulumi.export("serviceUri", service.uri) +{%- elif deploy_target == 'vertex-ai' -%} +import pulumi +import pulumi_gcp as gcp + +config = pulumi.Config() +image_uri = config.require("imageUri") +region = config.get("region") or "us-central1" + +model = gcp.vertex.AiModel( + "{{ project_slug }}", + display_name="{{ project_slug }}", + region=region, + container_spec={ + "image_uri": image_uri, + "ports": [{"container_port": 8000}], + "health_route": "/health", + "predict_route": "/predict", + }, +) +endpoint = gcp.vertex.AiEndpoint( + "{{ project_slug }}", display_name="{{ project_slug }}", region=region +) +pulumi.export("modelId", model.id) +pulumi.export("endpointId", endpoint.id) +{%- elif deploy_target == 'azure-container-apps' -%} +import pulumi +from pulumi_azure_native import app + +config = pulumi.Config() +image_uri = config.require("imageUri") +resource_group_name = config.require("resourceGroupName") +environment_id = config.require("environmentId") +location = config.get("location") or "eastus" + +container_app = app.ContainerApp( + "{{ project_slug }}", + container_app_name="{{ project_slug }}", + resource_group_name=resource_group_name, + location=location, + environment_id=environment_id, + configuration={"ingress": {"external": True, "target_port": 8000}}, + template={"containers": [{"name": "app", "image": image_uri}]}, +) +pulumi.export("fqdn", container_app.configuration.apply(lambda value: value.ingress.fqdn)) +{%- elif deploy_target == 'azure-ml' -%} +import pulumi +from pulumi_azure_native import machinelearningservices as ml + +config = pulumi.Config() +resource_group_name = config.require("resourceGroupName") +workspace_name = config.require("workspaceName") +location = config.get("location") or "eastus" + +endpoint = ml.OnlineEndpoint( + "{{ project_slug }}", + endpoint_name="{{ project_slug }}", + resource_group_name=resource_group_name, + workspace_name=workspace_name, + location=location, + identity={"type": "SystemAssigned"}, + properties={"auth_mode": "Key", "description": "{{ project_name }}"}, +) +pulumi.export("endpointName", endpoint.name) +{%- endif %} diff --git a/template/{% if iac_provider == 'pulumi' %}infra{% endif %}/{% if iac_provider == 'pulumi' %}pulumi{% endif %}/{% if iac_provider == 'pulumi' %}pyproject.toml{% endif %}.jinja b/template/{% if iac_provider == 'pulumi' %}infra{% endif %}/{% if iac_provider == 'pulumi' %}pulumi{% endif %}/{% if iac_provider == 'pulumi' %}pyproject.toml{% endif %}.jinja new file mode 100644 index 0000000..7db6e5d --- /dev/null +++ b/template/{% if iac_provider == 'pulumi' %}infra{% endif %}/{% if iac_provider == 'pulumi' %}pulumi{% endif %}/{% if iac_provider == 'pulumi' %}pyproject.toml{% endif %}.jinja @@ -0,0 +1,14 @@ +[project] +name = "{{ project_slug }}-infra" +version = "0.1.0" +requires-python = ">={{ python_version }}" +dependencies = [ + "pulumi>=3.256.0,<4", +{% if deploy_target in ['aws-ecs', 'aws-sagemaker'] -%} + "pulumi-aws>=7.41.0,<8", +{%- elif deploy_target in ['cloud-run', 'vertex-ai'] -%} + "pulumi-gcp>=9.33.0,<10", +{%- else -%} + "pulumi-azure-native>=3.25.0,<4", +{%- endif %} +] diff --git a/template/{% if iac_provider == 'terraform' %}infra{% endif %}/{% if iac_provider == 'terraform' %}terraform{% endif %}/{% if iac_provider == 'terraform' %}main.tf{% endif %}.jinja b/template/{% if iac_provider == 'terraform' %}infra{% endif %}/{% if iac_provider == 'terraform' %}terraform{% endif %}/{% if iac_provider == 'terraform' %}main.tf{% endif %}.jinja new file mode 100644 index 0000000..a18abe7 --- /dev/null +++ b/template/{% if iac_provider == 'terraform' %}infra{% endif %}/{% if iac_provider == 'terraform' %}terraform{% endif %}/{% if iac_provider == 'terraform' %}main.tf{% endif %}.jinja @@ -0,0 +1,165 @@ +terraform { + required_version = ">= 1.8.0" + required_providers { +{% if deploy_target in ['aws-ecs', 'aws-sagemaker'] -%} + aws = { + source = "hashicorp/aws" + version = "~> 6.0" + } +{%- elif deploy_target in ['cloud-run', 'vertex-ai'] -%} + google = { + source = "hashicorp/google" + version = "~> 7.0" + } +{%- elif deploy_target == 'azure-container-apps' -%} + azurerm = { + source = "hashicorp/azurerm" + version = "~> 4.0" + } +{%- else -%} + azapi = { + source = "azure/azapi" + version = "~> 2.0" + } +{%- endif %} + } +} + +{% if deploy_target in ['aws-ecs', 'aws-sagemaker'] -%} +provider "aws" { + region = var.region +} +{%- elif deploy_target in ['cloud-run', 'vertex-ai'] -%} +provider "google" { + project = var.project_id + region = var.region +} +{%- elif deploy_target == 'azure-container-apps' -%} +provider "azurerm" { + features {} +} +{%- else -%} +provider "azapi" {} +{%- endif %} + +{% if deploy_target == 'aws-ecs' -%} +resource "aws_ecs_cluster" "app" { + name = "{{ project_slug }}" +} + +resource "aws_ecs_task_definition" "app" { + family = "{{ project_slug }}" + requires_compatibilities = ["FARGATE"] + network_mode = "awsvpc" + cpu = 512 + memory = 1024 + execution_role_arn = var.execution_role_arn + container_definitions = jsonencode([{ + name = "app" + image = var.image_uri + essential = true + portMappings = [{ containerPort = 8000 }] + }]) +} + +resource "aws_ecs_service" "app" { + name = "{{ project_slug }}" + cluster = aws_ecs_cluster.app.id + task_definition = aws_ecs_task_definition.app.arn + desired_count = 1 + launch_type = "FARGATE" + network_configuration { + subnets = var.subnet_ids + security_groups = var.security_group_ids + assign_public_ip = true + } +} +{%- elif deploy_target == 'aws-sagemaker' -%} +resource "aws_sagemaker_model" "app" { + name = "{{ project_slug }}" + execution_role_arn = var.execution_role_arn + primary_container { image = var.image_uri } +} + +resource "aws_sagemaker_endpoint_configuration" "app" { + name = "{{ project_slug }}" + production_variants { + variant_name = "AllTraffic" + model_name = aws_sagemaker_model.app.name + initial_instance_count = 1 + instance_type = var.instance_type + } +} + +resource "aws_sagemaker_endpoint" "app" { + name = "{{ project_slug }}" + endpoint_config_name = aws_sagemaker_endpoint_configuration.app.name +} +{%- elif deploy_target == 'cloud-run' -%} +resource "google_cloud_run_v2_service" "app" { + name = "{{ project_slug }}" + location = var.region + deletion_protection = false + ingress = "INGRESS_TRAFFIC_ALL" + template { + containers { + image = var.image_uri + ports { container_port = 8000 } + } + } +} +{%- elif deploy_target == 'vertex-ai' -%} +resource "google_vertex_ai_endpoint" "app" { + name = var.endpoint_id + display_name = "{{ project_slug }}" + location = var.region +} + +resource "google_vertex_ai_model" "app" { + display_name = "{{ project_slug }}" + region = var.region + container_spec { + image_uri = var.image_uri + health_route = "/health" + predict_route = "/predict" + ports { container_port = 8000 } + } +} +{%- elif deploy_target == 'azure-container-apps' -%} +resource "azurerm_container_app" "app" { + name = "{{ project_slug }}" + resource_group_name = var.resource_group_name + container_app_environment_id = var.environment_id + revision_mode = "Single" + template { + container { + name = "app" + image = var.image_uri + cpu = 0.5 + memory = "1Gi" + } + } + ingress { + external_enabled = true + target_port = 8000 + traffic_weight { + percentage = 100 + latest_revision = true + } + } +} +{%- elif deploy_target == 'azure-ml' -%} +resource "azapi_resource" "endpoint" { + type = "Microsoft.MachineLearningServices/workspaces/onlineEndpoints@2024-10-01" + name = "{{ project_slug }}" + parent_id = var.workspace_id + location = var.location + identity { type = "SystemAssigned" } + body = { + properties = { + authMode = "Key" + description = "{{ project_name }}" + } + } +} +{%- endif %} diff --git a/template/{% if iac_provider == 'terraform' %}infra{% endif %}/{% if iac_provider == 'terraform' %}terraform{% endif %}/{% if iac_provider == 'terraform' %}variables.tf{% endif %}.jinja b/template/{% if iac_provider == 'terraform' %}infra{% endif %}/{% if iac_provider == 'terraform' %}terraform{% endif %}/{% if iac_provider == 'terraform' %}variables.tf{% endif %}.jinja new file mode 100644 index 0000000..61b9150 --- /dev/null +++ b/template/{% if iac_provider == 'terraform' %}infra{% endif %}/{% if iac_provider == 'terraform' %}terraform{% endif %}/{% if iac_provider == 'terraform' %}variables.tf{% endif %}.jinja @@ -0,0 +1,55 @@ +variable "region" { + type = string + default = "{% if deploy_target in ['aws-ecs', 'aws-sagemaker'] %}us-east-1{% elif deploy_target in ['cloud-run', 'vertex-ai'] %}us-central1{% else %}eastus{% endif %}" +} + +variable "image_uri" { + type = string + default = "IMAGE_URI" +} +{% if deploy_target in ['aws-ecs', 'aws-sagemaker'] -%} +variable "execution_role_arn" { + type = string +} +{%- endif %} +{% if deploy_target == 'aws-ecs' -%} +variable "subnet_ids" { + type = list(string) +} + +variable "security_group_ids" { + type = list(string) +} +{%- elif deploy_target == 'aws-sagemaker' -%} +variable "instance_type" { + type = string + default = "ml.m5.large" +} +{%- elif deploy_target in ['cloud-run', 'vertex-ai'] -%} +variable "project_id" { + type = string +} +{% if deploy_target == 'vertex-ai' %} +variable "endpoint_id" { + type = string + description = "Numeric Vertex endpoint resource name" +} +{% endif %} +{%- elif deploy_target == 'azure-container-apps' -%} +variable "resource_group_name" { + type = string +} + +variable "environment_id" { + type = string +} +{%- elif deploy_target == 'azure-ml' -%} +variable "location" { + type = string + default = "eastus" +} + +variable "workspace_id" { + type = string +} +{%- endif %} diff --git a/template/{% if use_docker or deploy_target != 'none' %}.dockerignore{% endif %}.jinja b/template/{% if use_docker or deploy_target != 'none' %}.dockerignore{% endif %}.jinja new file mode 100644 index 0000000..9c76763 --- /dev/null +++ b/template/{% if use_docker or deploy_target != 'none' %}.dockerignore{% endif %}.jinja @@ -0,0 +1,13 @@ +.git +.github +.venv +__pycache__ +.pytest_cache +.ruff_cache +.coverage +htmlcov +dist +docs +tests +*.pyc +.env diff --git a/template/{% if use_docker %}Dockerfile{% endif %}.jinja b/template/{% if use_docker or deploy_target != 'none' %}Dockerfile{% endif %}.jinja similarity index 73% rename from template/{% if use_docker %}Dockerfile{% endif %}.jinja rename to template/{% if use_docker or deploy_target != 'none' %}Dockerfile{% endif %}.jinja index fdb1ff3..2bd5157 100644 --- a/template/{% if use_docker %}Dockerfile{% endif %}.jinja +++ b/template/{% if use_docker or deploy_target != 'none' %}Dockerfile{% endif %}.jinja @@ -34,7 +34,7 @@ ENV PATH="/app/.venv/bin:$PATH" \ WORKDIR /app USER app -EXPOSE 8000 +EXPOSE {% if deploy_target == 'aws-sagemaker' %}8080{% else %}8000{% endif %} {% if workload == 'api' and framework == 'fastapi' -%} HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \ @@ -46,6 +46,16 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \ CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/health').status == 200 else 1)" CMD ["flask", "--app", "{{ module_name }}.api:app", "run", "--host", "0.0.0.0", "--port", "8000"] +{%- elif workload == 'mcp' -%} +CMD ["python", "-c", "from {{ module_name }}.mcp import mcp; mcp.run(transport='http', host='0.0.0.0', port=8000)"] +{%- elif workload == 'training' -%} +CMD ["{{ project_slug }}"] +{%- elif 'fastapi' in interfaces | default([]) -%} +CMD ["uvicorn", "{{ module_name }}.interfaces.fastapi_app:app", "--host", "0.0.0.0", "--port", "8000"] +{%- elif deploy_target == 'aws-sagemaker' -%} +CMD ["uvicorn", "{{ module_name }}.deployment:app", "--host", "0.0.0.0", "--port", "8080"] +{%- elif deploy_target != 'none' -%} +CMD ["uvicorn", "{{ module_name }}.deployment:app", "--host", "0.0.0.0", "--port", "8000"] {%- elif workload == 'cli' -%} ENTRYPOINT ["{{ project_slug }}"] {%- else -%} diff --git a/tests/test_ai_presets.py b/tests/test_ai_presets.py index fa023ad..0ab8620 100644 --- a/tests/test_ai_presets.py +++ b/tests/test_ai_presets.py @@ -164,3 +164,53 @@ def test_hf_finetuning_preset_renders_without_downloading_models( assert (project / "src/demo_project/serving.py").is_file() assert (project / "packages/training/pyproject.toml").is_file() assert (project / "packages/service/pyproject.toml").is_file() + + +@pytest.mark.preset +@pytest.mark.parametrize( + ("deploy_target", "workload", "framework", "serving", "entrypoint"), + [ + ("modal", "api", "fastapi", "none", "deploy/modal_app.py"), + ("runpod", "inference", "none", "litellm", "handler.py"), + ( + "bentocloud", + "inference", + "none", + "bentoml", + "deploy/bentoml_service.py", + ), + ], +) +def test_python_deployment_adapter_vertical_slice( + copie, + uv: str, + deploy_target: str, + workload: str, + framework: str, + serving: str, + entrypoint: str, +) -> None: + """Python-native deployment SDKs install, import and pass the generated gate.""" + result = copie.copy( + extra_answers=answers( + preset="custom", + workload=workload, + framework=framework, + serving=serving, + deploy_target=deploy_target, + use_docs=False, + use_codeql=False, + use_docker=False, + ) + ) + assert result.exception is None, result.exception + project = result.project_dir + assert (project / entrypoint).is_file() + + 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_deployments.py b/tests/test_deployments.py new file mode 100644 index 0000000..0c81e6e --- /dev/null +++ b/tests/test_deployments.py @@ -0,0 +1,161 @@ +"""Structural coverage for deployment targets and cloud IaC adapters.""" + +import ast +import json +import tomllib +from pathlib import Path +from shutil import copytree, ignore_patterns + +import hcl2 +import yaml +from conftest import answers +from copier import run_copy + +TARGETS: dict[str, tuple[dict[str, object], list[str]]] = { + "docker": ({"workload": "api", "framework": "fastapi"}, ["compose.yaml"]), + "render": ({"workload": "api", "framework": "fastapi"}, ["render.yaml"]), + "fly": ({"workload": "api", "framework": "fastapi"}, ["fly.toml"]), + "vercel": ( + {"workload": "api", "framework": "fastapi"}, + ["vercel.json", "api/index.py"], + ), + "railway": ( + {"workload": "api", "framework": "fastapi"}, + ["railway.toml"], + ), + "hf-spaces": ( + {"workload": "api", "framework": "fastapi"}, + ["README.md"], + ), + "modal": ( + {"workload": "api", "framework": "fastapi"}, + ["deploy/modal_app.py", "src/demo_project/deploy/modal_app.py"], + ), + "runpod": ( + {"workload": "inference", "framework": "none", "serving": "litellm"}, + ["handler.py", "src/demo_project/deploy/runpod_handler.py"], + ), + "bentocloud": ( + {"workload": "inference", "framework": "none", "serving": "bentoml"}, + ["bentofile.yaml", "src/demo_project/deploy/bentoml_service.py"], + ), + "aws-ecs": ( + {"workload": "api", "framework": "fastapi"}, + ["deploy/task-definition.json"], + ), + "aws-sagemaker": ( + {"workload": "inference", "framework": "none", "serving": "bentoml"}, + ["deploy/sagemaker-model.json"], + ), + "cloud-run": ( + {"workload": "api", "framework": "fastapi"}, + ["service.yaml"], + ), + "vertex-ai": ( + {"workload": "inference", "framework": "none", "serving": "bentoml"}, + ["deploy/vertex-model.json"], + ), + "azure-container-apps": ( + {"workload": "api", "framework": "fastapi"}, + ["deploy/container-app.yaml"], + ), + "azure-ml": ( + {"workload": "inference", "framework": "none", "serving": "bentoml"}, + ["deploy/endpoint.yml", "deploy/deployment.yml"], + ), +} + + +def working_template(tmp_path: Path) -> Path: + """Copy staged template changes without VCS checkout or Copier tasks.""" + template = tmp_path / "template" + copytree( + Path.cwd(), + template, + ignore=ignore_patterns(".git", ".venv", ".pytest_cache", "__pycache__"), + ) + return template + + +def render(template: Path, destination: Path, **overrides: object) -> Path: + template_answers: dict[str, object] = { + "preset": "custom", + "ai_capabilities": "none", + "deploy_target": "none", + "use_docs": False, + "use_codeql": False, + "use_docker": False, + } + run_copy( + str(template), + destination, + data=answers(**(template_answers | overrides)), + defaults=True, + unsafe=True, + skip_tasks=True, + ) + return destination + + +def test_every_deployment_target_has_a_portable_adapter(tmp_path: Path) -> None: + template = working_template(tmp_path) + for target, (stack_answers, expected) in TARGETS.items(): + project = render( + template, + tmp_path / target, + deploy_target=target, + **stack_answers, + ) + assert (project / "Dockerfile").is_file(), target + assert (project / ".dockerignore").is_file(), target + assert (project / "deploy/README.md").is_file(), target + for relative in expected: + assert (project / relative).is_file(), f"{target}: {relative}" + + for json_file in project.rglob("*.json"): + json.loads(json_file.read_text()) + for toml_file in project.rglob("*.toml"): + tomllib.loads(toml_file.read_text()) + for yaml_file in [*project.rglob("*.yaml"), *project.rglob("*.yml")]: + yaml.safe_load(yaml_file.read_text()) + for python_file in project.rglob("*.py"): + ast.parse(python_file.read_text(), filename=str(python_file)) + + +def test_cloud_targets_render_both_iac_options(tmp_path: Path) -> None: + template = working_template(tmp_path) + cloud_targets = { + target: values + for target, values in TARGETS.items() + if target + in { + "aws-ecs", + "aws-sagemaker", + "cloud-run", + "vertex-ai", + "azure-container-apps", + "azure-ml", + } + } + for target, (stack_answers, _) in cloud_targets.items(): + pulumi = render( + template, + tmp_path / f"{target}-pulumi", + deploy_target=target, + iac_provider="pulumi", + **stack_answers, + ) + ast.parse((pulumi / "infra/pulumi/__main__.py").read_text()) + tomllib.loads((pulumi / "infra/pulumi/pyproject.toml").read_text()) + yaml.safe_load((pulumi / "infra/pulumi/Pulumi.yaml").read_text()) + + terraform = render( + template, + tmp_path / f"{target}-terraform", + deploy_target=target, + iac_provider="terraform", + **stack_answers, + ) + for hcl_file in (terraform / "infra/terraform").glob("*.tf"): + with hcl_file.open() as stream: + hcl2.load(stream) diff --git a/uv.lock b/uv.lock index c362fd5..07129c1 100644 --- a/uv.lock +++ b/uv.lock @@ -120,6 +120,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/72/b9/313e8f2f2e9517ae050a692ae7b3e4b3f17cc5e6dfea0db51fe14e586580/jinja2_ansible_filters-1.3.2-py3-none-any.whl", hash = "sha256:e1082f5564917649c76fed239117820610516ec10f87735d0338688800a55b34", size = 18975, upload-time = "2022-06-30T14:08:49.571Z" }, ] +[[package]] +name = "lark" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/34/28fff3ab31ccff1fd4f6c7c7b0ceb2b6968d8ea4950663eadcb5720591a0/lark-1.3.1.tar.gz", hash = "sha256:b426a7a6d6d53189d318f2b6236ab5d6429eaf09259f1ca33eb716eed10d2905", size = 382732, upload-time = "2025-10-27T18:25:56.653Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3d/14ce75ef66813643812f3093ab17e46d3a206942ce7376d31ec2d36229e7/lark-1.3.1-py3-none-any.whl", hash = "sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12", size = 113151, upload-time = "2025-10-27T18:25:54.882Z" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -353,6 +362,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, ] +[[package]] +name = "python-hcl2" +version = "8.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lark" }, + { name = "regex" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/7b/6c9c973484a482f833a1b88ab69e078e05cc1f761af1839d5cbe7e7fad92/python_hcl2-8.1.2.tar.gz", hash = "sha256:ae809c7e6e39e8c3c3555e7b7f389082207929591fcba062c9f76afb1abe972d", size = 114685, upload-time = "2026-04-10T14:27:44.527Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/c7/10c7832ec3050a191c756f4a591c9033fbfb8e10688e979efb5c4ca4265c/python_hcl2-8.1.2-py3-none-any.whl", hash = "sha256:ac3d16dc6501d4f24db348b3a634afdfb5c10a6715b27cd105a5b77071b84ee3", size = 98951, upload-time = "2026-04-10T14:27:43.529Z" }, +] + [[package]] name = "python-template" version = "0.5.0" @@ -365,6 +387,7 @@ dev = [ { name = "pytest" }, { name = "pytest-copie" }, { name = "pytest-xdist" }, + { name = "python-hcl2" }, { name = "pyyaml" }, { name = "ruff" }, ] @@ -378,6 +401,7 @@ dev = [ { name = "pytest", specifier = ">=9.1.1" }, { name = "pytest-copie", specifier = ">=0.3.1" }, { name = "pytest-xdist", specifier = ">=3.8.0" }, + { name = "python-hcl2", specifier = ">=8.1.2,<9" }, { name = "pyyaml", specifier = ">=6.0.3,<7" }, { name = "ruff", specifier = ">=0.16.2" }, ] @@ -430,6 +454,78 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" }, ] +[[package]] +name = "regex" +version = "2026.7.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/98/04b13f1ddfb63158025291c02e03eb42fbb7acb51d091d541050eb4e35e8/regex-2026.7.19.tar.gz", hash = "sha256:7e77b324909c1617cbb4c668677e2c6ae13f44d7c1de0d4f15f2e3c10f3315b5", size = 416440, upload-time = "2026-07-19T00:19:48.923Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/3d/84165e4299ff76f3a40fe1f2abf939e976f693383a08d2beea6af62bd2c1/regex-2026.7.19-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f035d9dc1d25eff9d361456572231c7d27b5ccd473ca7dc0adfce732bd006d40", size = 496552, upload-time = "2026-07-19T00:17:36.808Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/a65293e6e4cf28eb7ee1be5335a5386c40d6742e9f47fafc8fec785e16c7/regex-2026.7.19-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c42572142ed0b9d5d261ba727157c426510da78e20828b66bbb855098b8a4e38", size = 296983, upload-time = "2026-07-19T00:17:38.816Z" }, + { url = "https://files.pythonhosted.org/packages/95/47/2d0564e93d87bc48618360ddca232a2ca612bbdf53ce8465d45ca5ce14ee/regex-2026.7.19-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:40b34dd88658e4fedd2fddbf0275ac970d00614b731357f425722a3ed1983d11", size = 291832, upload-time = "2026-07-19T00:17:40.726Z" }, + { url = "https://files.pythonhosted.org/packages/07/cd/42dfbabff3dfc9603c501c0e2e2c5adbb09d127b267bf5348de0af338c15/regex-2026.7.19-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c41c63992bf1874cebb6e7f56fd7d3c007924659a604ae3d90e427d40d4fd13", size = 796775, upload-time = "2026-07-19T00:17:42.382Z" }, + { url = "https://files.pythonhosted.org/packages/df/5d/f6a4839f2b934e3eed5973fd07f5929ee97d4c98939fb275ea23c274ee16/regex-2026.7.19-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d3372064506b94dd2c67c845f2db8062e9e9ba84d04e33cb96d7d33c11fe1ae", size = 865687, upload-time = "2026-07-19T00:17:44.185Z" }, + { url = "https://files.pythonhosted.org/packages/14/b0/b47d6c36049bc59806a50bd4c86ced70bbe058d787f80281b1d7a9b0e024/regex-2026.7.19-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fce7760bf283405b2c7999cab3da4e72f7deca6396013115e3f7a955db9760da", size = 911962, upload-time = "2026-07-19T00:17:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/2a/be/ff61f28f9273658cfe23acbbac5217221f6519960ed401e61dfdab12bc35/regex-2026.7.19-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0d702548d89d572b2929879bc883bb7a4c4709efafe4512cadee56c55c9bd15", size = 801817, upload-time = "2026-07-19T00:17:48.25Z" }, + { url = "https://files.pythonhosted.org/packages/c3/bb/8b4f7f26b333f9f79e1b453613c39bb4776f51d38ae66dd0ba31d6b354ca/regex-2026.7.19-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d446c6ac40bb6e05025ccee55b84d80fe9bf8e93010ffc4bb9484f13d498835f", size = 776908, upload-time = "2026-07-19T00:17:50.183Z" }, + { url = "https://files.pythonhosted.org/packages/09/13/610110fc5921d380516d03c26b652555f08aa0d23ea78a771231873c3638/regex-2026.7.19-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4c3501bfa814ab07b5580741f9bf78dfdfe146a04057f82df9e2402d2a975939", size = 784426, upload-time = "2026-07-19T00:17:52.454Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f5/1ef9e2a83a5947c57ebff0b377cb5727c3d5ec1992317a320d035cd0dbb6/regex-2026.7.19-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c4585c3e64b4f9e583b4d2683f18f5d5d872b3d71dcf24594b74ecc23602fa96", size = 860600, upload-time = "2026-07-19T00:17:54.229Z" }, + { url = "https://files.pythonhosted.org/packages/a0/02/073af33a3ec149241d11c80acea91e722aa0adbf05addd50f251c4fe89c3/regex-2026.7.19-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:571fde9741eb0ccde23dd4e0c1d50fbae910e901fa7e629faf39b2dda740d220", size = 765950, upload-time = "2026-07-19T00:17:56.041Z" }, + { url = "https://files.pythonhosted.org/packages/81/a9/d1e9f819dc394a568ef370cd56cf25394e957a2235f8370f23b576e5a475/regex-2026.7.19-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:15b364b9b98d6d2fe1a85034c23a3180ff913f46caddc3895f6fd65186255ccc", size = 851794, upload-time = "2026-07-19T00:17:57.897Z" }, + { url = "https://files.pythonhosted.org/packages/03/3a/8ae83eda7579feacdf984e71fb9e70635fb6f832eeddca58427ec4fca926/regex-2026.7.19-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffd8893ccc1c2fce6e0d6ca402d716fe1b29db70c7132609a05955e31b2aa8f2", size = 789845, upload-time = "2026-07-19T00:17:59.97Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/c195cbfe5a75fdec64d8f6554fd15237b837919d2c61bdc141d7c807b08b/regex-2026.7.19-cp313-cp313-win32.whl", hash = "sha256:f0fa4fa9c3632d708742baf2282f2055c11d888a790362670a403cbf48a2c404", size = 267135, upload-time = "2026-07-19T00:18:01.958Z" }, + { url = "https://files.pythonhosted.org/packages/b2/80/a11de8404b7272b70acb45c1c05987cce60b45d5693da2e176f0e390d564/regex-2026.7.19-cp313-cp313-win_amd64.whl", hash = "sha256:d51ffd3427640fa2da6ade574ceba932f210ad095f65fcc450a2b0a0d454868e", size = 277747, upload-time = "2026-07-19T00:18:04.121Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/0f5c8eff1b4f1f3d83276d365fccecf666afcc7d947420943bf394d07adb/regex-2026.7.19-cp313-cp313-win_arm64.whl", hash = "sha256:c670fe7be5b6020b76bc6e8d2196074657e1327595bca93a389e1a76ab130ad8", size = 277129, upload-time = "2026-07-19T00:18:05.821Z" }, + { url = "https://files.pythonhosted.org/packages/dc/4c/44b74742052cedda40f9ae469532a037112f7311a36669a891fba8984bb0/regex-2026.7.19-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db47b561c9afd884baa1f96f797c9ca369872c4b65912bc691cfa99e68340af2", size = 501134, upload-time = "2026-07-19T00:18:07.567Z" }, + { url = "https://files.pythonhosted.org/packages/f0/45/bbd038b5e39ee5613a5a689290145b40058cc152c41de9cc23639d2b9734/regex-2026.7.19-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:65dcd28d3eba2ab7c2fd906485cc301392b47cc2234790d27d4e4814e02cdfda", size = 299418, upload-time = "2026-07-19T00:18:09.38Z" }, + { url = "https://files.pythonhosted.org/packages/65/38/c5bde94b4cedfd5850d64c3f08222d8e1600e84f6ee71d9b44b4b8163f74/regex-2026.7.19-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f2e7f8e2ab6c2922be02c7ec45185aa5bd771e2e57b95455ee343a44d8130dff", size = 294486, upload-time = "2026-07-19T00:18:11.188Z" }, + { url = "https://files.pythonhosted.org/packages/d7/6a/2f5e107cb26c960b781967178899daf2787a7ab151844ed3c01d6fc95474/regex-2026.7.19-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe31f28c94402043161876a258a9c6f757cb485905c7614ce8d6cd40e6b7bdc1", size = 811643, upload-time = "2026-07-19T00:18:12.975Z" }, + { url = "https://files.pythonhosted.org/packages/37/d4/a2f963406d7d73a62eed84ba05a258afb6cad1b21aa4517443ce40506b78/regex-2026.7.19-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8f6fa298bb4f7f58a33334406218ba74716e68feddf5e4e54cd5d8082705abf", size = 871081, upload-time = "2026-07-19T00:18:14.733Z" }, + { url = "https://files.pythonhosted.org/packages/45/a3/44be546340bedb15f13063f5e7fe16793ea4d9ea2e805d09bd174ac27724/regex-2026.7.19-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cc1b2440423a851fad781309dd87843868f4f66a6bcd1ddb9225cf4ec2c84732", size = 917372, upload-time = "2026-07-19T00:18:16.724Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f6/e0870b0fd2a40dba0074e4b76e514b21313d37946c9248453e34ec43923e/regex-2026.7.19-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ac59a0900474a52b7c04af8196affc22bd9842acb0950df12f7b813e983609a", size = 816089, upload-time = "2026-07-19T00:18:18.617Z" }, + { url = "https://files.pythonhosted.org/packages/ae/27/957e8e22690ad6634572b39b71f130a6105f4d0718bb16849eac00fff147/regex-2026.7.19-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4896db1f4ce0576765b8272aa922df324e0f5b9bb2c3d03044ff32a7234a9aba", size = 785206, upload-time = "2026-07-19T00:18:20.464Z" }, + { url = "https://files.pythonhosted.org/packages/76/a4/186e410941e731037c01166069ab86da9f65e8f8110c18009ccf4bd623ee/regex-2026.7.19-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4e6883a021db30511d9fb8cfb0f222ce1f2c369f7d4d8b0448f449a93ba0bdfc", size = 800431, upload-time = "2026-07-19T00:18:22.716Z" }, + { url = "https://files.pythonhosted.org/packages/73/9f/e4e10e023d291d64a33e246610b724493bf1ce98e0e59c9b7c837e5acfb7/regex-2026.7.19-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:09523a592938aa9f587fb74467c63ff0cf88fc3df14c82ab0f0517dcf76aaa62", size = 864906, upload-time = "2026-07-19T00:18:24.772Z" }, + { url = "https://files.pythonhosted.org/packages/24/57/ccb20b6be5f1f52a053d1ba2a8f7a077edb9d918248b8490d7506c6832b3/regex-2026.7.19-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:1ebac3474b8589fce2f9b225b650afd61448f7c73a5d0255a10cc6366471aed1", size = 773559, upload-time = "2026-07-19T00:18:27.008Z" }, + { url = "https://files.pythonhosted.org/packages/a3/82/f3b263cf8fad927dc102891da8502e718b7ff9d19af7a2a07c03865d7188/regex-2026.7.19-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4a0530bb1b8c1c985e7e2122e2b4d3aedd8a3c21c6bfddae6767c4405668b56e", size = 857739, upload-time = "2026-07-19T00:18:29.107Z" }, + { url = "https://files.pythonhosted.org/packages/47/2e/1687bd1b6c2aed5e672ccf845fc11557821fe7366d921b50889ea5ce57bf/regex-2026.7.19-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2ef7eeb108c47ce7bcc9513e51bcb1bf57e8f483d52fce68a8642e3527141ae0", size = 804522, upload-time = "2026-07-19T00:18:31.362Z" }, + { url = "https://files.pythonhosted.org/packages/76/7c/cc4e7655181b2d9235b704f2c5e19d8eff002bbc437bae59baee0e381aca/regex-2026.7.19-cp313-cp313t-win32.whl", hash = "sha256:64b6ca7391a1395c2638dd5c7456d67bea44fc6c5e8e92c5dc8aa6a8f23292b4", size = 269141, upload-time = "2026-07-19T00:18:33.479Z" }, + { url = "https://files.pythonhosted.org/packages/bb/14/961b4c7b05a2391c32dbc85e27773076671ef8f97f36cec70fe414734c02/regex-2026.7.19-cp313-cp313t-win_amd64.whl", hash = "sha256:f04b9f56b0e0614c0126be12c2c2d9f8850c1e57af302bd0a63bed379d4af974", size = 280036, upload-time = "2026-07-19T00:18:35.419Z" }, + { url = "https://files.pythonhosted.org/packages/ce/67/795644550d788ddbb6dc458c95895f8009978ea6d6ea76b005eb3f45e8c9/regex-2026.7.19-cp313-cp313t-win_arm64.whl", hash = "sha256:fcee38cd8e5089d6d4f048ba1233b3ad76e5954f545382180889112ff5cb712d", size = 279394, upload-time = "2026-07-19T00:18:37.454Z" }, + { url = "https://files.pythonhosted.org/packages/d2/25/0c4c452f8ef3efe456745b2f33195f5904b573fb4c2ff3f0cb9ec188461e/regex-2026.7.19-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:a81758ed242b861b72e778ba34d41366441a2e10b16b472784c88da2dea7e2dd", size = 496750, upload-time = "2026-07-19T00:18:39.633Z" }, + { url = "https://files.pythonhosted.org/packages/24/9e/b70ca6c1704f6c7cd32a9e143c86cc5968d10981eca284bad670c245ea7d/regex-2026.7.19-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4aa5435cdb3eb6f55fe98a171b05e3fbcd95fadaa4aa32acf62afd9b0cfdbcac", size = 297093, upload-time = "2026-07-19T00:18:41.583Z" }, + { url = "https://files.pythonhosted.org/packages/87/74/0b692da2520d51fbff19c88b83d97e4c702909dd02386c585998b7e2dbed/regex-2026.7.19-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:60be8693a1dadc210bbcbc0db3e26da5f7d01d1d5a3da594e99b4fa42df404f5", size = 292043, upload-time = "2026-07-19T00:18:43.347Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a7/1d478e614016045a33feae57446215f9fd65b665a5ceb2f891fb3183bc52/regex-2026.7.19-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19662dbedbe783d323196312d38f5ba53cf56296378252171985da6899887d3", size = 797214, upload-time = "2026-07-19T00:18:45.362Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ae/11b9c9411d92c30e3d2db32df5a31133e4a99a8fc397a604fd08f6c4bffb/regex-2026.7.19-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d15df07081d91b76ff20d43f94592ee110330152d617b730fdbe5ef9fb680053", size = 866433, upload-time = "2026-07-19T00:18:47.315Z" }, + { url = "https://files.pythonhosted.org/packages/b1/62/2b2efc4992f91d6d204b24c647c9f9412e85379d92b7c0ab9fdae622327e/regex-2026.7.19-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:56ad4d9f77df871a99e25c37091052a02528ec0eb059de928ee33956b854b45b", size = 911360, upload-time = "2026-07-19T00:18:49.588Z" }, + { url = "https://files.pythonhosted.org/packages/14/71/986ceea9aa3da548bf1357cad89b63915ec6d21ec957c8113b29ece567df/regex-2026.7.19-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7322ec6cc9fba9d49ab888bb82d67ac5625627aa168f0165139b17018df3fb8a", size = 801275, upload-time = "2026-07-19T00:18:51.767Z" }, + { url = "https://files.pythonhosted.org/packages/15/be/ce9d9534b2cda96eab32c548261224b9b4e220a4126f098f60f42ae7b4cd/regex-2026.7.19-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9c7472192ebfad53a6be7c4a8bfb2d64b81c0e93a1fc8c57e1dd0b638297b5d1", size = 777131, upload-time = "2026-07-19T00:18:54.053Z" }, + { url = "https://files.pythonhosted.org/packages/61/2b/58b5c710f2c3929515a25f3a1ca0dad0dcd4518d4fff3cf23bc7adb8dcd2/regex-2026.7.19-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c10b82c2634df08dfb13b1f04e38fe310d086ee092f4f69c0c8da234251e556e", size = 785020, upload-time = "2026-07-19T00:18:56.579Z" }, + { url = "https://files.pythonhosted.org/packages/84/03/5fe091935b74f15fe0f97998c215cae418d1c0413f6258c7d4d2e83aa37f/regex-2026.7.19-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:17ed5692f6acc4183e98331101a5f9e4f64d72fe58b753da4d444a2c77d05b12", size = 861263, upload-time = "2026-07-19T00:18:58.64Z" }, + { url = "https://files.pythonhosted.org/packages/d8/fa/d60bf82e10841eef62a9e32aac401468f05fddfbcb2942e342b1ba3d2433/regex-2026.7.19-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:22a992de9a0d91bda927bf02b94351d737a0302905432c88a53de7c4b9ce62e2", size = 766199, upload-time = "2026-07-19T00:19:00.705Z" }, + { url = "https://files.pythonhosted.org/packages/bf/5d/11e64d151b0662b81d6bf644c74dc118d461df85bdf2577fadbbf751788a/regex-2026.7.19-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:618a0aed532be87294c4477b0481f3aa0f1520f4014a4374dd4cf789b4cd2c97", size = 851317, upload-time = "2026-07-19T00:19:03.015Z" }, + { url = "https://files.pythonhosted.org/packages/7c/34/532efb87488d90807bae6a443d357ee5e2728a478c597619c8aaa17cc0bd/regex-2026.7.19-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ce9e679f776649746729b6c86382da519ef649c8e34cc41df0d2e5e0f6c36d4", size = 789557, upload-time = "2026-07-19T00:19:05.338Z" }, + { url = "https://files.pythonhosted.org/packages/d6/90/3a8d5ca977171ec3ae21a71207d2228b2663bde14d7f7ef0e6363ecf9290/regex-2026.7.19-cp314-cp314-win32.whl", hash = "sha256:73f272fba87b8ccfe70a137d02a54af386f6d27aa509fbffdd978f5947aae1aa", size = 272531, upload-time = "2026-07-19T00:19:07.487Z" }, + { url = "https://files.pythonhosted.org/packages/96/e1/8862885e70409de70e8c005f57fb2e7be8d9ef0317250d60f4c9660a300d/regex-2026.7.19-cp314-cp314-win_amd64.whl", hash = "sha256:d721e53758b2cca74990185eb0671dd466d7a388a1a45d0c6f4c13cef41a68ac", size = 280831, upload-time = "2026-07-19T00:19:09.46Z" }, + { url = "https://files.pythonhosted.org/packages/08/82/2693e53e29f9104d9de95d37ce4dd826bd32d5f9c0085d3aa6ac042675c4/regex-2026.7.19-cp314-cp314-win_arm64.whl", hash = "sha256:65fa6cb38ed5e9c3637e68e544f598b39c3b86b808ed0627a67b68320384b459", size = 281099, upload-time = "2026-07-19T00:19:11.398Z" }, + { url = "https://files.pythonhosted.org/packages/92/b7/9a01aa16461a18cde9d7b9c3ab21e501db2ce33725f53014342b91df2b0a/regex-2026.7.19-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:5a2721c8720e2cb3c209925dfb9200199b4b07361c9e01d321719404b21458b3", size = 501121, upload-time = "2026-07-19T00:19:13.425Z" }, + { url = "https://files.pythonhosted.org/packages/f3/5e/bbaeca815dc9191c424c94a4fdc5c87c75748a64a6271821212ebdd4e1a3/regex-2026.7.19-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:199535629f25caf89698039af3d1ad5fcae7f933e2112c73f1cdf49165c99518", size = 299415, upload-time = "2026-07-19T00:19:15.43Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d6/0dd1a321afaab95eb7ff44aa0f637301786f1dc71c6b797b9ed236ed8890/regex-2026.7.19-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9b60d7814174f059e5de4ab98271cc5ba9259cfea55273a81544dceea32dc8d9", size = 294483, upload-time = "2026-07-19T00:19:17.879Z" }, + { url = "https://files.pythonhosted.org/packages/92/5f/40bacf91d0904f812e13bbbab3864604c463eced8afdc54aeaa50492ea95/regex-2026.7.19-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbece16025afda5e3031af0c4059207e61dcf73ef13af844964f57f387d1c435", size = 811833, upload-time = "2026-07-19T00:19:20.102Z" }, + { url = "https://files.pythonhosted.org/packages/94/7c/4902744261f775aeede8b5627314b38482da29cf49a57b66a6fb753246c5/regex-2026.7.19-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d24ecb4f5e009ea0bd275ee37ad9953b32005e2e5e60f8bbae16da0dbbf0d3a0", size = 871270, upload-time = "2026-07-19T00:19:22.365Z" }, + { url = "https://files.pythonhosted.org/packages/16/70/6980c9be6bf21c0a60ed3e0aea39cf419ecf3b08d1d9947bc56e196ef186/regex-2026.7.19-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8cae6fd77a5b72dae505084b1a2ee0360139faf72fedbab667cd7cc65aae7a6a", size = 917534, upload-time = "2026-07-19T00:19:24.529Z" }, + { url = "https://files.pythonhosted.org/packages/52/92/8b2bd872782ce8c42691e39acb38eb8efe014e5ddb78ad7d943d6f197ce9/regex-2026.7.19-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9724e6cb5e478cd7d8cabf027826178739cb18cf0e117d0e32814d479fa02276", size = 816135, upload-time = "2026-07-19T00:19:26.919Z" }, + { url = "https://files.pythonhosted.org/packages/de/2d/33a602f657bdc4041f17d79f92ab18261d255d91a06117a6e29df023e5e2/regex-2026.7.19-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:572fc57b0009c735ee56c175ea021b637a15551a312f56734277f923d6fd0f6c", size = 785492, upload-time = "2026-07-19T00:19:29.192Z" }, + { url = "https://files.pythonhosted.org/packages/9e/36/0987cf4cb271680064a70d24a475873775a151d0b7058698a006cb0cae4a/regex-2026.7.19-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:20568e182eb82d39a6bf7cff3fd58566f14c75c6f74b2c8c96537eecf9010e3a", size = 800658, upload-time = "2026-07-19T00:19:31.392Z" }, + { url = "https://files.pythonhosted.org/packages/a8/24/c14f31c135e1ba55fa4f9a58ca98d0842512bf6188230763c31c8f449e3b/regex-2026.7.19-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:1d58561843f0ff7dc78b4c28b5e2dc388f3eff94ebc8a232a3adba961fc00009", size = 865073, upload-time = "2026-07-19T00:19:33.485Z" }, + { url = "https://files.pythonhosted.org/packages/14/85/181a12211f22469f24d2de1ebddfe397d2396e2c29013b9a58134a91069a/regex-2026.7.19-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:61bb1bd45520aacd56dd80943bd34991fb5350afdd1f36f2282230fd5154a218", size = 773684, upload-time = "2026-07-19T00:19:35.599Z" }, + { url = "https://files.pythonhosted.org/packages/23/58/bd1a0c1a62251366f8d21f41b1ea3c76994962071b8b6ea42f72d505c0f0/regex-2026.7.19-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:cd3584591ea4429026cdb931b054342c2bcf189b44ff367f8d5c15bc092a2966", size = 857769, upload-time = "2026-07-19T00:19:37.738Z" }, + { url = "https://files.pythonhosted.org/packages/e4/4f/f7e2dad6756b2fe1fe75dd90a628c3b45f249d39f948dd90cd2476325417/regex-2026.7.19-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5cc26a66e212fa5d6c6170c3a40d99d888db3020c6fdab1523250d4341382e44", size = 804546, upload-time = "2026-07-19T00:19:40.229Z" }, + { url = "https://files.pythonhosted.org/packages/2b/d7/01d31d5bdb09bc026fab77f59a371fdf8f9b292e4810546c56182ca70498/regex-2026.7.19-cp314-cp314t-win32.whl", hash = "sha256:2c4e61e2e1be56f63ec3cc618aa9e0de81ef6f43d177205451840022e24f5b78", size = 274526, upload-time = "2026-07-19T00:19:42.398Z" }, + { url = "https://files.pythonhosted.org/packages/52/0e/cea4ce73bc0a8247a0748228ae6669984c7e1f8134b6fa66e59c0572e0ea/regex-2026.7.19-cp314-cp314t-win_amd64.whl", hash = "sha256:c639ea314df70a7b2811e8020448c75af8c9445f5a60f8a4ced81c306a9380c2", size = 283763, upload-time = "2026-07-19T00:19:44.644Z" }, + { url = "https://files.pythonhosted.org/packages/6f/b6/26e41975febae63b7a6e3e02f32cff6cff2e4f10d19c929082f56aebf7c6/regex-2026.7.19-cp314-cp314t-win_arm64.whl", hash = "sha256:9a15e785f244f3e07847b984ce8773fc3da10a9f3c131cc49a4c5b4d672b4547", size = 283451, upload-time = "2026-07-19T00:19:46.639Z" }, +] + [[package]] name = "ruff" version = "0.16.2"