diff --git a/.claude/agents/test-writer.md b/.claude/agents/test-writer.md index 7d6ca46..4a362a6 100644 --- a/.claude/agents/test-writer.md +++ b/.claude/agents/test-writer.md @@ -25,7 +25,7 @@ You are a test engineer for OpenContext. You write pytest tests that follow the ## Conventions to follow -**asyncio:** `asyncio_mode = "auto"` is in `pyproject.toml`. Do NOT add `@pytest.mark.asyncio` decorators. +**asyncio:** `asyncio_mode = "auto"` is in `pyproject.toml`. `@pytest.mark.asyncio` is present on existing tests (redundant but harmless); omit it in new tests. **Test structure:** ```python @@ -40,18 +40,20 @@ class TestFeatureName: "timeout": 120, } + @pytest.mark.asyncio async def test_verb_noun_condition(self, plugin_config): ... ``` **Mock pattern for httpx:** ```python -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, Mock, patch -with patch("plugins.{name}.plugin.httpx.AsyncClient") as mock_cls: +with patch("httpx.AsyncClient") as mock_cls: mock_client = AsyncMock() - mock_response = MagicMock() + mock_response = Mock() mock_response.json.return_value = {"success": True, "result": []} + mock_response.raise_for_status = Mock() mock_client.get = AsyncMock(return_value=mock_response) mock_cls.return_value = mock_client ``` diff --git a/.claude/rules/code-style.md b/.claude/rules/code-style.md index 720ef5a..2b4693c 100644 --- a/.claude/rules/code-style.md +++ b/.claude/rules/code-style.md @@ -22,7 +22,7 @@ from core.plugin_manager import PluginManager ``` ## Type Hints -Required on all public method signatures. Prefer `X | None` for optional types, consistent with the Python >=3.11 baseline and existing codebase usage. +Required on all public method signatures. Prefer `X | None` for optional types in new or updated code. Existing `Optional[X]` annotations are also acceptable until touched for other changes. ## Docstrings Google-style, triple-quoted. Class docstring before `__init__`. Methods get a one-liner minimum. diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index c6be04c..6684d32 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -6,8 +6,21 @@ alwaysApply: false # Testing Conventions +## Where tests live + +See [`tests/README.md`](../../tests/README.md). Summary: + +| Path | Purpose | +|------|---------| +| `tests/unit/` | Fast tests with mocks (`pytest.mark.unit`) | +| `tests/integration/` | Hermetic cross-boundary flows (`pytest.mark.integration`) | +| `tests/security/` | SSRF / SQL / SoQL guards (`pytest.mark.security`) | +| `tests/smoke/` | Minimal CLI/protocol smoke (`pytest.mark.smoke`) | + +Markers are registered in `pyproject.toml` under `[tool.pytest.ini_options]`. + ## asyncio -`asyncio_mode = "auto"` is set in `pyproject.toml`. Do NOT add `@pytest.mark.asyncio` to individual async test methods — it's redundant. +`asyncio_mode = "auto"` is set in `pyproject.toml`. `@pytest.mark.asyncio` is present on many existing tests (redundant but harmless); omit it in new tests. ## Test Structure ```python @@ -23,21 +36,22 @@ class TestPluginInitialization: "timeout": 120, } + @pytest.mark.asyncio async def test_initialize_succeeds_on_valid_config(self, plugin_config): """test_verb_noun_condition naming.""" ``` ## Mock Pattern ```python -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, Mock, patch -# Patch at the import location, not the definition location -with patch("plugins.ckan.plugin.httpx.AsyncClient") as mock_client_class: +with patch("httpx.AsyncClient") as mock_client_class: mock_client = AsyncMock() - mock_response = MagicMock() + mock_response = Mock() mock_response.json.return_value = {"success": True, "result": []} + mock_response.raise_for_status = Mock() mock_client.get = AsyncMock(return_value=mock_response) - mock_client_class.return_value.__aenter__ = AsyncMock(return_value=mock_client) + mock_client_class.return_value = mock_client ``` ## AWS / boto3 diff --git a/.claude/settings.json b/.claude/settings.json index b085ede..fd05069 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,5 +1,5 @@ { - "$schema": "https://schemas.anthropic.com/claude-code/settings.json", + "$schema": "https://json.schemastore.org/claude-code-settings.json", "permissions": { "allow": [ "Bash(uv run *)", @@ -63,7 +63,7 @@ "hooks": [ { "type": "command", - "command": "python3 -c \"import sys,json,subprocess; d=json.load(sys.stdin); fp=d.get('tool_input',{}).get('file_path',''); fp.endswith('.py') and subprocess.run(['uv','run','ruff','format',fp],capture_output=True)\"" + "command": "uv run python3 -c \"import sys,json,subprocess; d=json.load(sys.stdin); fp=d.get('tool_input',{}).get('file_path',''); fp.endswith('.py') and subprocess.run(['uv','run','ruff','format',fp],capture_output=True)\"" } ] } diff --git a/.claude/skills/add-plugin/SKILL.md b/.claude/skills/add-plugin/SKILL.md index fe0c707..ea01bb7 100644 --- a/.claude/skills/add-plugin/SKILL.md +++ b/.claude/skills/add-plugin/SKILL.md @@ -67,18 +67,18 @@ uv run opencontext test --url http://localhost:8000/mcp ``` ### 5. Write tests -Create `tests/test_{name}_plugin.py` following the structure in `tests/test_ckan_plugin.py`: +Create `tests/unit/plugins/{name}/test_{name}_plugin.py` following the structure in `tests/unit/plugins/ckan/test_ckan_plugin.py`: - Group by `TestXxx` classes - Use `AsyncMock` for httpx calls (see mock pattern in `.claude/rules/testing.md`) - Fixtures return dicts, not Pydantic models ```bash -uv run pytest tests/test_{name}_plugin.py -v --cov=custom_plugins --cov-report=term-missing +uv run pytest tests/unit/plugins/{name}/test_{name}_plugin.py -v --cov=custom_plugins --cov-report=term-missing ``` ### 6. Verify coverage gate still passes ```bash -uv run pytest tests/ -n auto --cov=core --cov=plugins --cov-fail-under=80 +uv run pytest tests/ -n auto --cov=core --cov=plugins --cov=server --cov-fail-under=80 ``` ## Common Mistakes diff --git a/.claude/skills/fix-coverage/SKILL.md b/.claude/skills/fix-coverage/SKILL.md index adf5a09..d97ebca 100644 --- a/.claude/skills/fix-coverage/SKILL.md +++ b/.claude/skills/fix-coverage/SKILL.md @@ -12,7 +12,7 @@ command: /fix-coverage ## 1. Find the gaps ```bash uv run pytest tests/ -n auto \ - --cov=core --cov=plugins \ + --cov=core --cov=plugins --cov=server \ --cov-report=term-missing \ --cov-report=html ``` @@ -33,7 +33,7 @@ Don't add tests for these unless you're actually fixing a bug there. 4. New code you just wrote ## 4. Write targeted tests -Follow patterns in `tests/test_ckan_plugin.py`: +Follow patterns in `tests/unit/plugins/ckan/test_ckan_plugin.py`: - Group by `TestXxx` class - `AsyncMock` for httpx, `MagicMock` for sync - Patch at the import location: `patch("plugins.arcgis.plugin.httpx.AsyncClient")` @@ -44,7 +44,7 @@ Focus on uncovered branches (the `term-missing` output shows which lines). One t ## 5. Verify ```bash uv run pytest tests/ -n auto \ - --cov=core --cov=plugins \ + --cov=core --cov=plugins --cov=server \ --cov-fail-under=80 ``` CI gate: must pass with `--cov-fail-under=80`. diff --git a/.cursor/rules/project.mdc b/.cursor/rules/project.mdc new file mode 100644 index 0000000..5725588 --- /dev/null +++ b/.cursor/rules/project.mdc @@ -0,0 +1,22 @@ +--- +description: Binds Cursor agents to repo-specific context via AI.md. +alwaysApply: true +--- + +@../../AI.md + +## Cursor specifics + +- Repo-wide facts live in `@../../AI.md`. Claude Code permissions, hooks, and bundled prompts live in `CLAUDE.md` and `.claude/`; do not load `CLAUDE.md` as the primary context unless the user opens it. +- The repo ships `.vscode/tasks.json` (Cursor-compatible). On folder open it runs `git pull` then `uv sync --all-extras`. The `Test: run all` and `Lint: ruff fix + format` tasks mirror the CI commands in `AI.md` if you prefer the task UI. +- The `@../../AI.md` include resolves relative to this file; if this rule is moved, update the path. + +## Context Hygiene + +- As the final step of any agentic task, after tests pass and before closing the task, scan for anything non-obvious you discovered: architectural decisions made, constraints encountered, non-obvious file relationships, gotchas debugged, commands that only work a certain way. +- If any such discovery would have saved you time had it been in `AI.md` at the start of the session, add it there. +- Add to `AI.md` only — never bloat this file with project facts. +- Write additions as pointers or single-line facts, not paragraphs. +- Do not add things that are obvious from the code, already documented in `docs/`, generic best practices, or specific to just the current task. +- If nothing non-obvious was discovered, make no changes — do not add placeholder or "session notes" content. +- Never restructure or reformat existing `AI.md` content during an update — append only, to the most relevant existing section. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 818e66c..04f1543 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -107,6 +107,7 @@ jobs: -n auto \ --cov=core \ --cov=plugins \ + --cov=server \ --cov-report=term-missing \ --cov-fail-under=80 diff --git a/.github/workflows/infra.yml b/.github/workflows/infra.yml index 3b20c6b..6f23487 100644 --- a/.github/workflows/infra.yml +++ b/.github/workflows/infra.yml @@ -37,6 +37,7 @@ jobs: - name: Create placeholder artifacts for validation run: | python3 -c "import zipfile; zipfile.ZipFile('terraform/aws/lambda-deployment.zip', 'w').close()" + python3 -c "import zipfile; zipfile.ZipFile('terraform/gcp/gcf-deployment.zip', 'w').close()" - name: Validate all Terraform directories run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d7bde99..431e990 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -51,6 +51,7 @@ jobs: -n auto \ --cov=core \ --cov=plugins \ + --cov=server \ --cov-report=term-missing \ --cov-fail-under=80 diff --git a/.gitignore b/.gitignore index f1de949..d72a199 100644 --- a/.gitignore +++ b/.gitignore @@ -213,6 +213,7 @@ lambda-deployment.zip terraform/*/.terraform/ terraform/*/.terraform.lock.hcl terraform/*/lambda-deployment.zip +terraform/*/gcf-deployment.zip # Terraform state (contains secrets, ignore across all folders) **/*.tfstate diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 0d2ea57..30458ec 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -49,7 +49,7 @@ { "label": "Test: run all", "type": "shell", - "command": "uv run pytest tests/ -n auto --cov=core --cov=plugins --cov-fail-under=80", + "command": "uv run pytest tests/ -n auto --cov=core --cov=plugins --cov=server --cov-fail-under=80", "presentation": { "reveal": "always", "panel": "shared" diff --git a/AI.md b/AI.md new file mode 100644 index 0000000..c5ab9b3 --- /dev/null +++ b/AI.md @@ -0,0 +1,68 @@ +# AI.md + +OpenContext is an extensible MCP (Model Context Protocol) framework that exposes +public open-data portals (CKAN, ArcGIS Hub, Socrata) to AI assistants. Stack: +Python 3.11+ managed with `uv`, Typer CLI, aiohttp dev server, deployed to AWS +(Lambda + API Gateway) or GCP (Cloud Functions gen2) via Terraform (`--cloud`). + +## Directory map (non-obvious only) + +- `custom_plugins/` — drop-in user plugins; auto-discovered at startup and copied into the Lambda bundle by `cli/commands/deploy.py` +- `server/adapters/` — runtime entry points: `aws_lambda.py` for production, local aiohttp server for dev +- `examples/` — per-city example `config.yaml` files +- `client/` — optional Go stdio↔HTTP MCP bridge for stdio-only clients +- `.claude/` — Claude Code project config (`settings.json`, glob-scoped `rules/`, `skills/`, `agents/`); see `CLAUDE.md` +- `.cursor/` — Cursor rules; see `.cursor/rules/project.mdc` + +Self-evident dirs (`core/`, `plugins/`, `cli/`, `tests/`, `docs/`, `terraform/`) are intentionally omitted. + +## Dev commands + +- Install full dev deps (matches CI): `uv sync --all-extras` +- CLI-only install: `uv sync --extra cli` +- Bootstrap config: `cp config-example.yaml config.yaml` then edit; optional `uv run pre-commit install` +- Local MCP server (http://localhost:8000/mcp): `uv run opencontext serve` +- Smoke test running server: `uv run opencontext test --url http://localhost:8000/mcp` +- Tests with CI coverage gate: `uv run pytest tests/ -n auto --cov=core --cov=plugins --cov=server --cov-report=term-missing --cov-fail-under=80` +- Targeted suites by marker: `uv run pytest tests/unit -m unit -v` (also `integration`, `security`, `smoke`) +- Go client tests: `cd client && go test ./...` +- Lint as CI runs it (no autofix): `uv run ruff check core/ plugins/ server/ tests/` +- Local autofix + format: `uv run ruff check core/ plugins/ server/ tests/ --fix --unsafe-fixes && uv run ruff format core/ plugins/ server/ tests/` +- CVE audit (CI parity): `uv run pip-audit -r requirements.txt` +- Validate before deploy: `uv run opencontext validate --env staging` +- Deploy: `uv run opencontext deploy --env staging` (requires TTY; prompts for confirmation) + +`--env` defaults to `staging` on every command that accepts it. +`--cloud` defaults to `aws` on every command that accepts it. + +## Hard constraints + +- **Exactly one `plugins.*.enabled: true`** in `config.yaml`. Enforced at server startup (`core/validators.py`, `core/plugin_manager.py`) and pre-deploy (`cli/commands/deploy.py::_validate_single_plugin`). Multiple data sources require forking the repo, not toggling more flags. +- **`config.yaml` is gitignored.** Never commit it. Template-level changes go in `config-example.yaml`. +- **Branch off `develop`; PRs target `develop`, not `main`.** Prefixes: `feature/`, `bugfix/`, `docs/`, `chore/`. +- **CI fails below 80% coverage** on `core`, `plugins`, `server`. New code needs tests. `pyproject.toml` `[tool.coverage.run].omit` documents which modules are excluded — adding logic to those without un-omitting will not earn coverage. +- **MCP tool names are namespaced `plugin__tool_name`** (double underscore, prepended automatically). Plugins must NOT include the prefix in their own `get_tools()` names. +- **Lambda packaging targets `x86_64-manylinux2014` + Python 3.11.** `cli/commands/deploy.py::_package_lambda` runs `uv pip install -r requirements.txt --python-platform x86_64-manylinux2014 --python-version 3.11`. New deps must be wheel-compatible with that target — local `uv sync` success is not sufficient proof. +- **Lambda bundle uses `requirements.txt`, not the `uv` lockfile.** Update both when adding deps; `pip-audit` in CI scans `requirements.txt`. +- **`asyncio_mode = "auto"`** is set in `pyproject.toml`. `@pytest.mark.asyncio` is present on existing tests (redundant but harmless); omit it in new tests. +- **Never modify `config.yaml`, `.env*`, `terraform/**/*.tfvars`, or `terraform/**/*.tfstate*`** from agent tools; these are denied in `.claude/settings.json` and contain secrets / state. +- **Plugins inherit `MCPPlugin`** (`core/interfaces.py`) and must implement `initialize`, `shutdown`, `get_tools`, `execute_tool`, `health_check`. Data-source plugins inherit `DataPlugin` and add `search_datasets`, `get_dataset`, `query_data`. +- **`PluginType` enum values** (`core/interfaces.py`): `OPEN_DATA`, `CUSTOM_API`, `DATABASE`, `ANALYTICS`. + +## Pointers + +- `@docs/GETTING_STARTED.md` +- `@docs/QUICKSTART.md` +- `@docs/CLI.md` +- `@docs/ARCHITECTURE.md` +- `@docs/BUILT_IN_PLUGINS.md` +- `@docs/CUSTOM_PLUGINS.md` +- `@docs/DEPLOYMENT.md` +- `@docs/TESTING.md` +- `@docs/FAQ.md` +- `@tests/README.md` +- `@CONTRIBUTING.md` +- `@.claude/settings.json` +- `@.claude/rules/` +- `@.claude/skills/` +- `@.claude/agents/` diff --git a/CLAUDE.md b/CLAUDE.md index 6ad18e4..8acb6fd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,11 +11,9 @@ Each repo fork runs **exactly one** plugin. `core/validators.py` and `core/plugi ```bash uv sync --all-extras # install all deps including cli + dev cp config-example.yaml config.yaml # then edit for your data source -uv run pre-commit install # set up git hooks +pre-commit install # set up git hooks ``` -`requirements.txt` pins dependencies for Lambda bundles and `pip-audit` in CI; local development uses `uv sync`, not `pip install -r requirements.txt`. - ## Common Commands ```bash @@ -26,11 +24,11 @@ opencontext serve opencontext test --url http://localhost:8000/mcp # Tests -uv run pytest tests/ -n auto --cov=core --cov=plugins --cov-fail-under=80 +pytest tests/ -n auto --cov=core --cov=plugins --cov-fail-under=80 # Lint + format (matches CI) -uv run ruff check core/ plugins/ server/ tests/ --fix --unsafe-fixes -uv run ruff format core/ plugins/ server/ tests/ +ruff check core/ plugins/ server/ tests/ --fix --unsafe-fixes +ruff format core/ plugins/ server/ tests/ # CLI opencontext validate --env staging # validate config + Terraform before deploy @@ -49,9 +47,11 @@ core/ # Framework kernel — interfaces, MCP server, plugin manager, v plugins/ # Built-in plugins: ckan/, arcgis/, socrata/ custom_plugins/ # Drop user plugins here — auto-discovered at startup cli/ # Typer CLI (opencontext command) +server/ # HTTP adapters: local aiohttp + AWS Lambda entry point server/adapters/ # local aiohttp dev server + AWS Lambda entry point tests/ # pytest suite (80% coverage required) terraform/aws/ # Lambda + API Gateway + IAM IaC +terraform/gcp/ # Cloud Functions gen2 + GCS + IAM IaC (see terraform/gcp/README.md) docs/ # Architecture, deployment, plugin authoring guides ``` @@ -72,7 +72,7 @@ To add a custom plugin: 2. Implement: `initialize`, `shutdown`, `get_tools`, `execute_tool`, `health_check` 3. Enable in `config.yaml` under `plugins.my_plugin.enabled: true` -See `docs/CUSTOM_PLUGINS.md` for the interface contract and `.claude/skills/add-plugin/SKILL.md` for the step-by-step workflow. +See `docs/CUSTOM_PLUGINS.md` for full interface contract. ## Config (`config.yaml`) @@ -112,15 +112,6 @@ uv run pip-audit -r requirements.txt uv run pytest tests/ -n auto --cov=core --cov=plugins --cov-fail-under=80 ``` -## Branching - -Branch off `develop`: `feature/`, `bugfix/`, `docs/`, `chore/`. PRs target `develop`, not `main`. -Terraform workspace naming: `{city}-{env}` (e.g., `chicago-staging`) — created by `opencontext configure`. - -## Conventions - -Coding style, test patterns, plugin authoring, and infrastructure rules live in `.claude/rules/` and load automatically for relevant file types. - ## Gotchas - **Multiple plugins enabled** → hard crash at startup. Only one `enabled: true` allowed. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e300144..f638c06 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,7 +21,7 @@ uv run pre-commit install # set up lint/format hooks **PR checklist:** - [ ] `uv run ruff check core/ plugins/ server/ tests/` passes -- [ ] `uv run pytest tests/ -n auto --cov=core --cov=plugins --cov-fail-under=80` passes +- [ ] `uv run pytest tests/ -n auto --cov=core --cov=plugins --cov=server --cov-fail-under=80` passes - [ ] New code has tests; coverage gate is enforced in CI ## Code Style @@ -40,16 +40,21 @@ Hooks: Ruff (Python), yamllint, gofmt. Type hints are expected on all public met ## Testing ```bash -uv run pytest tests/ -n auto --cov=core --cov=plugins --cov-fail-under=80 +uv run pytest tests/ -n auto --cov=core --cov=plugins --cov=server --cov-fail-under=80 + +# Targeted suites (markers defined in pyproject.toml) +uv run pytest tests/integration -m integration -v +uv run pytest tests/unit -m unit -v +uv run pytest tests/security -m security -v # HTML coverage report (find gaps) -uv run pytest tests/ --cov=core --cov=plugins --cov-report=html +uv run pytest tests/ --cov=core --cov=plugins --cov=server --cov-report=html open htmlcov/index.html ``` - Coverage gate is 80% — enforced in CI. New code needs tests. -- Built-in plugin tests: `tests/plugins/{plugin_name}/` -- Custom plugin tests: `tests/custom_plugins/{plugin_name}/` +- See [`tests/README.md`](tests/README.md) for the layout: `tests/unit/`, `tests/integration/` (hermetic cross-boundary), `tests/security/`, `tests/smoke/`. +- Built-in plugin unit tests: `tests/unit/plugins/{ckan,arcgis,socrata}/` - Mock external HTTP (use `httpx`'s mock transport or `pytest-httpx`) — do not hit live APIs in unit tests. ## Plugin Development diff --git a/README.md b/README.md index bfd222d..88c6d12 100644 --- a/README.md +++ b/README.md @@ -25,12 +25,16 @@ uv run opencontext authenticate # 2. Configure interactively (creates config.yaml + Terraform workspace) uv run opencontext configure +# or for GCP -- note: you must have a GCP project set up; you will be prompted for the project ID when you run this command +uv run opencontext configure --cloud gcp # 3. Test locally (optional) uv run opencontext serve # 4. Deploy uv run opencontext deploy --env staging +# or for GCP: +uv run opencontext deploy --cloud gcp --env staging ``` Connect via **Claude Connectors** (same steps on both Claude.ai and Claude Desktop): @@ -62,7 +66,7 @@ Details: [Getting Started — full walkthrough](docs/GETTING_STARTED.md) (sectio | [Architecture](docs/ARCHITECTURE.md) | System design and plugins | | [Built-in Plugins](docs/BUILT_IN_PLUGINS.md) | CKAN, ArcGIS Hub, Socrata plugin details | | [Custom Plugins](docs/CUSTOM_PLUGINS.md) | How to write your own plugin | -| [Deployment](docs/DEPLOYMENT.md) | AWS, Terraform, monitoring | +| [Deployment](docs/DEPLOYMENT.md) | AWS & GCP (`--cloud`), Terraform, monitoring | | [Testing](docs/TESTING.md) | Local testing (Terminal, Claude, MCP Inspector) | diff --git a/cli/commands/authenticate.py b/cli/commands/authenticate.py index dfe9c62..925e831 100644 --- a/cli/commands/authenticate.py +++ b/cli/commands/authenticate.py @@ -5,9 +5,10 @@ import subprocess import sys +import typer from rich.table import Table -from cli.utils import console, friendly_exit +from cli.utils import console, friendly_exit, normalize_cloud def _is_available( @@ -47,8 +48,14 @@ def _find_pip() -> list[str] | None: @friendly_exit -def authenticate() -> None: +def authenticate( + cloud: str = typer.Option("aws", "--cloud", help="Cloud provider: aws or gcp"), +) -> None: """Check all prerequisites and print a status table.""" + if not isinstance(cloud, str): + cloud = "aws" + cloud = normalize_cloud(cloud) + checks: list[tuple[str, bool, str]] = [] # --- 1. Python >= 3.11 (cannot auto-install) --- @@ -96,65 +103,97 @@ def authenticate() -> None: uv_available = shutil.which("uv") is not None - # --- 3. AWS CLI (auto-install via uv/pip if missing) --- - result = _is_available(["aws", "--version"]) - if result: - version = ( - result.stdout.strip().split()[0] if result.stdout.strip() else "installed" - ) - checks.append(("AWS CLI", True, version)) - else: - installed = False - if uv_available: - installed = _auto_install( - "awscli", ["uv", "pip", "install", "awscli"], "AWS CLI via uv" + # --- 3/4. Cloud CLI + credentials --- + if cloud == "aws": + # --- 3. AWS CLI (auto-install via uv/pip if missing) --- + result = _is_available(["aws", "--version"]) + if result: + version = ( + result.stdout.strip().split()[0] if result.stdout.strip() else "installed" ) - if not installed: - pip_cmd = _find_pip() - if pip_cmd: + checks.append(("AWS CLI", True, version)) + else: + installed = False + if uv_available: installed = _auto_install( - "awscli", - [*pip_cmd, "install", "awscli"], - "AWS CLI via pip fallback", + "awscli", ["uv", "pip", "install", "awscli"], "AWS CLI via uv" ) + if not installed: + pip_cmd = _find_pip() + if pip_cmd: + installed = _auto_install( + "awscli", + [*pip_cmd, "install", "awscli"], + "AWS CLI via pip fallback", + ) - if installed: - result = _is_available(["aws", "--version"]) - if result: - version = ( - result.stdout.strip().split()[0] - if result.stdout.strip() - else "installed" + if installed: + result = _is_available(["aws", "--version"]) + if result: + version = ( + result.stdout.strip().split()[0] + if result.stdout.strip() + else "installed" + ) + checks.append(("AWS CLI", True, f"{version} (auto-installed)")) + else: + installed = False + + if not installed: + checks.append( + ( + "AWS CLI", + False, + "Install: https://docs.aws.amazon.com/cli/latest/userguide/install-cliv2.html", + ) ) - checks.append(("AWS CLI", True, f"{version} (auto-installed)")) - else: - installed = False - if not installed: + # --- 4. AWS credentials (cannot auto-install) --- + result = _is_available(["aws", "sts", "get-caller-identity"], timeout=15) + if result: + try: + identity = json.loads(result.stdout) + checks.append( + ( + "AWS Credentials", + True, + f"Account: {identity.get('Account', 'unknown')}", + ) + ) + except json.JSONDecodeError: + checks.append(("AWS Credentials", False, "Run: aws configure")) + else: + checks.append(("AWS Credentials", False, "Run: aws configure")) + else: + # --- 3. gcloud CLI --- + result = _is_available(["gcloud", "--version"]) + if result: + first_line = result.stdout.strip().splitlines()[0] if result.stdout.strip() else "installed" + checks.append(("gcloud CLI", True, first_line)) + else: checks.append( ( - "AWS CLI", + "gcloud CLI", False, - "Install: https://docs.aws.amazon.com/cli/latest/userguide/install-cliv2.html", + "Install: https://cloud.google.com/sdk/docs/install", ) ) - # --- 4. AWS credentials (cannot auto-install) --- - result = _is_available(["aws", "sts", "get-caller-identity"], timeout=15) - if result: - try: - identity = json.loads(result.stdout) + # --- 4. GCP ADC credentials --- + result = _is_available( + ["gcloud", "auth", "application-default", "print-access-token"], + timeout=15, + ) + if result and result.stdout.strip(): + checks.append(("GCP Credentials (ADC)", True, "Application Default Credentials available")) + else: checks.append( ( - "AWS Credentials", - True, - f"Account: {identity.get('Account', 'unknown')}", + "GCP Credentials (ADC)", + False, + "Run: gcloud auth application-default login", ) ) - except json.JSONDecodeError: - checks.append(("AWS Credentials", False, "Run: aws configure")) - else: - checks.append(("AWS Credentials", False, "Run: aws configure")) # --- 5. Terraform (cannot auto-install — binary download required) --- result = _is_available(["terraform", "--version"]) diff --git a/cli/commands/configure.py b/cli/commands/configure.py index 7f4c5cb..314e44e 100644 --- a/cli/commands/configure.py +++ b/cli/commands/configure.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os import subprocess from pathlib import Path @@ -15,6 +16,7 @@ friendly_exit, get_project_root, get_terraform_dir, + normalize_cloud, run_cmd, ) @@ -22,6 +24,7 @@ # Bucket name must match the `backend "s3"` block in terraform/aws/main.tf. TERRAFORM_STATE_BUCKET = "opencontext-terraform-state" +TERRAFORM_STATE_BUCKET_GCP = "opencontext-terraform-state-gcp" def _ensure_state_bucket(bucket_name: str, region: str) -> None: @@ -82,6 +85,73 @@ def _ensure_state_bucket(bucket_name: str, region: str) -> None: ) +def _ensure_gcp_state_bucket(bucket_name: str, region: str, project_id: str) -> None: + """Check that the Terraform GCS state bucket exists; create it if not.""" + describe_cmd = [ + "gcloud", + "storage", + "buckets", + "describe", + f"gs://{bucket_name}", + "--project", + project_id, + ] + try: + result = subprocess.run( + describe_cmd, + capture_output=True, + text=True, + timeout=20, + ) + if result.returncode == 0: + console.print( + f"[dim]Terraform state bucket [bold]{bucket_name}[/bold] already exists.[/dim]" + ) + return + except (FileNotFoundError, subprocess.TimeoutExpired): + console.print( + "[red]gcloud CLI is required to manage GCP state buckets.[/red]\n" + "Install it, then re-run [bold]opencontext authenticate --cloud gcp[/bold]." + ) + raise typer.Exit(1) + + console.print( + f"[yellow]Terraform state bucket [bold]{bucket_name}[/bold] not found. Creating...[/yellow]" + ) + run_cmd( + [ + "gcloud", + "storage", + "buckets", + "create", + f"gs://{bucket_name}", + "--project", + project_id, + "--location", + region, + "--uniform-bucket-level-access", + ], + spinner_msg="Creating GCS state bucket", + ) + run_cmd( + [ + "gcloud", + "storage", + "buckets", + "update", + f"gs://{bucket_name}", + "--project", + project_id, + "--versioning", + ], + spinner_msg="Enabling GCS bucket versioning", + ) + console.print( + f"[green]Created GCS bucket [bold]{bucket_name}[/bold] " + f"(region: {region}, versioning: enabled).[/green]" + ) + + def _load_example_defaults(project_root: Path) -> dict: example = project_root / "config-example.yaml" if example.exists(): @@ -197,22 +267,56 @@ def _write_tfvars( return path +def _write_tfvars_gcp( + terraform_dir: Path, + env: str, + project_id: str, + region: str, + function_name: str, + function_memory_mb: int, + function_timeout_sec: int, + min_instance_count: int, + max_instance_count: int, + artifact_bucket_name: str, +) -> Path: + path = terraform_dir / f"{env}.tfvars" + lines = [ + f'project_id = "{project_id}"', + f'gcp_region = "{region}"', + f'stage_name = "{env}"', + 'config_file = "../../config.yaml"', + f'function_name = "{function_name}"', + f"function_memory_mb = {function_memory_mb}", + f"function_timeout_sec = {function_timeout_sec}", + f"min_instance_count = {min_instance_count}", + f"max_instance_count = {max_instance_count}", + f'artifact_bucket_name = "{artifact_bucket_name}"', + ] + with open(path, "w") as f: + f.write("\n".join(lines) + "\n") + return path + + @friendly_exit def configure( - state_bucket: str = typer.Option( - TERRAFORM_STATE_BUCKET, + cloud: str = typer.Option("aws", "--cloud", help="Cloud provider: aws or gcp"), + state_bucket: str | None = typer.Option( + None, "--state-bucket", - help="S3 bucket name for Terraform state (default: opencontext-terraform-state)", + help="Terraform state bucket name override (S3 on aws, GCS on gcp)", ), ) -> None: """Interactive wizard to configure your OpenContext MCP server.""" - # When called programmatically (e.g. in tests), Typer does not resolve - # Option defaults — guard against receiving the raw OptionInfo sentinel. - if not isinstance(state_bucket, str): - state_bucket = TERRAFORM_STATE_BUCKET + if not isinstance(cloud, str): + cloud = "aws" + cloud = normalize_cloud(cloud) + if not isinstance(state_bucket, str) or state_bucket.strip() == "": + state_bucket = ( + TERRAFORM_STATE_BUCKET if cloud == "aws" else TERRAFORM_STATE_BUCKET_GCP + ) project_root = get_project_root() - terraform_dir = get_terraform_dir() + terraform_dir = get_terraform_dir(cloud) console.print("\n[bold]OpenContext Configuration Wizard[/bold]\n") @@ -262,60 +366,149 @@ def configure( plugin_config = _prompt_plugin_config(plugin, defaults) plugin_key = plugin.lower() - # Step 4 — AWS settings - console.print("\n[bold]AWS Settings[/bold]") - + city_slug = city_name.lower().replace(" ", "-") aws_defaults = defaults.get("aws", {}) - region = questionary.text( - "AWS region:", - default=aws_defaults.get("region", "us-east-1"), - ).ask() - if region is None: - raise typer.Exit(0) + gcp_defaults = defaults.get("gcp", {}) - city_slug = city_name.lower().replace(" ", "-") - suggested_lambda = f"{city_slug}-opencontext-mcp-{env}" + lambda_name = "" + lambda_memory = 0 + lambda_timeout = 0 + custom_domain = "" - lambda_name = questionary.text( - "Lambda function name:", - default=suggested_lambda, - ).ask() - if lambda_name is None: - raise typer.Exit(0) + gcp_project_id = "" + gcp_function_name = "" + gcp_memory_mb = 0 + gcp_timeout_sec = 0 + gcp_min_instances = 0 + gcp_max_instances = 0 + gcp_artifact_bucket_name = "" + + if cloud == "aws": + # Step 4 — AWS settings + console.print("\n[bold]AWS Settings[/bold]") + + region = questionary.text( + "AWS region:", + default=aws_defaults.get("region", "us-east-1"), + ).ask() + if region is None: + raise typer.Exit(0) - memory_str = questionary.text( - "Lambda memory (MB):", - default=str(aws_defaults.get("lambda_memory", 512)), - ).ask() - if memory_str is None: - raise typer.Exit(0) - lambda_memory = int(memory_str) + suggested_lambda = f"{city_slug}-opencontext-mcp-{env}" + lambda_name = questionary.text( + "Lambda function name:", + default=suggested_lambda, + ).ask() + if lambda_name is None: + raise typer.Exit(0) - timeout_str = questionary.text( - "Lambda timeout (seconds):", - default=str(aws_defaults.get("lambda_timeout", 120)), - ).ask() - if timeout_str is None: - raise typer.Exit(0) - lambda_timeout = int(timeout_str) + memory_str = questionary.text( + "Lambda memory (MB):", + default=str(aws_defaults.get("lambda_memory", 512)), + ).ask() + if memory_str is None: + raise typer.Exit(0) + lambda_memory = int(memory_str) - # Step 5 — Custom domain - console.print("\n[bold]Custom Domain[/bold]") + timeout_str = questionary.text( + "Lambda timeout (seconds):", + default=str(aws_defaults.get("lambda_timeout", 120)), + ).ask() + if timeout_str is None: + raise typer.Exit(0) + lambda_timeout = int(timeout_str) - use_domain = questionary.confirm( - "Do you want a custom domain?", - default=False, - ).ask() - if use_domain is None: - raise typer.Exit(0) + # Step 5 — Custom domain + console.print("\n[bold]Custom Domain[/bold]") + use_domain = questionary.confirm( + "Do you want a custom domain?", + default=False, + ).ask() + if use_domain is None: + raise typer.Exit(0) + if use_domain: + custom_domain = questionary.text( + "Domain name (e.g. data-mcp.boston.gov):" + ).ask() + if custom_domain is None: + raise typer.Exit(0) + else: + # Step 4 — GCP settings + console.print("\n[bold]GCP Settings[/bold]") + region = questionary.text( + "GCP region:", + default=gcp_defaults.get("region", "us-central1"), + ).ask() + if region is None: + raise typer.Exit(0) - custom_domain = "" - if use_domain: - custom_domain = questionary.text( - "Domain name (e.g. data-mcp.boston.gov):" + gcp_project_id = questionary.text("GCP project ID:").ask() + if gcp_project_id is None: + raise typer.Exit(0) + gcp_project_id = gcp_project_id.strip() + if not gcp_project_id: + console.print("[red]GCP project ID cannot be empty.[/red]") + raise typer.Exit(1) + + suggested_function = f"{city_slug}-opencontext-mcp-{env}" + gcp_function_name = questionary.text( + "Cloud Function name:", + default=gcp_defaults.get("function_name", suggested_function), + ).ask() + if gcp_function_name is None: + raise typer.Exit(0) + + gcp_memory_str = questionary.text( + "Function memory (MiB):", + default=str(gcp_defaults.get("function_memory_mb", 512)), + ).ask() + if gcp_memory_str is None: + raise typer.Exit(0) + gcp_memory_mb = int(gcp_memory_str) + + gcp_timeout_str = questionary.text( + "Function timeout (seconds):", + default=str(gcp_defaults.get("function_timeout_sec", 120)), + ).ask() + if gcp_timeout_str is None: + raise typer.Exit(0) + gcp_timeout_sec = int(gcp_timeout_str) + + # Autoscaling controls + gcp_min_instances_str = questionary.text( + "Min instances (autoscaling):", + default=str(gcp_defaults.get("min_instance_count", 0)), ).ask() - if custom_domain is None: + if gcp_min_instances_str is None: raise typer.Exit(0) + gcp_min_instances = int(gcp_min_instances_str) + + gcp_max_instances_str = questionary.text( + "Max instances (autoscaling):", + default=str(gcp_defaults.get("max_instance_count", 100)), + ).ask() + if gcp_max_instances_str is None: + raise typer.Exit(0) + gcp_max_instances = int(gcp_max_instances_str) + + if gcp_min_instances < 0 or gcp_max_instances < 1: + console.print( + "[red]Instance counts must be non-negative, with max >= 1.[/red]" + ) + raise typer.Exit(1) + if gcp_min_instances > gcp_max_instances: + console.print( + "[red]Min instances cannot be greater than max instances.[/red]" + ) + raise typer.Exit(1) + + gcp_artifact_bucket_name = ( + questionary.text( + "Artifact bucket name (optional, Enter for auto-generated):", + default="", + ).ask() + or "" + ).strip() # Step 6 — Write outputs console.print("\n[bold]Writing configuration files...[/bold]\n") @@ -330,32 +523,62 @@ def configure( "plugins": { plugin_key: plugin_config, }, - "aws": { - "region": region, - "lambda_name": lambda_name, - "lambda_memory": lambda_memory, - "lambda_timeout": lambda_timeout, - }, + "aws": {}, + "gcp": {}, "logging": { "level": "INFO", "format": "json", }, } + if cloud == "aws": + config_data["aws"] = { + "region": region, + "lambda_name": lambda_name, + "lambda_memory": lambda_memory, + "lambda_timeout": lambda_timeout, + } + config_data["gcp"] = defaults.get("gcp", {}) + else: + config_data["gcp"] = { + "region": region, + "function_name": gcp_function_name, + "function_memory_mb": gcp_memory_mb, + "function_timeout_sec": gcp_timeout_sec, + "min_instance_count": gcp_min_instances, + "max_instance_count": gcp_max_instances, + } + config_data["aws"] = defaults.get("aws", {}) config_path = _write_config(project_root, config_data) - tfvars_path = _write_tfvars(terraform_dir, env, lambda_name, region, custom_domain) + if cloud == "aws": + tfvars_path = _write_tfvars( + terraform_dir, env, lambda_name, region, custom_domain + ) + else: + tfvars_path = _write_tfvars_gcp( + terraform_dir=terraform_dir, + env=env, + project_id=gcp_project_id, + region=region, + function_name=gcp_function_name, + function_memory_mb=gcp_memory_mb, + function_timeout_sec=gcp_timeout_sec, + min_instance_count=gcp_min_instances, + max_instance_count=gcp_max_instances, + artifact_bucket_name=gcp_artifact_bucket_name, + ) # Terraform workspace ws_name = f"{city_slug}-{env}" - _ensure_state_bucket(state_bucket, region) - - # Override the backend config at init time so Terraform uses the correct - # bucket and region instead of the defaults hard-coded in main.tf. - if not (terraform_dir / ".terraform").exists(): + if cloud == "aws": + _ensure_state_bucket(state_bucket, region) + # Always reconfigure backend so bucket/region overrides are applied + # even when .terraform already exists. init_cmd = [ "terraform", "init", + "-reconfigure", f"-backend-config=bucket={state_bucket}", f"-backend-config=region={region}", ] @@ -364,6 +587,21 @@ def configure( cwd=terraform_dir, spinner_msg="Initializing Terraform", ) + else: + _ensure_gcp_state_bucket(state_bucket, region, gcp_project_id) + # Always reconfigure backend so bucket overrides are applied even when + # .terraform already exists from previous runs. + init_cmd = [ + "terraform", + "init", + "-reconfigure", + f"-backend-config=bucket={state_bucket}", + ] + run_cmd( + init_cmd, + cwd=terraform_dir, + spinner_msg="Initializing Terraform", + ) result = subprocess.run( ["terraform", "workspace", "list"], @@ -398,24 +636,43 @@ def configure( summary.add_row("City", city_name) summary.add_row("Environment", env) summary.add_row("Plugin", plugin) - summary.add_row("Lambda name", lambda_name) - summary.add_row("AWS region", region) - summary.add_row("Lambda memory", f"{lambda_memory} MB") - summary.add_row("Lambda timeout", f"{lambda_timeout}s") - summary.add_row("X-Ray Tracing", "Enabled") - summary.add_row("Dead Letter Queue", "Enabled (failures → SQS)") + summary.add_row("Cloud provider", cloud) + if cloud == "aws": + summary.add_row("Lambda name", lambda_name) + summary.add_row("AWS region", region) + summary.add_row("Lambda memory", f"{lambda_memory} MB") + summary.add_row("Lambda timeout", f"{lambda_timeout}s") + summary.add_row("X-Ray Tracing", "Enabled") + summary.add_row("Dead Letter Queue", "Enabled (failures → SQS)") + else: + summary.add_row("GCP project", gcp_project_id) + summary.add_row("GCP region", region) + summary.add_row("Function name", gcp_function_name) + summary.add_row("Function memory", f"{gcp_memory_mb} MiB") + summary.add_row("Function timeout", f"{gcp_timeout_sec}s") + summary.add_row("Min instances", str(gcp_min_instances)) + summary.add_row("Max instances", str(gcp_max_instances)) + summary.add_row( + "Artifact bucket", + gcp_artifact_bucket_name + if gcp_artifact_bucket_name + else "[dim]Auto-generated by Terraform[/dim]", + ) summary.add_row("Workspace", ws_name) - summary.add_row( - "Custom domain", - ( - custom_domain - if custom_domain - else "[dim]None (no domain resources will be created)[/dim]" - ), - ) + if cloud == "aws": + summary.add_row( + "Custom domain", + ( + custom_domain + if custom_domain + else "[dim]None (no domain resources will be created)[/dim]" + ), + ) console.print() console.print(summary) console.print() console.print("[green bold]Configuration complete![/green bold]") - console.print("Next step: [bold]opencontext deploy --env " + env + "[/bold]") + command_prefix = "uv run " if os.environ.get("UV") else "" + next_cmd = f"{command_prefix}opencontext deploy --cloud {cloud} --env {env}" + console.print(f"Next step: [bold]{next_cmd}[/bold]") diff --git a/cli/commands/deploy.py b/cli/commands/deploy.py index 9eb238f..8763021 100644 --- a/cli/commands/deploy.py +++ b/cli/commands/deploy.py @@ -22,6 +22,7 @@ get_terraform_dir, load_config, load_tfvars, + normalize_cloud, require_tty, run_cmd, run_cmd_stream, @@ -110,6 +111,68 @@ def _package_lambda(project_root: Path) -> Path: return zip_path +def _package_cloud_function(project_root: Path, terraform_dir: Path) -> Path: + """Build .deploy/ directory and create gcf-deployment.zip.""" + deploy_dir = project_root / ".deploy" + + if deploy_dir.exists(): + shutil.rmtree(deploy_dir) + deploy_dir.mkdir() + + req_file = project_root / "requirements.txt" + if req_file.exists(): + run_cmd( + [ + "uv", + "pip", + "install", + "-r", + str(req_file), + "--target", + str(deploy_dir), + "--python-platform", + "x86_64-manylinux2014", + "--python-version", + "3.11", + "--no-compile", + ], + cwd=project_root, + spinner_msg="Installing dependencies into .deploy/", + ) + + for src_dir in ["core", "plugins", "server"]: + src = project_root / src_dir + dst = deploy_dir / src_dir + if src.exists(): + shutil.copytree(src, dst, dirs_exist_ok=True) + + custom_plugins = project_root / "custom_plugins" + custom_dst = deploy_dir / "custom_plugins" + if custom_plugins.exists(): + shutil.copytree(custom_plugins, custom_dst, dirs_exist_ok=True) + else: + custom_dst.mkdir(exist_ok=True) + + main_py = project_root / "main.py" + if not main_py.exists(): + console.print( + "[red]main.py not found at project root.[/red]\n" + "Cloud Functions deployment requires this entrypoint file." + ) + raise typer.Exit(1) + shutil.copy2(main_py, deploy_dir / "main.py") + + zip_path = terraform_dir / "gcf-deployment.zip" + with ZipFile(zip_path, "w") as zf: + for root, _dirs, files in os.walk(deploy_dir): + for file in files: + file_path = Path(root) / file + arcname = file_path.relative_to(deploy_dir) + zf.write(file_path, arcname) + + return zip_path + + def _parse_plan_summary(output: str) -> tuple[int, int, int]: """Extract add/change/destroy counts from terraform plan output.""" match = re.search(r"(\d+) to add, (\d+) to change, (\d+) to destroy", output) @@ -121,13 +184,17 @@ def _parse_plan_summary(output: str) -> tuple[int, int, int]: @friendly_exit def deploy( env: str = typer.Option("staging", help="Environment: staging or prod"), + cloud: str = typer.Option("aws", "--cloud", help="Cloud provider: aws or gcp"), ) -> None: - """Package and deploy the MCP server to AWS Lambda.""" + """Package and deploy the MCP server with Terraform.""" require_tty() + if not isinstance(cloud, str): + cloud = "aws" + cloud = normalize_cloud(cloud) # Validate configuration before doing any work console.print("\n[bold]Validating configuration before deploy...[/bold]") - if not _run_validate_checks(env, include_artifact_checks=False): + if not _run_validate_checks(env, include_artifact_checks=False, cloud=cloud): console.print( "\n[red bold]Validation failed.[/red bold] " "Fix the issues above before redeploying." @@ -135,27 +202,37 @@ def deploy( raise typer.Exit(1) project_root = get_project_root() - terraform_dir = get_terraform_dir() + terraform_dir = get_terraform_dir(cloud) ensure_config_exists() - ensure_terraform_init() + ensure_terraform_init(cloud) config = load_config() plugin_name = _validate_single_plugin(config) - console.print(f"[green]Plugin:[/green] {plugin_name}") + console.print( + f"[green]Plugin:[/green] {plugin_name} [green]Cloud:[/green] {cloud}" + ) - # Package - console.print("\n[bold]Packaging Lambda deployment...[/bold]") - zip_path = _package_lambda(project_root) + # Package artifact + if cloud == "aws": + console.print("\n[bold]Packaging Lambda deployment...[/bold]") + zip_path = _package_lambda(project_root) + else: + console.print("\n[bold]Packaging Cloud Function deployment...[/bold]") + zip_path = _package_cloud_function(project_root, terraform_dir) console.print(f"[green]Created:[/green] {zip_path.name}") # Copy artifacts to terraform directory - shutil.copy2(zip_path, terraform_dir / "lambda-deployment.zip") - shutil.copy2(project_root / "config.yaml", terraform_dir / "config.yaml") + terraform_zip_name = ( + "lambda-deployment.zip" if cloud == "aws" else "gcf-deployment.zip" + ) + if cloud == "aws": + shutil.copy2(zip_path, terraform_dir / terraform_zip_name) + shutil.copy2(project_root / "config.yaml", terraform_dir / "config.yaml") # Re-run full validation once deployment artifact exists. console.print("\n[bold]Running full validation with deployment artifact...[/bold]") - if not _run_validate_checks(env, include_artifact_checks=True): + if not _run_validate_checks(env, include_artifact_checks=True, cloud=cloud): console.print( "\n[red bold]Validation failed after packaging.[/red bold] " "Fix the issues above before redeploying." @@ -163,14 +240,14 @@ def deploy( raise typer.Exit(1) # Select workspace - select_workspace(env, terraform_dir) + select_workspace(env, terraform_dir, cloud=cloud) # Terraform plan tfvars_file = terraform_dir / f"{env}.tfvars" if not tfvars_file.exists(): console.print( f"[red]{env}.tfvars not found.[/red]\n" - "Run [bold]opencontext configure[/bold] to generate it." + f"Create terraform/{cloud}/{env}.tfvars then try again." ) raise typer.Exit(1) @@ -246,56 +323,75 @@ def deploy( output_table.add_column("Value") api_gw = outputs.get("api_gateway_url") + mcp_endpoint = outputs.get("mcp_endpoint_url") log_group = outputs.get("cloudwatch_log_group") - if api_gw: - output_table.add_row("API Gateway URL", str(api_gw)) - if log_group: - output_table.add_row("CloudWatch Log Group", str(log_group)) - - # Custom domain outputs (populated when custom_domain != "") - tfvars = load_tfvars(env) - custom_domain = tfvars.get("custom_domain", "") - if custom_domain: - regional = outputs.get("custom_domain_target") - outputs.get("acm_certificate_arn") - val_name = outputs.get("acm_validation_cname_name") - val_value = outputs.get("acm_validation_cname_value") - - output_table.add_row("Custom Domain", custom_domain) - if regional: - output_table.add_row("Regional Domain (CNAME target)", str(regional)) - if not str(regional).startswith("d-"): - console.print( - "[yellow]Warning: regionalDomainName doesn't start with 'd-' — " - "verify this is the correct value.[/yellow]" - ) - if val_name: - output_table.add_row("ACM Validation CNAME Name", str(val_name)) - if val_value: - output_table.add_row("ACM Validation CNAME Value", str(val_value)) + if cloud == "aws": + if api_gw: + output_table.add_row("API Gateway URL", str(api_gw)) + if log_group: + output_table.add_row("CloudWatch Log Group", str(log_group)) + + # Custom domain outputs (populated when custom_domain != "") + tfvars = load_tfvars(env, cloud=cloud) + custom_domain = tfvars.get("custom_domain", "") + if custom_domain: + regional = outputs.get("custom_domain_target") + outputs.get("acm_certificate_arn") + val_name = outputs.get("acm_validation_cname_name") + val_value = outputs.get("acm_validation_cname_value") + + output_table.add_row("Custom Domain", custom_domain) + if regional: + output_table.add_row("Regional Domain (CNAME target)", str(regional)) + if not str(regional).startswith("d-"): + console.print( + "[yellow]Warning: regionalDomainName doesn't start with 'd-' — " + "verify this is the correct value.[/yellow]" + ) + if val_name: + output_table.add_row("ACM Validation CNAME Name", str(val_name)) + if val_value: + output_table.add_row("ACM Validation CNAME Value", str(val_value)) + else: + if mcp_endpoint: + output_table.add_row("MCP Endpoint URL", str(mcp_endpoint)) + if outputs.get("function_uri"): + output_table.add_row("Function URL", str(outputs.get("function_uri"))) + if outputs.get("source_bucket"): + output_table.add_row("Artifact Bucket", str(outputs.get("source_bucket"))) console.print() console.print(output_table) - # Print cert status if custom domain is configured - if custom_domain: - _print_cert_status(custom_domain, env) + # Print cert status if AWS custom domain is configured + if cloud == "aws": + tfvars = load_tfvars(env, cloud=cloud) + custom_domain = tfvars.get("custom_domain", "") + if custom_domain: + _print_cert_status(custom_domain, env) console.print("\n[green bold]Deployment complete![/green bold]") - if api_gw: + if cloud == "aws" and api_gw: console.print( "\nConnect via Claude Connectors:\n" " 1. Go to Settings → Connectors\n" " 2. Click 'Add custom connector'\n" f" 3. Enter URL: {api_gw}" ) - - console.print( - "\n[dim]To enable cost filtering in AWS Cost Explorer, activate the Project, " - "Environment, and ManagedBy tags at: " - "AWS Console \u2192 Billing \u2192 Cost allocation tags[/dim]" - ) + if cloud == "gcp" and mcp_endpoint: + console.print( + "\nConnect via Claude Connectors:\n" + " 1. Go to Settings → Connectors\n" + " 2. Click 'Add custom connector'\n" + f" 3. Enter URL: {mcp_endpoint}" + ) + if cloud == "aws": + console.print( + "\n[dim]To enable cost filtering in AWS Cost Explorer, activate the Project, " + "Environment, and ManagedBy tags at: " + "AWS Console \u2192 Billing \u2192 Cost allocation tags[/dim]" + ) def _print_cert_status(domain: str, env: str) -> None: diff --git a/cli/commands/destroy.py b/cli/commands/destroy.py index 8c474ee..adc68e3 100644 --- a/cli/commands/destroy.py +++ b/cli/commands/destroy.py @@ -9,6 +9,7 @@ ensure_terraform_init, friendly_exit, get_terraform_dir, + normalize_cloud, require_tty, run_cmd_stream, select_workspace, @@ -19,33 +20,37 @@ @friendly_exit def destroy( env: str = typer.Option("staging", help="Environment: staging or prod"), + cloud: str = typer.Option("aws", "--cloud", help="Cloud provider: aws or gcp"), ) -> None: """Destroy all deployed resources for an environment.""" require_tty() + if not isinstance(cloud, str): + cloud = "aws" + cloud = normalize_cloud(cloud) ensure_config_exists() - ensure_terraform_init() + ensure_terraform_init(cloud) - terraform_dir = get_terraform_dir() + terraform_dir = get_terraform_dir(cloud) ws = workspace_name(env) tfvars_file = terraform_dir / f"{env}.tfvars" if not tfvars_file.exists(): console.print( - f"[red]{env}.tfvars not found in terraform/aws/.[/red]\n" + f"[red]{env}.tfvars not found in terraform/{cloud}/.[/red]\n" "Nothing to destroy — this environment has not been configured." ) raise typer.Exit(1) - select_workspace(env, terraform_dir) + select_workspace(env, terraform_dir, cloud=cloud) - console.print(f"\n[red bold]WARNING: This will destroy ALL resources in workspace '{ws}'.[/red bold]") + console.print( + f"\n[red bold]WARNING: This will destroy ALL resources in workspace '{ws}'.[/red bold]" + ) console.print(f"Environment: [bold]{env}[/bold]") console.print(f"Var file: [bold]{env}.tfvars[/bold]\n") - confirmation = questionary.text( - f'Type "{env}" to confirm destruction:' - ).ask() + confirmation = questionary.text(f'Type "{env}" to confirm destruction:').ask() if confirmation is None or confirmation != env: console.print("[yellow]Destruction cancelled.[/yellow]") @@ -54,7 +59,8 @@ def destroy( console.print("\n[bold]Destroying resources...[/bold]\n") exit_code = run_cmd_stream( [ - "terraform", "destroy", + "terraform", + "destroy", f"-var-file={env}.tfvars", "-input=false", "-auto-approve", diff --git a/cli/commands/logs.py b/cli/commands/logs.py index d989a45..d08b17c 100644 --- a/cli/commands/logs.py +++ b/cli/commands/logs.py @@ -16,6 +16,7 @@ friendly_exit, get_terraform_dir, load_tfvars, + normalize_cloud, select_workspace, ) @@ -108,7 +109,9 @@ def _print_verbose(invocations: list[Invocation], log_group: str) -> None: for inv in invocations: status_markup = "[red]ERROR[/red]" if inv.has_error else "[green]OK[/green]" - duration_str = f"{inv.duration_ms:.1f} ms" if inv.duration_ms is not None else "—" + duration_str = ( + f"{inv.duration_ms:.1f} ms" if inv.duration_ms is not None else "—" + ) ts = inv.timestamp[:19].replace("T", " ") if inv.timestamp else "—" header = Text.assemble( @@ -157,16 +160,64 @@ def run_cmd_stream(cmd: list[str]) -> int: @friendly_exit def logs( env: str = typer.Option("staging", help="Environment: staging or prod"), + cloud: str = typer.Option("aws", "--cloud", help="Cloud provider: aws or gcp"), follow: bool = typer.Option(False, "--follow", "-f", help="Follow log output"), - verbose: bool = typer.Option(False, "--verbose", "-v", help="Show formatted log entries"), - since: str = typer.Option("1h", "--since", help="How far back to fetch logs (e.g. 1h, 30m, 24h)"), + verbose: bool = typer.Option( + False, "--verbose", "-v", help="Show formatted log entries" + ), + since: str = typer.Option( + "1h", "--since", help="How far back to fetch logs (e.g. 1h, 30m, 24h)" + ), ) -> None: - """Show CloudWatch logs for the deployed Lambda.""" + """Show runtime logs for the deployed function.""" + if not isinstance(cloud, str): + cloud = "aws" + cloud = normalize_cloud(cloud) ensure_config_exists() - ensure_terraform_init() + ensure_terraform_init(cloud) + + terraform_dir = get_terraform_dir(cloud) + select_workspace(env, terraform_dir, cloud=cloud) + + if cloud == "gcp": + tfvars = load_tfvars(env, cloud=cloud) + function_name = tfvars.get("function_name", "") + if not function_name: + out = subprocess.run( + ["terraform", "output", "-raw", "function_name"], + cwd=terraform_dir, + capture_output=True, + text=True, + timeout=15, + ) + if out.returncode == 0 and out.stdout.strip(): + function_name = out.stdout.strip() + if not function_name: + console.print( + "[red]Could not determine function name.[/red]\n" + "Ensure the Cloud Function has been deployed with [bold]opencontext deploy --cloud gcp[/bold]." + ) + raise typer.Exit(1) - terraform_dir = get_terraform_dir() - select_workspace(env, terraform_dir) + cmd = [ + "gcloud", + "functions", + "logs", + "read", + function_name, + "--region", + tfvars.get("gcp_region", "us-central1"), + "--gen2", + "--limit", + "100", + ] + if follow: + cmd.append("--follow") + exit_code = run_cmd_stream(cmd) + if exit_code != 0: + console.print("[red]Failed to fetch Cloud Function logs.[/red]") + raise typer.Exit(1) + return log_group = "" result = subprocess.run( @@ -180,7 +231,7 @@ def logs( log_group = result.stdout.strip() if not log_group: - tfvars = load_tfvars(env) + tfvars = load_tfvars(env, cloud=cloud) lambda_name = tfvars.get("lambda_name", "") if not lambda_name: console.print( @@ -199,7 +250,10 @@ def logs( # Capture output for structured display with console.status("Fetching logs…"): result = subprocess.run( - cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, timeout=30, ) if result.returncode != 0: diff --git a/cli/commands/status.py b/cli/commands/status.py index 2e87e1c..c1851fb 100644 --- a/cli/commands/status.py +++ b/cli/commands/status.py @@ -13,6 +13,7 @@ friendly_exit, get_terraform_dir, load_tfvars, + normalize_cloud, select_workspace, workspace_name, ) @@ -37,17 +38,22 @@ def _get_cert_status(domain: str) -> str: @friendly_exit def status( env: str = typer.Option("staging", help="Environment: staging or prod"), + cloud: str = typer.Option("aws", "--cloud", help="Cloud provider: aws or gcp"), ) -> None: """Show deployment status for the given environment.""" + if not isinstance(cloud, str): + cloud = "aws" + cloud = normalize_cloud(cloud) ensure_config_exists() - ensure_terraform_init() + ensure_terraform_init(cloud) - terraform_dir = get_terraform_dir() - tfvars = load_tfvars(env) + terraform_dir = get_terraform_dir(cloud) + tfvars = load_tfvars(env, cloud=cloud) lambda_name = tfvars.get("lambda_name", "") + function_name = tfvars.get("function_name", "") custom_domain = tfvars.get("custom_domain", "") - select_workspace(env, terraform_dir) + select_workspace(env, terraform_dir, cloud=cloud) # Gather terraform outputs tf_outputs: dict[str, str | None] = {} @@ -64,15 +70,19 @@ def status( for key, val in raw.items(): tf_outputs[key] = val.get("value") - # Gather Lambda info + # Gather Lambda / Function info lambda_info: dict[str, str] = {} - if lambda_name: + if cloud == "aws" and lambda_name: with console.status("Fetching Lambda function info..."): result = subprocess.run( [ - "aws", "lambda", "get-function", - "--function-name", lambda_name, - "--output", "json", + "aws", + "lambda", + "get-function", + "--function-name", + lambda_name, + "--output", + "json", ], capture_output=True, text=True, @@ -86,7 +96,7 @@ def status( # Gather cert status via AWS CLI if custom domain is set cert_status = "" - if custom_domain: + if cloud == "aws" and custom_domain: with console.status("Checking certificate status..."): cert_status = _get_cert_status(custom_domain) @@ -98,30 +108,46 @@ def status( ws = workspace_name(env) table.add_row("Environment", env) table.add_row("Workspace", ws) - table.add_row("Lambda name", lambda_name or "N/A") + table.add_row("Cloud", cloud) + if cloud == "aws": + table.add_row("Lambda name", lambda_name or "N/A") + else: + table.add_row( + "Function name", + function_name or str(tf_outputs.get("function_name") or "N/A"), + ) if lambda_info: table.add_row("Last modified", lambda_info.get("last_modified", "N/A")) table.add_row("Runtime", lambda_info.get("runtime", "N/A")) - table.add_row( - "API Gateway URL", - str(tf_outputs.get("api_gateway_url") or "N/A"), - ) + if cloud == "aws": + table.add_row( + "API Gateway URL", + str(tf_outputs.get("api_gateway_url") or "N/A"), + ) + else: + table.add_row("Function URL", str(tf_outputs.get("function_uri") or "N/A")) + table.add_row( + "MCP Endpoint URL", str(tf_outputs.get("mcp_endpoint_url") or "N/A") + ) - if custom_domain: + if cloud == "aws" and custom_domain: table.add_row("Custom domain", custom_domain) table.add_row("Certificate status", cert_status or "Not found") regional = tf_outputs.get("custom_domain_target") if regional: table.add_row("Regional domain", str(regional)) - else: + elif cloud == "aws": table.add_row("Custom domain", "Not configured") - table.add_row( - "CloudWatch log group", - str(tf_outputs.get("cloudwatch_log_group") or f"/aws/lambda/{lambda_name}"), - ) + if cloud == "aws": + table.add_row( + "CloudWatch log group", + str(tf_outputs.get("cloudwatch_log_group") or f"/aws/lambda/{lambda_name}"), + ) + else: + table.add_row("Artifact bucket", str(tf_outputs.get("source_bucket") or "N/A")) console.print() console.print(table) diff --git a/cli/commands/upgrade.py b/cli/commands/upgrade.py index 5d23071..86fe29d 100644 --- a/cli/commands/upgrade.py +++ b/cli/commands/upgrade.py @@ -16,11 +16,15 @@ "config.yaml", "terraform/aws/staging.tfvars", "terraform/aws/prod.tfvars", + "terraform/gcp/staging.tfvars", + "terraform/gcp/prod.tfvars", } PROTECTED_PREFIXES = ("examples/",) -def _run_git(args: list[str], cwd=None, check: bool = False, timeout: int = 30) -> subprocess.CompletedProcess: +def _run_git( + args: list[str], cwd=None, check: bool = False, timeout: int = 30 +) -> subprocess.CompletedProcess: return subprocess.run( ["git", *args], cwd=cwd, @@ -68,7 +72,9 @@ def upgrade( result = _run_git(["remote", "add", "upstream", upstream_url], cwd=project_root) if result.returncode != 0: - console.print(f"[red]Failed to add upstream remote:[/red] {result.stderr.strip()}") + console.print( + f"[red]Failed to add upstream remote:[/red] {result.stderr.strip()}" + ) raise typer.Exit(1) console.print(f"[green]Added upstream remote:[/green] {upstream_url}") else: @@ -88,7 +94,9 @@ def upgrade( cwd=project_root, ) if result.returncode != 0: - console.print(f"[red]Could not compare with upstream/main:[/red] {result.stderr.strip()}") + console.print( + f"[red]Could not compare with upstream/main:[/red] {result.stderr.strip()}" + ) raise typer.Exit(1) new_commits = [line for line in result.stdout.strip().splitlines() if line.strip()] @@ -108,15 +116,21 @@ def upgrade( changed_files = [f for f in result.stdout.strip().splitlines() if f.strip()] if changed_files: - console.print(f"\n[bold]Files that will be affected ({len(changed_files)}):[/bold]") + console.print( + f"\n[bold]Files that will be affected ({len(changed_files)}):[/bold]" + ) for f in changed_files: console.print(f" {f}") # 5. Warn about protected files protected_changes = [f for f in changed_files if _is_protected(f)] if protected_changes: - console.print("\n[yellow bold]⚠️ The following city-specific files have upstream changes[/yellow bold]") - console.print("[yellow]These will NOT be overwritten — your local versions will be kept:[/yellow]") + console.print( + "\n[yellow bold]⚠️ The following city-specific files have upstream changes[/yellow bold]" + ) + console.print( + "[yellow]These will NOT be overwritten — your local versions will be kept:[/yellow]" + ) for f in protected_changes: console.print(f" [yellow]{f}[/yellow]") @@ -138,7 +152,6 @@ def upgrade( timeout=120, ) - # 8. Handle conflicts conflict_result = _run_git( ["diff", "--name-only", "--diff-filter=U"], @@ -149,7 +162,9 @@ def upgrade( ] if conflicted_files: - console.print(f"\n[yellow bold]Merge conflicts in {len(conflicted_files)} file(s):[/yellow bold]") + console.print( + f"\n[yellow bold]Merge conflicts in {len(conflicted_files)} file(s):[/yellow bold]" + ) auto_resolved = [] needs_manual = [] @@ -171,18 +186,26 @@ def upgrade( if needs_manual: console.print("\n[yellow]Manual steps to complete the merge:[/yellow]") - console.print(" 1. Edit each conflicted file and resolve the <<<<<<< markers") + console.print( + " 1. Edit each conflicted file and resolve the <<<<<<< markers" + ) for f in needs_manual: console.print(f" git add {f}") console.print(" 2. Once all conflicts are resolved:") console.print(" git commit") - console.print("\nThe merge is currently staged (--no-commit). " - "Run [bold]git merge --abort[/bold] to cancel entirely.") + console.print( + "\nThe merge is currently staged (--no-commit). " + "Run [bold]git merge --abort[/bold] to cancel entirely." + ) raise typer.Exit(1) # 9. Print summary console.print("\n[green bold]Merge complete![/green bold]") console.print(f" {len(new_commits)} commit(s) merged from upstream/main") if protected_changes: - console.print(f" {len(protected_changes)} city-specific file(s) kept unchanged") - console.print("\nRun [bold]git commit[/bold] to finalize the merge, or [bold]git merge --abort[/bold] to cancel.") + console.print( + f" {len(protected_changes)} city-specific file(s) kept unchanged" + ) + console.print( + "\nRun [bold]git commit[/bold] to finalize the merge, or [bold]git merge --abort[/bold] to cancel." + ) diff --git a/cli/commands/validate.py b/cli/commands/validate.py index a14ee37..b826f22 100644 --- a/cli/commands/validate.py +++ b/cli/commands/validate.py @@ -9,7 +9,7 @@ import yaml from rich.table import Table -from cli.utils import console, get_project_root, get_terraform_dir +from cli.utils import console, get_project_root, get_terraform_dir, normalize_cloud app = typer.Typer() @@ -37,10 +37,17 @@ def _parse_tfvars_file(path: Path) -> dict[str, str]: return result -def run_checks(env: str, include_artifact_checks: bool = True) -> bool: +def run_checks( + env: str, + include_artifact_checks: bool = True, + cloud: str = "aws", +) -> bool: """Run all validation checks. Returns True if all pass.""" + if not isinstance(cloud, str): + cloud = "aws" + cloud = normalize_cloud(cloud) project_root = get_project_root() - terraform_dir = get_terraform_dir() + terraform_dir = get_terraform_dir(cloud) # (check_name, passed, detail) checks: list[tuple[str, bool, str]] = [] @@ -106,12 +113,12 @@ def run_checks(env: str, include_artifact_checks: bool = True) -> bool: ("Plugin config valid", False, "Skipped — fix plugin selection first") ) - # 4. terraform/aws/{env}.tfvars exists + # 4. terraform//{env}.tfvars exists tfvars_path = terraform_dir / f"{env}.tfvars" tfvars_exists = tfvars_path.exists() checks.append( ( - f"terraform/aws/{env}.tfvars exists", + f"terraform/{cloud}/{env}.tfvars exists", tfvars_exists, "Found" if tfvars_exists else "Run: opencontext configure", ) @@ -218,32 +225,63 @@ def run_checks(env: str, include_artifact_checks: bool = True) -> bool: ) ) - # 8. AWS credentials valid - try: - result = subprocess.run( - ["aws", "sts", "get-caller-identity", "--output", "json"], - capture_output=True, - text=True, - timeout=15, - ) - if result.returncode == 0: - identity = json.loads(result.stdout) - checks.append( - ( - "AWS credentials valid", - True, - f"Account: {identity.get('Account', 'unknown')}", - ) + # 8. Cloud provider credentials valid + if cloud == "aws": + try: + result = subprocess.run( + ["aws", "sts", "get-caller-identity", "--output", "json"], + capture_output=True, + text=True, + timeout=15, ) - else: + if result.returncode == 0: + identity = json.loads(result.stdout) + checks.append( + ( + "AWS credentials valid", + True, + f"Account: {identity.get('Account', 'unknown')}", + ) + ) + else: + checks.append(("AWS credentials valid", False, "Run: aws configure")) + except FileNotFoundError: + checks.append(("AWS credentials valid", False, "AWS CLI not found")) + except (subprocess.TimeoutExpired, json.JSONDecodeError): checks.append(("AWS credentials valid", False, "Run: aws configure")) - except FileNotFoundError: - checks.append(("AWS credentials valid", False, "AWS CLI not found")) - except (subprocess.TimeoutExpired, json.JSONDecodeError): - checks.append(("AWS credentials valid", False, "Run: aws configure")) + else: + try: + result = subprocess.run( + ["gcloud", "auth", "application-default", "print-access-token"], + capture_output=True, + text=True, + timeout=15, + ) + if result.returncode == 0 and result.stdout.strip(): + checks.append( + ( + "GCP credentials valid", + True, + "Application Default Credentials available", + ) + ) + else: + checks.append( + ( + "GCP credentials valid", + False, + "Run: gcloud auth application-default login", + ) + ) + except FileNotFoundError: + checks.append(("GCP credentials valid", False, "gcloud CLI not found")) + except subprocess.TimeoutExpired: + checks.append( + ("GCP credentials valid", False, "Timeout checking ADC credentials") + ) - # 9. ACM cert exists for custom domain (only if custom_domain is set) - if custom_domain: + # 9. Provider-specific checks + if cloud == "aws" and custom_domain: try: result = subprocess.run( ["aws", "acm", "list-certificates", "--output", "json"], @@ -293,6 +331,19 @@ def run_checks(env: str, include_artifact_checks: bool = True) -> bool: f"Error: {str(e)[:60]}", ) ) + if cloud == "gcp": + if tfvars.get("project_id"): + checks.append( + ("GCP project_id set", True, f"project_id={tfvars.get('project_id')}") + ) + else: + checks.append( + ( + "GCP project_id set", + False, + f"Missing in terraform/{cloud}/{env}.tfvars", + ) + ) # Print results table table = Table(title=f"OpenContext Validation — {env}", show_lines=True) @@ -324,10 +375,13 @@ def run_checks(env: str, include_artifact_checks: bool = True) -> bool: def validate( ctx: typer.Context, env: str = typer.Option("staging", help="Environment: staging or prod"), + cloud: str = typer.Option("aws", "--cloud", help="Cloud provider: aws or gcp"), ) -> None: """Run pre-deployment validation checks.""" if ctx.invoked_subcommand is not None: return - passed = run_checks(env) + if not isinstance(cloud, str): + cloud = "aws" + passed = run_checks(env, cloud=cloud) if not passed: raise typer.Exit(1) diff --git a/cli/utils.py b/cli/utils.py index fe9dfae..d188ee0 100644 --- a/cli/utils.py +++ b/cli/utils.py @@ -4,6 +4,7 @@ import subprocess import sys from pathlib import Path +from typing import Literal import click import typer @@ -11,6 +12,7 @@ from rich.console import Console console = Console() +CloudProvider = Literal["aws", "gcp"] def get_project_root() -> Path: @@ -24,8 +26,17 @@ def get_project_root() -> Path: raise typer.Exit(1) -def get_terraform_dir() -> Path: - return get_project_root() / "terraform" / "aws" +def normalize_cloud(cloud: str) -> CloudProvider: + value = cloud.lower().strip() + if value not in {"aws", "gcp"}: + console.print("[red]Unsupported cloud provider.[/red] Use 'aws' or 'gcp'.") + raise typer.Exit(1) + return value # type: ignore[return-value] + + +def get_terraform_dir(cloud: str = "aws") -> Path: + provider = normalize_cloud(cloud) + return get_project_root() / "terraform" / provider def load_config() -> dict: @@ -41,12 +52,13 @@ def load_config() -> dict: return yaml.safe_load(f) -def load_tfvars(env: str) -> dict[str, str]: - """Parse terraform/aws/{env}.tfvars into a dict of key=value pairs.""" - tfvars_path = get_terraform_dir() / f"{env}.tfvars" +def load_tfvars(env: str, cloud: str = "aws") -> dict[str, str]: + """Parse terraform//{env}.tfvars into a dict of key=value pairs.""" + provider = normalize_cloud(cloud) + tfvars_path = get_terraform_dir(provider) / f"{env}.tfvars" if not tfvars_path.exists(): console.print( - f"[red]{env}.tfvars not found in terraform/aws/.[/red]\n" + f"[red]{env}.tfvars not found in terraform/{provider}/.[/red]\n" "Run [bold]opencontext configure[/bold] to generate it." ) raise typer.Exit(1) @@ -76,8 +88,9 @@ def ensure_config_exists() -> None: raise typer.Exit(1) -def ensure_terraform_init() -> None: - tf_dir = get_terraform_dir() +def ensure_terraform_init(cloud: str = "aws") -> None: + provider = normalize_cloud(cloud) + tf_dir = get_terraform_dir(provider) if not (tf_dir / ".terraform").exists(): console.print( "[red]Terraform has not been initialized.[/red]\n" @@ -114,9 +127,13 @@ def workspace_name(env: str) -> str: return f"{city}-{env}" -def select_workspace(env: str, terraform_dir: Path | None = None) -> None: +def select_workspace( + env: str, + terraform_dir: Path | None = None, + cloud: str = "aws", +) -> None: """Select (or create) the Terraform workspace for the given environment.""" - tf_dir = terraform_dir or get_terraform_dir() + tf_dir = terraform_dir or get_terraform_dir(cloud) ws = workspace_name(env) result = subprocess.run( @@ -215,6 +232,7 @@ def run_cmd_stream_capture( def friendly_exit(func): """Decorator that catches exceptions and prints a user-friendly error.""" + def wrapper(*args, **kwargs): try: return func(*args, **kwargs) @@ -223,6 +241,7 @@ def wrapper(*args, **kwargs): except Exception as exc: console.print(f"\n[red bold]Error:[/red bold] {exc}") raise typer.Exit(1) from None + wrapper.__name__ = func.__name__ wrapper.__doc__ = func.__doc__ wrapper.__module__ = func.__module__ diff --git a/config-example.yaml b/config-example.yaml index 55cbe68..480db57 100644 --- a/config-example.yaml +++ b/config-example.yaml @@ -78,6 +78,15 @@ aws: lambda_memory: 512 # Lambda memory in MB (128-10240) lambda_timeout: 120 # Lambda timeout in seconds (max 900) +# GCP deployment settings (terraform/gcp — Cloud Functions gen2) +gcp: + region: "us-central1" # GCP region for Cloud Functions + function_name: "your-opendata-mcp" # Optional; defaults from server_name slug + function_memory_mb: 512 # MiB for the function + function_timeout_sec: 120 # Seconds (max 3600 for gen2) + min_instance_count: 0 # Minimum warm instances + max_instance_count: 100 # Maximum autoscaled instances + # Logging configuration logging: level: "INFO" # Log level: DEBUG, INFO, WARNING, ERROR diff --git a/core/plugin_manager.py b/core/plugin_manager.py index f126d86..0626fa2 100644 --- a/core/plugin_manager.py +++ b/core/plugin_manager.py @@ -89,11 +89,13 @@ def _load_plugin_class(self, plugin_name: str, plugin_path: Path) -> type: ImportError: If plugin cannot be imported ValueError: If plugin class not found or invalid """ - # Determine module path - if "plugins" in str(plugin_path): - module_path = f"plugins.{plugin_name}.plugin" - elif "custom_plugins" in str(plugin_path): + # Determine module path — check custom_plugins before matching "plugins", + # since paths like ".../custom_plugins/foo" contain the substring "plugins". + resolved_parts = plugin_path.resolve().parts + if "custom_plugins" in resolved_parts: module_path = f"custom_plugins.{plugin_name}.plugin" + elif "plugins" in resolved_parts: + module_path = f"plugins.{plugin_name}.plugin" else: raise ValueError(f"Invalid plugin path: {plugin_path}") diff --git a/custom_plugins/.gitkeep b/custom_plugins/.gitkeep deleted file mode 100644 index a3d18ed..0000000 --- a/custom_plugins/.gitkeep +++ /dev/null @@ -1,2 +0,0 @@ -# This file ensures the custom_plugins directory is tracked by git -# Governments can add their custom plugins here diff --git a/custom_plugins/integration_test_fake/__init__.py b/custom_plugins/integration_test_fake/__init__.py new file mode 100644 index 0000000..931b50f --- /dev/null +++ b/custom_plugins/integration_test_fake/__init__.py @@ -0,0 +1 @@ +"""Integration test fake plugin package.""" diff --git a/custom_plugins/integration_test_fake/plugin.py b/custom_plugins/integration_test_fake/plugin.py new file mode 100644 index 0000000..5951ceb --- /dev/null +++ b/custom_plugins/integration_test_fake/plugin.py @@ -0,0 +1,62 @@ +"""Hermetic integration-test plugin (disabled in normal configs). + +Enable only in tests via a temporary config or OPENCONTEXT_CONFIG JSON. +""" + +from __future__ import annotations + +from typing import Any, Dict, List + +from core.interfaces import MCPPlugin, PluginType, ToolDefinition, ToolResult + + +class IntegrationTestFakePlugin(MCPPlugin): + """Minimal MCP plugin for cross-component integration tests.""" + + plugin_name = "integration_test_fake" + plugin_type = PluginType.CUSTOM_API + plugin_version = "0.0.1" + + async def initialize(self) -> bool: + self._initialized = True + return True + + async def shutdown(self) -> None: + self._initialized = False + + def get_tools(self) -> List[ToolDefinition]: + return [ + ToolDefinition( + name="echo", + description="Echo message for integration tests", + input_schema={ + "type": "object", + "properties": {"msg": {"type": "string"}}, + "additionalProperties": False, + }, + ), + ToolDefinition( + name="fail_me", + description="Always returns failure for error-path tests", + input_schema={"type": "object"}, + ), + ] + + async def execute_tool( + self, tool_name: str, arguments: Dict[str, Any] + ) -> ToolResult: + if tool_name == "echo": + msg = arguments.get("msg", "") + return ToolResult( + content=[{"type": "text", "text": msg}], + success=True, + ) + if tool_name == "fail_me": + return ToolResult( + success=False, + error_message="integration fake failure", + ) + return ToolResult(success=False, error_message=f"unknown tool: {tool_name}") + + async def health_check(self) -> bool: + return True diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 405631b..2659c28 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -24,8 +24,9 @@ core/ server/ ├── adapters/ -│ └── aws_lambda.py # Lambda handler entry point -└── http_handler.py # HTTP request handling +│ ├── aws_lambda.py # AWS Lambda entry point +│ └── gcp_functions.py # GCP Cloud Functions gen2 entry point +└── http_handler.py # HTTP request handling plugins/ # Built-in plugins ├── ckan/ # CKAN open data portals @@ -62,6 +63,8 @@ tests/ # Unit tests ### Request Flow +**AWS (`--cloud aws`):** + ``` Claude / MCP Client → Claude Connectors (HTTPS) or Go stdio client @@ -78,6 +81,20 @@ Logs & traces: Failed async invocations → SQS Dead Letter Queue ``` +**GCP (`--cloud gcp`):** + +``` +Claude / MCP Client + → Claude Connectors (HTTPS) +HTTPS (Cloud Functions gen2 / Cloud Run) + → server.adapters.gcp_functions.mcp_http + → MCP Server → Plugin Manager → Plugin → External API + +Logs: Cloud Logging (gcloud / opencontext logs --cloud gcp) +``` + +Local dev uses `opencontext serve` (aiohttp) with the same MCP handler stack; no cloud ingress. + ## Plugins Each deployment enables **exactly one** plugin. diff --git a/docs/CLI.md b/docs/CLI.md index ea07ed3..9a453c8 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -23,7 +23,8 @@ uv run opencontext --help ## Global behavior - Commands that modify infrastructure (`deploy`, `destroy`) require a TTY and prompt for confirmation. -- All commands that interact with AWS or Terraform respect the environment set by `--env`. +- Multi-cloud commands support `--cloud aws|gcp` (default: `aws`). +- All commands that interact with Terraform respect the environment set by `--env`. - `--env` defaults to `staging` on every command that accepts it. --- @@ -32,19 +33,27 @@ uv run opencontext --help ### `opencontext authenticate` -Check all prerequisites and print a status table. Auto-installs `uv` and `awscli` when possible (`uv` first; pip only as a fallback for `uv` if neither is installed). +Check all prerequisites and print a status table. Supports cloud-specific checks via `--cloud`. + +- AWS mode auto-installs `uv` and `awscli` when possible (`uv` first; pip fallback for missing tools). +- GCP mode checks for `gcloud` and Application Default Credentials (ADC). **Checks:** 1. Python >= 3.11 2. `uv` (if missing: auto-install attempted via pip; preferred: install from [docs.astral.sh/uv](https://docs.astral.sh/uv/getting-started/installation/)) -3. AWS CLI (if missing: auto-install via `uv pip install awscli`, then pip as fallback) -4. AWS credentials (`aws sts get-caller-identity`) +3. Cloud CLI (`aws` in AWS mode, `gcloud` in GCP mode) +4. Cloud credentials (`aws sts get-caller-identity` in AWS mode, ADC token in GCP mode) 5. Terraform >= 1.0 ```bash opencontext authenticate +opencontext authenticate --cloud gcp ``` +| Flag | Default | Description | +|------|---------|-------------| +| `--cloud` | `aws` | Cloud provider (`aws` or `gcp`) | + --- ### `opencontext configure` @@ -56,16 +65,18 @@ Interactive wizard that creates `config.yaml`, the Terraform `.tfvars` file, and - Organization name and city - Environment (`staging` or `prod`) - Plugin (CKAN, ArcGIS, or Socrata) and connection settings -- AWS region, Lambda name, memory (MB), and timeout (seconds) -- Optional custom domain +- Cloud-specific settings: + - AWS: region, Lambda name, memory (MB), timeout (seconds), optional custom domain + - GCP: project ID, region, function name, memory (MiB), timeout (seconds), autoscaling min/max instances, optional artifact bucket **Outputs:** -- `config.yaml` — plugin and Lambda settings -- `terraform/aws/.tfvars` — Terraform variables +- `config.yaml` — plugin + cloud settings +- `terraform//.tfvars` — Terraform variables - Terraform workspace `-` (created or selected) ```bash opencontext configure +opencontext configure --cloud gcp ``` --- @@ -91,43 +102,47 @@ The server starts at `http://localhost:/mcp`. Use it with Claude Desktop ( ### `opencontext deploy` -Package the Lambda deployment zip, run `terraform plan`, show a summary, prompt for confirmation, then apply. +Package the deployment artifact, run `terraform plan`, show a summary, prompt for confirmation, then apply. ```bash opencontext deploy --env staging opencontext deploy --env prod +opencontext deploy --cloud gcp --env staging ``` | Flag | Default | Description | |------|---------|-------------| | `--env` | `staging` | Environment to deploy to | +| `--cloud` | `aws` | Cloud provider (`aws` or `gcp`) | **What it does:** 1. Runs all validation checks (same as `opencontext validate`) -2. Installs Python dependencies into `.deploy/` using `uv pip install -r requirements.txt` (pinned list for Lambda size and reproducibility) +2. Installs Python dependencies into `.deploy/` using `uv pip install -r requirements.txt` 3. Copies `core/`, `plugins/`, `server/`, and `custom_plugins/` into the zip -4. Copies the zip and `config.yaml` into `terraform/aws/` +4. Copies deployment artifacts into `terraform//` 5. Runs `terraform plan` and shows add/change/destroy counts 6. Prompts for confirmation (defaults to No) 7. Runs `terraform apply` -8. Prints API Gateway URL, CloudWatch log group, and custom domain details +8. Prints provider-specific outputs (`api_gateway_url` / `mcp_endpoint_url`, logs/resources) -After deployment, the API Gateway URL includes `/mcp` and is ready to use with Claude Connectors. +After deployment, use the printed connector URL with Claude Connectors. --- ### `opencontext status` -Show deployment status for an environment: Lambda info, API Gateway URL, custom domain, and certificate status. +Show deployment status for an environment with provider-specific runtime and endpoint details. ```bash opencontext status --env staging opencontext status --env prod +opencontext status --cloud gcp --env staging ``` | Flag | Default | Description | |------|---------|-------------| | `--env` | `staging` | Environment to query | +| `--cloud` | `aws` | Cloud provider (`aws` or `gcp`) | --- @@ -137,22 +152,24 @@ Run pre-deployment validation checks without deploying. Useful for CI or before ```bash opencontext validate --env staging +opencontext validate --cloud gcp --env staging ``` | Flag | Default | Description | |------|---------|-------------| | `--env` | `staging` | Environment to validate against | +| `--cloud` | `aws` | Cloud provider (`aws` or `gcp`) | **Checks:** 1. `config.yaml` exists 2. Exactly one plugin enabled 3. Plugin required fields present -4. `terraform/aws/.tfvars` exists +4. `terraform//.tfvars` exists 5. Terraform installed 6. Terraform initialized (`.terraform/` directory present) 7. `terraform validate` passes -8. AWS credentials valid -9. ACM certificate exists (only if `custom_domain` is set in tfvars) +8. Provider credentials valid (`aws sts ...` for AWS, ADC token for GCP) +9. Provider-specific checks (ACM/domain checks for AWS, `project_id` for GCP) Exits with code 1 if any check fails. @@ -184,23 +201,25 @@ If a custom domain is configured and its certificate is `ISSUED`, the command al ### `opencontext logs` -Tail CloudWatch logs for the deployed Lambda. +Tail runtime logs for the deployed function. ```bash opencontext logs --env staging opencontext logs --env staging --follow opencontext logs --env staging --verbose opencontext logs --env staging --since 30m +opencontext logs --cloud gcp --env staging ``` | Flag | Default | Description | |------|---------|-------------| | `--env` | `staging` | Environment to fetch logs for | +| `--cloud` | `aws` | Cloud provider (`aws` or `gcp`) | | `--follow`, `-f` | False | Stream new log entries as they arrive | | `--verbose`, `-v` | False | Show structured per-invocation view with duration and error highlighting | | `--since` | `1h` | How far back to fetch (e.g., `30m`, `2h`, `24h`) | -Without `--verbose`, log lines are printed with START entries highlighted in cyan and ERROR lines highlighted in red. With `--verbose`, invocations are grouped with request ID, duration, and status. +AWS supports `--verbose` parsing/highlights for CloudWatch logs. GCP uses `gcloud functions logs read` output. --- @@ -304,15 +323,17 @@ opencontext upgrade --upstream-url https://github.com/thealphacubicle/OpenContex ### `opencontext destroy` -Tear down all AWS resources for an environment. Requires typing the environment name to confirm. +Tear down all resources for an environment in the selected cloud. Requires typing the environment name to confirm. ```bash opencontext destroy --env staging opencontext destroy --env prod +opencontext destroy --cloud gcp --env staging ``` | Flag | Default | Description | |------|---------|-------------| | `--env` | `staging` | Environment to destroy | +| `--cloud` | `aws` | Cloud provider (`aws` or `gcp`) | -Runs `terraform destroy -auto-approve` after confirmation. This is irreversible — all Lambda, API Gateway, IAM, and CloudWatch resources for the workspace are removed. +Runs `terraform destroy -auto-approve` after confirmation. This is irreversible. diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 51a1018..29a8f08 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -1,16 +1,42 @@ # Deployment Guide -Deploy OpenContext to AWS Lambda. See [Getting Started](GETTING_STARTED.md) for the quick path. +Deploy OpenContext to **AWS** (Lambda + API Gateway) or **GCP** (Cloud Functions gen2). See [Getting Started](GETTING_STARTED.md) for the quick path. For per-command flags, see [CLI Reference](CLI.md). -## Prerequisites +## Cloud provider (`--cloud`) + +Most lifecycle commands accept `--cloud aws|gcp` (default: **`aws`**). + +| Command | `--cloud` support | +|---------|-------------------| +| `opencontext authenticate` | Yes | +| `opencontext configure` | Yes | +| `opencontext validate` | Yes | +| `opencontext deploy` | Yes | +| `opencontext status` | Yes | +| `opencontext logs` | Yes | +| `opencontext destroy` | Yes | + +AWS-only today: `domain`, `architecture`, `cost` (CloudWatch / AWS pricing). + +Terraform roots: `terraform/aws/` and `terraform/gcp/`. GCP module details: [terraform/gcp/README.md](../terraform/gcp/README.md). + +## Prerequisites (all clouds) -- AWS account, AWS CLI configured - Terraform >= 1.0 - Python 3.11+ +- Cloud CLI and credentials for the provider you choose (see below) -Run `opencontext authenticate` to verify all prerequisites before deploying. +Run `opencontext authenticate` (add `--cloud gcp` for GCP) to verify prerequisites before deploying. -## AWS Permissions +--- + +## AWS deployment + +### Prerequisites + +- AWS account, [AWS CLI](https://aws.amazon.com/cli/) configured (`aws configure` or SSO) + +### AWS permissions - Lambda (create, update functions) - IAM (roles, policies) @@ -20,29 +46,24 @@ Run `opencontext authenticate` to verify all prerequisites before deploying. - X-Ray (tracing, via AWSXRayDaemonWriteAccess) - ACM (only required when configuring a custom domain) -## Deployment - -### Using the CLI (recommended) +### Deploy (CLI) ```bash -# Check prerequisites opencontext authenticate +opencontext configure # or: opencontext configure --cloud aws +opencontext validate --env staging +opencontext deploy --env staging # default --cloud aws +``` -# Configure (creates config.yaml, .tfvars, and Terraform workspace) -opencontext configure +`opencontext deploy` packages the Lambda (`uv pip install` from `requirements.txt` into the bundle), runs `terraform plan`, shows a summary, asks for confirmation, then applies. The **API Gateway URL** (includes `/mcp`) is printed on success. -# Validate before deploying -opencontext validate --env staging +To update after changing code or config: -# Deploy +```bash opencontext deploy --env staging ``` -`opencontext deploy` packages the Lambda (Python dependencies are installed from `requirements.txt` via `uv pip install` into the bundle), runs `terraform plan`, shows a summary of changes, asks for confirmation, then applies. The API Gateway URL is printed on success. - -To update after changing code or config, just run `opencontext deploy --env staging` again. - -### Manual Terraform +### Manual Terraform (AWS) First-time: bootstrap the S3 backend (run once). See [terraform/bootstrap/README.md](../terraform/bootstrap/README.md): @@ -60,39 +81,38 @@ terraform plan -var-file=staging.tfvars -out=tfplan terraform apply tfplan ``` -The `opencontext configure` command generates the `.tfvars` file. For manual runs, ensure `config.yaml` exists in the project root and `terraform/aws/staging.tfvars` has the correct values. +`opencontext configure` generates `terraform/aws/.tfvars`. For manual runs, ensure `config.yaml` exists and the `.tfvars` file matches your environment. -## Endpoints +### AWS endpoints -All traffic — development, staging, and production — goes through the API Gateway URL. There is no separate no-auth endpoint. +All traffic goes through the **API Gateway URL**. There is no separate no-auth endpoint. | Endpoint | Use Case | Auth | |----------|----------|------| | **API Gateway** | All environments | Rate limiting, daily quota | -### Get the URL +**Get the URL:** ```bash -# Via CLI opencontext status --env staging -# Via Terraform directly +# Or via Terraform cd terraform/aws terraform output -raw api_gateway_url # Includes /mcp suffix ``` -### API Gateway +**API Gateway behavior:** -- **Throttling:** Default 10 burst / 5 sustained req/s; configurable via `api_burst_limit` and `api_rate_limit` Terraform variables -- **Daily quota:** Configurable via `api_quota_limit` Terraform variable -- **Stage name:** Default is `staging`; URL format: `https://...execute-api.region.amazonaws.com/staging/mcp` +- **Throttling:** Default 10 burst / 5 sustained req/s; configurable via `api_burst_limit` and `api_rate_limit` +- **Daily quota:** Configurable via `api_quota_limit` +- **Stage name:** Default `staging`; URL format: `https://...execute-api.region.amazonaws.com/staging/mcp` - **HTTP 429** when rate or quota is exceeded -## Configuration +Custom domains: `opencontext domain --env staging` (ACM + DNS). See [CLI Reference](CLI.md). -Config is passed via `OPENCONTEXT_CONFIG` env var. Use `opencontext configure` to generate `config.yaml`, or create it manually from `config-example.yaml`. +### AWS configuration (`config.yaml`) -### Lambda Settings (in config.yaml) +Config is passed via the `OPENCONTEXT_CONFIG` environment variable on Lambda. Use `opencontext configure` or copy from `config-example.yaml`. ```yaml aws: @@ -102,41 +122,151 @@ aws: lambda_timeout: 120 # 1–900 seconds ``` -## Monitoring +### AWS monitoring - **CloudWatch Logs:** `/aws/lambda/`, 14-day retention -- **Tail logs via CLI:** `opencontext logs --env staging` -- **Stream logs:** `opencontext logs --env staging --follow` -- **Raw AWS CLI:** `aws logs tail /aws/lambda/my-mcp-server --follow` +- **CLI:** `opencontext logs --env staging` (`--follow`, `--verbose`) +- **Raw:** `aws logs tail /aws/lambda/my-mcp-server --follow` -## Updating & Cleanup +### AWS cost (us-east-1, indicative) -**Update:** Change code or `config.yaml`, then run `opencontext deploy --env staging` again. +- Lambda: ~$0.20/1M requests, ~$0.0000166667/GB-second +- API Gateway: ~$3.50/1M requests +- Example: 100K req/month, 512 MB, 1s avg ≈ **$1/month** + +Use `opencontext cost --env staging` for estimates from CloudWatch metrics. + +--- + +## GCP deployment + +### Prerequisites + +- GCP project with billing enabled (if required by your org) +- [gcloud](https://cloud.google.com/sdk/docs/install) and Application Default Credentials: `gcloud auth application-default login` +- Required APIs (Terraform enables common ones; org policies may require approval) + +### Deploy (CLI) -**Destroy:** ```bash -opencontext destroy --env staging +opencontext authenticate --cloud gcp +opencontext configure --cloud gcp +opencontext validate --cloud gcp --env staging +opencontext deploy --cloud gcp --env staging ``` -This runs `terraform destroy` with a confirmation prompt. You must type the environment name to confirm. +`opencontext deploy --cloud gcp` builds `gcf-deployment.zip`, copies it into `terraform/gcp/`, runs `terraform plan` / `apply`, and prints **`mcp_endpoint_url`** (HTTPS URL ending in `/mcp`). -## Cost (us-east-1) +Day-2 operations: -- Lambda: ~$0.20/1M requests, ~$0.0000166667/GB-second -- API Gateway: ~$3.50/1M requests -- Example: 100K req/month, 512 MB, 1s avg ≈ **$1/month** +```bash +opencontext status --cloud gcp --env staging +opencontext logs --cloud gcp --env staging +opencontext destroy --cloud gcp --env staging +``` + +### Bootstrap remote state (GCP, once) + +```bash +cd terraform/gcp/bootstrap +terraform init +terraform apply -var="project_id=YOUR_PROJECT_ID" +``` + +Use a globally unique bucket name if the default is taken (`-var="state_bucket_name=..."`). Align `terraform/gcp/backend.tf` or pass `-backend-config="bucket=..."` when initializing the main module. + +### Manual Terraform (GCP) + +See [terraform/gcp/README.md](../terraform/gcp/README.md) for packaging `gcf-deployment.zip` and manual `terraform apply`. + +### GCP endpoints + +| Output | Description | +|--------|-------------| +| `mcp_endpoint_url` | MCP JSON-RPC URL (`…/mcp`) — use with Claude Connectors | +| `function_uri` | Base HTTPS URL of the Cloud Function | +| `source_bucket` | GCS bucket storing the deployment zip | + +**Get the URL:** + +```bash +opencontext status --cloud gcp --env staging + +cd terraform/gcp +terraform output -raw mcp_endpoint_url +``` + +The function is publicly invokable via Cloud Run IAM (`allUsers` + `roles/run.invoker`), similar to open API Gateway invoke on AWS. Optional API Gateway + custom domain: [terraform/gcp/API_GATEWAY_PHASE2.md](../terraform/gcp/API_GATEWAY_PHASE2.md). + +### GCP configuration (`config.yaml`) + +```yaml +gcp: + region: "us-central1" + function_name: "your-opendata-mcp" # Optional; defaults from server_name slug + function_memory_mb: 512 + function_timeout_sec: 120 # Max 3600 for gen2 + min_instance_count: 0 + max_instance_count: 100 +``` + +The configure wizard also prompts for `project_id` and optional `artifact_bucket_name` (written to `terraform/gcp/.tfvars`). + +### GCP monitoring + +- **CLI:** `opencontext logs --cloud gcp --env staging` (`gcloud functions logs read` under the hood) +- **Console:** Cloud Logging for the Cloud Functions gen2 service + +There is no AWS-style SQS DLQ on the HTTP Cloud Function path; async failure handling differs from Lambda. See [terraform/gcp/README.md](../terraform/gcp/README.md). + +### GCP cost + +Pricing depends on invocations, memory, CPU time, and networking. Use [Google Cloud pricing](https://cloud.google.com/functions/pricing) and billing reports for your project. There is no `opencontext cost` command for GCP yet. + +--- + +## Shared behavior + +### Configuration and plugins + +- **`OPENCONTEXT_CONFIG`:** JSON config injected at deploy time (same env var name on AWS and GCP). +- **One plugin per deployment:** Exactly one `plugins.*.enabled: true` in `config.yaml`. See [Architecture](ARCHITECTURE.md). +- **Packaging:** Both clouds use `requirements.txt` with `uv pip install` targeting **Python 3.11** and **linux x86_64** wheels (`x86_64-manylinux2014`). + +### Updating + +Change code or `config.yaml`, then redeploy with the same `--cloud` and `--env` you used initially: + +```bash +opencontext deploy --env staging +opencontext deploy --cloud gcp --env staging +``` + +### Destroy + +```bash +opencontext destroy --env staging +opencontext destroy --cloud gcp --env staging +``` + +Runs `terraform destroy` after confirmation. You must type the environment name to confirm. This is irreversible. + +--- ## Troubleshooting | Issue | Solution | |-------|----------| | Multiple plugins | Enable only ONE in `config.yaml` | -| Lambda timeout | Increase `lambda_timeout` in `config.yaml` | -| 500 error | `opencontext logs --env staging` | -| Missing `.tfvars` | Run `opencontext configure` | -| High cost | Reduce `lambda_memory`, review usage | +| Wrong cloud / missing `.tfvars` | Run `opencontext configure` with the same `--cloud` as deploy; files live under `terraform//` | +| AWS Lambda timeout | Increase `lambda_timeout` in `config.yaml` | +| GCP function timeout | Increase `function_timeout_sec` in `config.yaml` | +| 500 error | `opencontext logs --env staging` (add `--cloud gcp` on GCP) | +| Validation fails (GCP) | `opencontext authenticate --cloud gcp`; check ADC and `project_id` in `.tfvars` | +| High AWS cost | Reduce `lambda_memory`, review usage; use `opencontext cost` | ## Security -- API Gateway enforces rate limiting and daily quotas for all environments -- Store secrets in env vars, not code +- **AWS:** API Gateway rate limiting and daily quotas; optional custom domain with ACM. +- **GCP:** Public HTTPS invoke on the function URL; tighten IAM if you add API Gateway or IAP later. +- Store secrets in environment variables or secret managers, not in committed config. diff --git a/docs/FAQ.md b/docs/FAQ.md index 2976bfd..7c48d64 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -36,7 +36,7 @@ Yes, but they must be resolved before deployment. Terraform will set the final c ### What if I need to change configuration after deployment? -Edit `config.yaml` and run `opencontext deploy --env staging` again. Terraform will update the Lambda environment variable. +Edit `config.yaml` and run `opencontext deploy --env staging` again (add `--cloud gcp` if you deployed to GCP). Terraform updates the runtime configuration (`OPENCONTEXT_CONFIG`). ## Plugins @@ -60,12 +60,21 @@ No. Built-in plugins are part of the core framework. Create a custom plugin inst ## Deployment -### What AWS resources are created? +### What cloud resources are created? + +**AWS (`--cloud aws`, default):** - Lambda function -- Lambda Function URL +- API Gateway (REST) - IAM role and policies - CloudWatch Log Group +- SQS dead-letter queue (async failures) + +**GCP (`--cloud gcp`):** + +- Cloud Functions gen2 (HTTP) +- GCS bucket for deployment artifacts +- IAM for public HTTPS invoke (Cloud Run invoker) ### How much does it cost? @@ -73,7 +82,7 @@ Typical costs: ~$1/month for 100K requests. See [Deployment Guide](DEPLOYMENT.md ### Can I deploy to a different cloud provider? -The current implementation is AWS-specific. Contributions for other providers are welcome! +**AWS** and **GCP** are supported via `--cloud aws` (default) or `--cloud gcp` on `authenticate`, `configure`, `validate`, `deploy`, `status`, `logs`, and `destroy`. See [Deployment Guide](DEPLOYMENT.md) and [terraform/gcp/README.md](../terraform/gcp/README.md). Azure Terraform is a placeholder only. ### How do I update an existing deployment? diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index 2aef80f..9e368d8 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -8,9 +8,10 @@ OpenContext uses the Model Context Protocol (MCP), which connects AI assistants - Python 3.11+ - Terraform >= 1.0 (for deployment) -- AWS CLI configured (for deployment) +- **AWS:** AWS CLI configured (`aws configure` or SSO) +- **GCP:** `gcloud` and Application Default Credentials (`gcloud auth application-default login`) -Run `opencontext authenticate` to check all prerequisites automatically — it will flag anything missing and try to auto-install `uv` and `awscli` if needed. +Run `opencontext authenticate` to check prerequisites for AWS (default). Use `opencontext authenticate --cloud gcp` before a GCP deploy. --- @@ -67,7 +68,7 @@ Run the interactive wizard: opencontext configure ``` -This walks you through selecting a plugin, setting the data source URL, and configuring AWS settings. It writes `config.yaml` and the Terraform variable files for you. +This walks you through selecting a plugin, setting the data source URL, and cloud settings (AWS by default; use `--cloud gcp` for GCP). It writes `config.yaml` and `terraform//.tfvars`. If you prefer to configure manually, copy the template and edit it: @@ -139,20 +140,27 @@ opencontext configure This prompts for your organization name, city, plugin, AWS region, Lambda name, and optional custom domain. It creates `config.yaml`, the Terraform `.tfvars` file, and initializes the Terraform workspace. -### 2. Deploy to AWS +### 2. Deploy + +**AWS (default):** ```bash opencontext deploy --env staging ``` -The command validates config, packages code, runs `terraform plan`, asks for confirmation, then applies. At the end you'll see: +**GCP:** +```bash +opencontext configure --cloud gcp # if not done yet +opencontext deploy --cloud gcp --env staging ``` -API Gateway URL (use for Claude Connectors): -https://xxx.execute-api.us-east-1.amazonaws.com/staging/mcp -``` -AWS creates: Lambda function, Function URL, API Gateway, IAM role, CloudWatch Log Group. Cost is roughly $1/month for 100K requests. See [Deployment](DEPLOYMENT.md) for details. +The command validates config, packages code, runs `terraform plan`, asks for confirmation, then applies. On success you get a connector URL: + +- **AWS:** `api_gateway_url` (e.g. `https://xxx.execute-api.us-east-1.amazonaws.com/staging/mcp`) +- **GCP:** `mcp_endpoint_url` (Cloud Functions HTTPS URL ending in `/mcp`) + +See [Deployment](DEPLOYMENT.md) for permissions, bootstrap, monitoring, and costs per cloud. ### 3. Connect via Claude Connectors (Production) @@ -160,22 +168,23 @@ Connect using **Claude Connectors** (same steps on both Claude.ai and Claude Des 1. Go to **Settings** → **Connectors** (or **Customize** → **Connectors** on claude.ai) 2. Click **Add custom connector** -3. Enter a name (e.g. "Your City OpenData") and your API Gateway URL +3. Enter a name (e.g. "Your City OpenData") and your deployment URL (API Gateway on AWS, `mcp_endpoint_url` on GCP) To retrieve the URL later: ```bash opencontext status --env staging +opencontext status --cloud gcp --env staging ``` -Or directly from Terraform: +Or from Terraform: ```bash -cd terraform/aws -terraform output -raw api_gateway_url +cd terraform/aws && terraform output -raw api_gateway_url +cd terraform/gcp && terraform output -raw mcp_endpoint_url ``` -The output already includes `/mcp`. Use the API Gateway URL for all testing and production traffic. +Outputs include the `/mcp` path. Use that URL for all testing and production traffic. ### 4. Updating @@ -193,21 +202,21 @@ See [CLI Guide](CLI.md) for full flag documentation. | Command | Description | |---------|-------------| -| `opencontext authenticate` | Check prerequisites (Python, uv, AWS CLI, credentials, Terraform) | -| `opencontext configure` | Interactive wizard: creates `config.yaml`, `.tfvars`, and Terraform workspace | -| `opencontext serve` | Start local dev server at `http://localhost:8000/mcp` (no AWS required) | -| `opencontext deploy --env ` | Package Lambda, plan changes, confirm, and deploy | -| `opencontext status --env ` | Show deployment status, URLs, and cert status | -| `opencontext validate --env ` | Run pre-deployment checks without deploying | +| `opencontext authenticate [--cloud aws\|gcp]` | Check prerequisites for the selected cloud | +| `opencontext configure [--cloud aws\|gcp]` | Wizard: `config.yaml`, `terraform//*.tfvars`, workspace | +| `opencontext serve` | Start local dev server at `http://localhost:8000/mcp` (no cloud account required) | +| `opencontext deploy [--cloud aws\|gcp] --env ` | Package artifact, plan, confirm, deploy | +| `opencontext status [--cloud aws\|gcp] --env ` | Deployment status and endpoint URLs | +| `opencontext validate [--cloud aws\|gcp] --env ` | Pre-deployment checks without deploying | | `opencontext test --env ` | Test the deployed MCP server endpoints | -| `opencontext logs --env ` | Tail CloudWatch logs (`--follow` to stream, `--verbose` for structured view) | +| `opencontext logs [--cloud aws\|gcp] --env ` | Tail logs (CloudWatch or `gcloud functions logs`) | | `opencontext domain --env ` | Check custom domain and certificate status | | `opencontext architecture` | Show AWS architecture diagram in the terminal | | `opencontext plugin list` | List all plugins and their enabled/disabled status | | `opencontext security` | Run a pip-audit vulnerability scan (`--export` to save report) | | `opencontext cost --env ` | Estimate AWS costs from CloudWatch metrics (`--days` to adjust window) | | `opencontext upgrade` | Merge updates from the upstream OpenContext template | -| `opencontext destroy --env ` | Tear down all deployed resources | +| `opencontext destroy [--cloud aws\|gcp] --env ` | Tear down deployed resources for that cloud | --- @@ -228,7 +237,7 @@ See [CLI Guide](CLI.md) for full flag documentation. - [CLI Reference](CLI.md) — All commands and flags in detail - [Architecture](ARCHITECTURE.md) — System design, built-in plugins, custom plugins - [Built-in Plugins](BUILT_IN_PLUGINS.md) — CKAN, ArcGIS Hub, and Socrata tool reference -- [Deployment](DEPLOYMENT.md) — AWS details, monitoring, cost +- [Deployment](DEPLOYMENT.md) — AWS & GCP (`--cloud`), monitoring, cost - [Testing](TESTING.md) — Local testing (Terminal, Claude, MCP Inspector) --- diff --git a/docs/TESTING.md b/docs/TESTING.md index b44f76a..dfbb4f4 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -106,17 +106,36 @@ asyncio.run(t()) --- -## Unit Tests +## Automated tests (pytest) -With dev dependencies installed (`uv sync --all-extras`): +With dev dependencies (`uv sync --all-extras`). Layout: [`tests/README.md`](../tests/README.md). + +**Full suite + CI-equivalent coverage:** + +```bash +uv run pytest tests/ -n auto \ + --cov=core --cov=plugins --cov=server \ + --cov-report=term-missing \ + --cov-fail-under=80 +``` + +**Targeted suites** (markers are defined in `pyproject.toml`): + +```bash +uv run pytest tests/integration -m integration -v +uv run pytest tests/unit -m unit -v +uv run pytest tests/security -m security -v +uv run pytest tests/smoke -m smoke -v +``` + +Single-file examples: ```bash -uv run pytest -uv run pytest tests/test_plugin_manager.py -v -uv run pytest --cov=core --cov=plugins +uv run pytest tests/unit/core/test_plugin_manager.py -v +uv run pytest tests/unit/plugins/ckan/test_ckan_plugin.py -v ``` -`sqlparse` and other test-related packages are pulled in via `pyproject.toml`; you do not need a separate `pip install` for them when using `uv sync`. +`sqlparse` and other test-related packages come from `pyproject.toml`; no extra `pip install` when using `uv sync`. --- diff --git a/main.py b/main.py new file mode 100644 index 0000000..afd524f --- /dev/null +++ b/main.py @@ -0,0 +1,9 @@ +"""Google Cloud Functions gen2 entry module. + +Cloud Functions Python loads `mcp_http` from this file (see Terraform `entry_point`). +The deployment zip must place this file at the archive root next to `core/`, `server/`, etc. + +AWS Lambda uses `server.adapters.aws_lambda.lambda_handler` instead; this file is unused there. +""" + +from server.adapters.gcp_functions import mcp_http # noqa: F401 diff --git a/pyproject.toml b/pyproject.toml index 52c4354..8f2f5d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,8 @@ dependencies = [ "botocore>=1.42.83", "click>=8.3.1", "pytest>=9.0.2", + "functions-framework>=3.4.0", + "typing-extensions<4.14.0", ] [project.optional-dependencies] @@ -58,6 +60,12 @@ include = ["core*", "server*", "plugins*", "custom_plugins*", "cli*"] [tool.pytest.ini_options] asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" +markers = [ + "unit: fast isolated unit tests", + "integration: hermetic cross-component integration tests", + "security: security and validation guardrail tests", + "smoke: lightweight smoke checks", +] [tool.coverage.run] # Exclude hard-to-test modules from coverage (formatters, integration-heavy plugin code) diff --git a/requirements-dev.txt b/requirements-dev.txt deleted file mode 100644 index eebd2e0..0000000 --- a/requirements-dev.txt +++ /dev/null @@ -1,26 +0,0 @@ -# Development dependencies for testing (legacy companion to requirements.txt). -# Prefer: uv sync --all-extras (uses pyproject.toml + uv.lock) -# Or: uv pip install -r requirements-dev.txt - --r requirements.txt - -# Testing -pytest>=7.0.0 -pytest-asyncio>=0.21.0 -pytest-cov>=4.0.0 -pytest-xdist>=3.0.0 -click>=8.3.1 - -# Linting / auditing -ruff>=0.1.0 -pip-audit>=2.0.0 - -# CLI -typer>=0.9.0 -questionary>=2.0.0 -rich>=13.0.0 -boto3>=1.42.83 -botocore>=1.42.83 - -# Pre-commit hooks (run: uv run pre-commit install) -pre-commit>=4.0.0 diff --git a/requirements.txt b/requirements.txt index af89c23..6a3279a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,3 +10,5 @@ aiohttp>=3.13.4 pygments>=2.20.0 requests>=2.33.0 pre-commit>=4.5.1 +functions-framework>=3.4.0 +typing-extensions<4.14.0 diff --git a/server/adapters/gcp_functions.py b/server/adapters/gcp_functions.py new file mode 100644 index 0000000..7af8d68 --- /dev/null +++ b/server/adapters/gcp_functions.py @@ -0,0 +1,108 @@ +"""Google Cloud Functions (gen2) HTTP adapter for OpenContext MCP server. + +Maps Flask `Request` objects (via functions-framework) to UniversalHTTPHandler. +Config is read from OPENCONTEXT_CONFIG (set by Terraform), same contract as AWS Lambda. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import uuid +from typing import Any, Optional + +import functions_framework +from flask import Request + +from server.http_handler import UniversalHTTPHandler + +logger = logging.getLogger(__name__) + +_handler: Optional[UniversalHTTPHandler] = None + + +def get_handler() -> UniversalHTTPHandler: + """Lazy handler for warm starts.""" + global _handler + if _handler is None: + _handler = UniversalHTTPHandler() + return _handler + + +def _request_id(request: Request) -> str: + rid = request.headers.get("X-Request-ID") or request.headers.get( + "X-Cloud-Trace-Context" + ) + if rid and "/" in rid: # trace context form: TRACE_ID/SPAN_ID;o=1 + rid = rid.split("/")[0] + return rid or str(uuid.uuid4()) + + +@functions_framework.http +def mcp_http(request: Request) -> Any: + """HTTP Cloud Function entry point (Terraform entry_point = mcp_http, main.py re-exports).""" + req_id = _request_id(request) + method = request.method.upper() + path = request.path or "/" + + try: + if method == "OPTIONS": + handler = get_handler() + status_code, headers, body = handler.handle_options(request_id=req_id) + return (body, status_code, headers) + + body_raw = request.get_data(as_text=True) + if not body_raw: + body_raw = "{}" + + headers = {k.lower(): v for k, v in request.headers.items()} + + handler = get_handler() + + async def _run_with_cleanup(): + try: + return await handler.handle_request( + method=method, + path=path, + body=body_raw, + headers=headers, + request_id=req_id, + ) + finally: + from server import http_handler + + if http_handler._plugin_manager is not None: + await http_handler._plugin_manager.shutdown() + http_handler._plugin_manager = None + http_handler._mcp_server = None + + status_code, response_headers, response_body = asyncio.run(_run_with_cleanup()) + return (response_body, status_code, response_headers) + + except Exception as e: + logger.error( + "Error in Cloud Functions handler: %s", + e, + extra={"request_id": req_id, "error_type": type(e).__name__}, + exc_info=True, + ) + err = json.dumps( + { + "jsonrpc": "2.0", + "id": None, + "error": { + "code": -32603, + "message": "Internal error", + "data": str(e), + }, + } + ) + return ( + err, + 500, + { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*", + }, + ) diff --git a/terraform/README.md b/terraform/README.md index 2dcc500..cfcc77b 100644 --- a/terraform/README.md +++ b/terraform/README.md @@ -1,8 +1,10 @@ # Terraform Configurations -## AWS (Primary) +OpenContext supports **AWS** and **GCP** via `opencontext deploy --cloud aws|gcp`. See [Deployment Guide](../docs/DEPLOYMENT.md). -Deploy OpenContext to AWS Lambda. See [Deployment Guide](../docs/DEPLOYMENT.md). +## AWS + +Deploy OpenContext to AWS Lambda + API Gateway. ### First-time setup: Bootstrap backend @@ -39,5 +41,5 @@ Use `opencontext configure --state-bucket ` to create a per-account ## Other Clouds -- **GCP:** [gcp/](gcp/) – Coming soon +- **GCP:** [gcp/](gcp/) — Cloud Functions gen2 + GCS artifact (see [gcp/README.md](gcp/README.md); bootstrap state in [gcp/bootstrap/](gcp/bootstrap/)) - **Azure:** [azure/](azure/) – Coming soon diff --git a/terraform/gcp/API_GATEWAY_PHASE2.md b/terraform/gcp/API_GATEWAY_PHASE2.md new file mode 100644 index 0000000..cc43f34 --- /dev/null +++ b/terraform/gcp/API_GATEWAY_PHASE2.md @@ -0,0 +1,22 @@ +# Phase 2: Google Cloud API Gateway + custom domain + +The v1 Terraform stack deploys **Cloud Functions (gen2)** with a public HTTPS URL and `POST /mcp` on that host. That matches the single HTTPS endpoint model without a separate edge product. + +When you need closer parity with **AWS API Gateway** (OpenAPI-first routing, API keys, quotas, or a stable custom domain in front of managed TLS), add **[Google Cloud API Gateway](https://cloud.google.com/api-gateway/docs)** in a follow-up change. + +## Why it is not in v1 + +- API Gateway is driven by an **OpenAPI 3** document that references your backend URL (the Cloud Run service behind gen2). The backend URL changes when you recreate the function unless you fix it with a stable hostname or separate Cloud Run service name. +- Terraform for API Gateway typically involves `google_api_gateway_api`, configs, gateways, and DNS verification for custom domains — more moving parts than the AWS REST API Gateway resources in `terraform/aws/`. + +## Suggested approach + +1. Deploy v1 and capture the function URL from `terraform output mcp_endpoint_url` (or `function_uri`). +2. Author an OpenAPI spec with a path `/mcp` forwarding to the Cloud Run or function backend (Google documents `x-google-backend` / backend address for your deployment type). +3. Add Terraform resources for the API config and gateway; map a **custom domain** using Google's API Gateway custom domain flow (DNS validation + managed certificate as documented for the product). +4. Optionally tighten **Cloud Functions / Cloud Run** ingress to **internal + load balancer** only if you want all traffic to flow through API Gateway. + +## Related GCP docs + +- [API Gateway overview](https://cloud.google.com/api-gateway/docs/about-api-gateway) +- [Cloud Functions gen2](https://cloud.google.com/functions/docs/2nd-gen/overview) (runs on Cloud Run) diff --git a/terraform/gcp/README.md b/terraform/gcp/README.md index 7914b25..e8aef5e 100644 --- a/terraform/gcp/README.md +++ b/terraform/gcp/README.md @@ -1,7 +1,111 @@ -# GCP Terraform Configuration +# GCP Terraform (Cloud Functions gen2) -This directory contains Terraform configurations for deploying OpenContext to Google Cloud Platform. +Deploy OpenContext to **Google Cloud Functions (2nd gen)** with Terraform. Runtime behavior matches AWS: configuration is injected as **`OPENCONTEXT_CONFIG`** (JSON), same as Lambda. -## Status +This module is supported by the `opencontext` CLI (`configure`, `validate`, `deploy`, `status`, `logs`, `destroy`) via `--cloud gcp`. -🚧 **Coming Soon** - GCP deployment configurations will be added here. +## Architecture + +- **Cloud Functions gen2** (Python 3.11) — HTTP trigger; MCP endpoint is **`{function_url}/mcp`** (same path as AWS API Gateway). +- **GCS** — Source zip uploaded to a bucket; the function build reads from `gcf-deployment.zip` in this directory (or update the path in `main.tf` locals). +- **Cloud Run IAM** — `roles/run.invoker` for `allUsers` so the HTTPS URL is publicly invokable (equivalent to open API Gateway invoke in the AWS template). +- **Optional Phase 2** — [Google Cloud API Gateway](API_GATEWAY_PHASE2.md) + custom domain for API-management–style features. +- **Failure / DLQ** — AWS Lambda uses an SQS dead-letter queue for async failures. HTTP Cloud Functions gen2 does not mirror that; you can add an optional Pub/Sub topic and custom handling later if you introduce async triggers. + +## Prerequisites + +- GCP project, billing enabled if required by your org +- [gcloud](https://cloud.google.com/sdk/docs/install) and Application Default Credentials (`gcloud auth application-default login`) +- Terraform >= 1.0 +- APIs enabled (Terraform enables common ones; you may need org approval) + +## 1. Bootstrap remote state (once) + +```bash +cd terraform/gcp/bootstrap +terraform init +terraform apply -var="project_id=YOUR_PROJECT_ID" +``` + +Use a **globally unique** bucket name if the default is taken (`-var="state_bucket_name=..."`). Align [`backend.tf`](backend.tf) or pass `-backend-config="bucket=..."` when initializing the main module. + +## 2. Configure with CLI (recommended) + +```bash +opencontext configure --cloud gcp +``` + +This writes: +- `config.yaml` (plugin + `gcp` settings) +- `terraform/gcp/.tfvars` +- Terraform workspace `-` + +GCP wizard prompts include: +- `project_id`, `region`, `function_name` +- `function_memory_mb`, `function_timeout_sec` +- `min_instance_count`, `max_instance_count` (autoscaling) +- optional `artifact_bucket_name` + +## 3. Validate and deploy with CLI + +```bash +opencontext validate --cloud gcp --env staging +opencontext deploy --cloud gcp --env staging +``` + +The deploy command packages `gcf-deployment.zip`, copies it into `terraform/gcp/`, runs `terraform plan`, prompts for confirmation, then applies. + +## 4. Day-2 operations + +```bash +opencontext status --cloud gcp --env staging +opencontext logs --cloud gcp --env staging +opencontext destroy --cloud gcp --env staging +``` + +## Manual packaging/deploy (advanced) + +If you need a fully manual flow, you can still package and apply Terraform yourself. + +```bash +rm -rf .deploy && mkdir .deploy +uv pip install -r requirements.txt --target .deploy --python-platform x86_64-manylinux2014 --python-version 3.11 --no-compile +cp -R core plugins server custom_plugins .deploy/ 2>/dev/null || true +mkdir -p .deploy/custom_plugins +cp main.py .deploy/ +cd .deploy && zip -r ../terraform/gcp/gcf-deployment.zip . && cd .. +``` + +Adjust `--python-platform` if Google’s build uses a different arch; Cloud Build runs on Google’s infrastructure and installs from your zip layout. + +Deploy manually: + +```bash +cd terraform/gcp +terraform init # add -backend-config if your state bucket name differs +terraform plan -var="project_id=YOUR_PROJECT_ID" -var="gcp_region=us-central1" -var="stage_name=staging" +terraform apply +``` + +Use `-var="config_file=../../config.yaml"` if you run from another working directory. + +## Outputs + +- `mcp_endpoint_url` — MCP JSON-RPC URL (`…/mcp`). +- `function_uri` — Base URL of the function. +- `source_bucket` — Bucket storing the uploaded zip. + +## Files + +| File | Purpose | +|------|---------| +| [`main.tf`](main.tf) | Config parse, GCS object, Cloud Function, public invoker | +| [`variables.tf`](variables.tf) | `project_id`, region, stage, overrides | +| [`outputs.tf`](outputs.tf) | URLs and bucket | +| [`backend.tf`](backend.tf) | GCS backend for state | +| [`versions.tf`](versions.tf) | Provider pins | +| `gcf-deployment.zip` | **You** build and refresh before `apply` (CI uses an empty placeholder for `validate` only) | + +## CI + +Repository CI runs `terraform fmt` / `validate` on all `terraform/*/` directories. A minimal empty `gcf-deployment.zip` must exist so `filebase64sha256` and `validate` succeed locally and in GitHub Actions. diff --git a/terraform/gcp/backend.tf b/terraform/gcp/backend.tf new file mode 100644 index 0000000..de162e0 --- /dev/null +++ b/terraform/gcp/backend.tf @@ -0,0 +1,9 @@ +# Remote state in GCS. Create the bucket first (see bootstrap/README.md), then either: +# terraform init -backend-config="bucket=YOUR_STATE_BUCKET" +# or set bucket below to match your bootstrap bucket and run terraform init. +terraform { + backend "gcs" { + bucket = "opencontext-terraform-state-gcp" + prefix = "opencontext/gcp/terraform.tfstate" + } +} diff --git a/terraform/gcp/bootstrap/README.md b/terraform/gcp/bootstrap/README.md new file mode 100644 index 0000000..28e5389 --- /dev/null +++ b/terraform/gcp/bootstrap/README.md @@ -0,0 +1,30 @@ +# GCP Terraform state bootstrap + +Creates a **versioned GCS bucket** for remote Terraform state used by [`../`](../README.md). + +## Prerequisites + +- [gcloud](https://cloud.google.com/sdk/docs/install) authenticated (`gcloud auth application-default login` and `gcloud config set project YOUR_PROJECT`) +- Terraform >= 1.0 +- A GCP project and permission to create GCS buckets + +## Usage + +```bash +cd terraform/gcp/bootstrap +terraform init +terraform apply -var="project_id=YOUR_PROJECT_ID" +``` + +The default bucket name is `opencontext-terraform-state-gcp`. It must be **globally unique**; if the name is taken, set: + +```bash +terraform apply -var="project_id=YOUR_PROJECT_ID" -var="state_bucket_name=your-org-opencontext-tfstate" +``` + +Then update the `bucket` in [`../backend.tf`](../backend.tf) to match, or run `terraform init` in `terraform/gcp` with: + +```bash +cd ../ +terraform init -backend-config="bucket=your-org-opencontext-tfstate" +``` diff --git a/terraform/gcp/bootstrap/main.tf b/terraform/gcp/bootstrap/main.tf new file mode 100644 index 0000000..934e7b9 --- /dev/null +++ b/terraform/gcp/bootstrap/main.tf @@ -0,0 +1,22 @@ +resource "google_project_service" "storage" { + project = var.project_id + service = "storage.googleapis.com" + disable_on_destroy = false +} + +resource "google_storage_bucket" "terraform_state" { + name = var.state_bucket_name + location = var.gcp_region + uniform_bucket_level_access = true + force_destroy = false + + versioning { + enabled = true + } + + lifecycle { + prevent_destroy = true + } + + depends_on = [google_project_service.storage] +} diff --git a/terraform/gcp/bootstrap/outputs.tf b/terraform/gcp/bootstrap/outputs.tf new file mode 100644 index 0000000..ee4ca83 --- /dev/null +++ b/terraform/gcp/bootstrap/outputs.tf @@ -0,0 +1,9 @@ +output "state_bucket" { + description = "GCS bucket name — use this as the backend bucket for terraform/gcp" + value = google_storage_bucket.terraform_state.name +} + +output "state_bucket_url" { + description = "gs:// URL for documentation" + value = google_storage_bucket.terraform_state.url +} diff --git a/terraform/gcp/bootstrap/variables.tf b/terraform/gcp/bootstrap/variables.tf new file mode 100644 index 0000000..b5a79b0 --- /dev/null +++ b/terraform/gcp/bootstrap/variables.tf @@ -0,0 +1,16 @@ +variable "project_id" { + description = "GCP project ID where the state bucket will be created" + type = string +} + +variable "gcp_region" { + description = "Region for the state bucket (e.g. us-central1)" + type = string + default = "us-central1" +} + +variable "state_bucket_name" { + description = "Globally unique GCS bucket name for Terraform state" + type = string + default = "opencontext-terraform-state-gcp" +} diff --git a/terraform/gcp/bootstrap/versions.tf b/terraform/gcp/bootstrap/versions.tf new file mode 100644 index 0000000..c7c93a8 --- /dev/null +++ b/terraform/gcp/bootstrap/versions.tf @@ -0,0 +1,19 @@ +terraform { + required_version = ">= 1.0" + + backend "local" { + path = "terraform.tfstate" + } + + required_providers { + google = { + source = "hashicorp/google" + version = "~> 5.0" + } + } +} + +provider "google" { + project = var.project_id + region = var.gcp_region +} diff --git a/terraform/gcp/main.tf b/terraform/gcp/main.tf new file mode 100644 index 0000000..65098a4 --- /dev/null +++ b/terraform/gcp/main.tf @@ -0,0 +1,129 @@ +locals { + config = yamldecode(file(var.config_file)) + + gcp_cfg = try(local.config.gcp, {}) + + function_name = var.function_name != "" ? var.function_name : ( + try(local.gcp_cfg.function_name, "") != "" ? local.gcp_cfg.function_name : ( + local.config.server_name != "" ? lower(replace(local.config.server_name, " ", "-")) : "opencontext-mcp-server" + ) + ) + + memory_mb = try(local.gcp_cfg.function_memory_mb, null) != null ? local.gcp_cfg.function_memory_mb : var.function_memory_mb + timeout_sec = try(local.gcp_cfg.function_timeout_sec, null) != null ? local.gcp_cfg.function_timeout_sec : var.function_timeout_sec + min_instances = try(local.gcp_cfg.min_instance_count, null) != null ? local.gcp_cfg.min_instance_count : var.min_instance_count + max_instances = try(local.gcp_cfg.max_instance_count, null) != null ? local.gcp_cfg.max_instance_count : var.max_instance_count + + config_json = jsonencode(local.config) + + artifact_bucket = var.artifact_bucket_name != "" ? var.artifact_bucket_name : "${var.project_id}-opencontext-fn-${var.stage_name}" + + zip_path = "${path.module}/gcf-deployment.zip" + # Include the function name in the source object so deployed artifacts are + # easy to map back to a specific Cloud Function/environment. + zip_object_name = "${local.function_name}-source-${filebase64sha256(local.zip_path)}.zip" +} + +resource "google_project_service" "required_apis" { + for_each = toset([ + "cloudfunctions.googleapis.com", + "cloudbuild.googleapis.com", + "run.googleapis.com", + "artifactregistry.googleapis.com", + "storage.googleapis.com", + ]) + + project = var.project_id + service = each.key + disable_on_destroy = false +} + +resource "google_storage_bucket" "function_source" { + name = local.artifact_bucket + location = var.gcp_region + uniform_bucket_level_access = true + force_destroy = true + + versioning { + enabled = true + } + + labels = { + project = "opencontext" + environment = var.stage_name + managed_by = "terraform" + } + + depends_on = [google_project_service.required_apis] +} + +resource "google_storage_bucket_object" "function_zip" { + name = local.zip_object_name + bucket = google_storage_bucket.function_source.name + source = local.zip_path + + depends_on = [google_storage_bucket.function_source] +} + +resource "google_service_account" "function" { + # Stable unique id (GCP: 6–30 chars, start with letter) + account_id = "ocfn-${substr(md5("${var.project_id}-${local.function_name}"), 0, 20)}" + display_name = "OpenContext MCP ${local.function_name} (${var.stage_name})" + description = "Runtime identity for OpenContext Cloud Functions gen2" +} + +resource "google_cloudfunctions2_function" "mcp" { + name = local.function_name + location = var.gcp_region + description = "OpenContext MCP HTTP server (Cloud Functions gen2)" + + build_config { + runtime = "python311" + entry_point = "mcp_http" + source { + storage_source { + bucket = google_storage_bucket.function_source.name + object = google_storage_bucket_object.function_zip.name + } + } + } + + service_config { + max_instance_count = local.max_instances + min_instance_count = local.min_instances + available_memory = "${local.memory_mb}Mi" + timeout_seconds = local.timeout_sec + service_account_email = google_service_account.function.email + ingress_settings = "ALLOW_ALL" + environment_variables = { + OPENCONTEXT_CONFIG = local.config_json + } + all_traffic_on_latest_revision = true + } + + labels = { + project = "opencontext" + environment = var.stage_name + managed_by = "terraform" + } + + depends_on = [ + google_project_service.required_apis, + google_service_account.function, + ] + + lifecycle { + ignore_changes = [ + build_config[0].source[0].storage_source[0].generation, + ] + } +} + +# Gen2 functions run on Cloud Run — public HTTPS requires run.invoker on the service +resource "google_cloud_run_v2_service_iam_member" "public_invoker" { + project = var.project_id + location = var.gcp_region + name = google_cloudfunctions2_function.mcp.name + role = "roles/run.invoker" + member = "allUsers" +} diff --git a/terraform/gcp/outputs.tf b/terraform/gcp/outputs.tf new file mode 100644 index 0000000..811132c --- /dev/null +++ b/terraform/gcp/outputs.tf @@ -0,0 +1,29 @@ +output "function_name" { + description = "Cloud Functions gen2 function name" + value = google_cloudfunctions2_function.mcp.name +} + +output "function_uri" { + description = "HTTPS URL of the Cloud Function (Cloud Run–backed)" + value = google_cloudfunctions2_function.mcp.url +} + +output "mcp_endpoint_url" { + description = "MCP JSON-RPC endpoint (POST /mcp)" + value = "${google_cloudfunctions2_function.mcp.url}/mcp" +} + +output "function_service_account" { + description = "Service account email used by the function" + value = google_service_account.function.email +} + +output "source_bucket" { + description = "GCS bucket holding deployment zip artifacts" + value = google_storage_bucket.function_source.name +} + +output "source_object" { + description = "Object name of the deployed zip in the source bucket" + value = google_storage_bucket_object.function_zip.name +} diff --git a/terraform/gcp/variables.tf b/terraform/gcp/variables.tf new file mode 100644 index 0000000..05f7d21 --- /dev/null +++ b/terraform/gcp/variables.tf @@ -0,0 +1,58 @@ +variable "project_id" { + description = "GCP project ID for OpenContext deployment" + type = string +} + +variable "gcp_region" { + description = "GCP region (e.g. us-central1)" + type = string + default = "us-central1" +} + +variable "config_file" { + description = "Path to config.yaml (relative to this module or absolute)" + type = string + default = "../../config.yaml" +} + +variable "function_name" { + description = "Cloud Function name (empty = derive from config.yaml server_name or gcp.function_name)" + type = string + default = "" +} + +variable "function_memory_mb" { + description = "Memory for the Cloud Function (MiB)" + type = number + default = 512 +} + +variable "function_timeout_sec" { + description = "Timeout in seconds (max 3600 for Cloud Functions gen2)" + type = number + default = 120 +} + +variable "min_instance_count" { + description = "Minimum warm instances for Cloud Functions gen2 autoscaling" + type = number + default = 0 +} + +variable "max_instance_count" { + description = "Maximum instances for Cloud Functions gen2 autoscaling" + type = number + default = 10 +} + +variable "stage_name" { + description = "Environment label (e.g. staging, prod) — used in labels and resource names" + type = string + default = "staging" +} + +variable "artifact_bucket_name" { + description = "Optional: GCS bucket name for function source zips (empty = auto-generated from project + stage)" + type = string + default = "" +} diff --git a/terraform/gcp/versions.tf b/terraform/gcp/versions.tf new file mode 100644 index 0000000..f2ef1a9 --- /dev/null +++ b/terraform/gcp/versions.tf @@ -0,0 +1,15 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + google = { + source = "hashicorp/google" + version = "~> 5.0" + } + } +} + +provider "google" { + project = var.project_id + region = var.gcp_region +} diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..2625d9a --- /dev/null +++ b/tests/README.md @@ -0,0 +1,25 @@ +# OpenContext test layout + +Tests are grouped by intent (not only by component): + +| Directory | Role | +|-----------|------| +| [`tests/unit/`](unit/) | Isolated logic; mocks for I/O | +| [`tests/integration/`](integration/) | Hermetic cross-boundary flows (HTTP ↔ MCP ↔ plugins, Lambda adapter, CLI smoke server, Terraform contract checks) | +| [`tests/security/`](security/) | SSRF, SQL/SoQL injection guards | +| [`tests/smoke/`](smoke/) | Minimal CLI/protocol smoke checks | + +Pytest markers (`pyproject.toml`): `unit`, `integration`, `security`, `smoke`. + +Hermetic integration tests use the in-repo plugin [`custom_plugins/integration_test_fake/`](../custom_plugins/integration_test_fake/) (always disabled in real configs; enabled only via test fixtures / `OPENCONTEXT_CONFIG` JSON). + +Examples (always via `uv`): + +```bash +uv run pytest tests/ +uv run pytest tests/integration -m integration +uv run pytest tests/unit -m unit +uv run pytest tests/security -m security +``` + +Shared stubs/fixtures: [`conftest.py`](conftest.py). diff --git a/tests/arcgis/__init__.py b/tests/arcgis/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/ckan/__init__.py b/tests/ckan/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/test_cli_configure.py b/tests/integration/cli/test_cli_configure.py similarity index 100% rename from tests/test_cli_configure.py rename to tests/integration/cli/test_cli_configure.py diff --git a/tests/test_cli_configure_extended.py b/tests/integration/cli/test_cli_configure_extended.py similarity index 79% rename from tests/test_cli_configure_extended.py rename to tests/integration/cli/test_cli_configure_extended.py index 465cd39..9bce037 100644 --- a/tests/test_cli_configure_extended.py +++ b/tests/integration/cli/test_cli_configure_extended.py @@ -395,3 +395,108 @@ def test_socrata_exits_when_city_name_none(self): with pytest.raises((SystemExit, TypeError, Exception)): _prompt_plugin_config("Socrata", {}) + + +class TestConfigureWizardGCP: + @patch("cli.commands.configure.subprocess.run") + @patch("cli.commands.configure.run_cmd") + @patch("cli.commands.configure.get_project_root") + @patch("cli.commands.configure.get_terraform_dir") + @patch("cli.commands.configure.questionary") + def test_gcp_wizard_writes_tfvars_with_autoscaling( + self, + mock_q, + mock_tf_dir, + mock_root, + mock_run_cmd, + mock_subproc, + tmp_path, + ): + from cli.commands.configure import configure + + tf_dir = tmp_path / "terraform" / "gcp" + tf_dir.mkdir(parents=True) + mock_root.return_value = tmp_path + mock_tf_dir.return_value = tf_dir + + mock_q.select.side_effect = _mock_q(["Start from scratch", "staging", "CKAN"]) + mock_q.text.side_effect = _mock_q( + [ + "City of Boston", # org_name + "Boston", # city_name + "https://data.boston.gov", # ckan base_url + "https://data.boston.gov", # ckan portal_url + "Boston", # plugin city_name + "120", # plugin timeout + "us-central1", # gcp region + "my-gcp-project", # gcp project id + "boston-mcp-staging", # function name + "512", # memory + "120", # timeout + "2", # min instances + "25", # max instances + "boston-opencontext-artifacts", # artifact bucket + ] + ) + + mock_subproc.return_value = subprocess.CompletedProcess( + args=[], returncode=0, stdout=" default\n", stderr="" + ) + mock_run_cmd.return_value = MagicMock(returncode=0) + + configure(cloud="gcp") + + tfvars_file = tf_dir / "staging.tfvars" + content = tfvars_file.read_text() + assert 'project_id = "my-gcp-project"' in content + assert "min_instance_count = 2" in content + assert "max_instance_count = 25" in content + + @patch("cli.commands.configure.subprocess.run") + @patch("cli.commands.configure.run_cmd") + @patch("cli.commands.configure.get_project_root") + @patch("cli.commands.configure.get_terraform_dir") + @patch("cli.commands.configure.questionary") + def test_gcp_min_greater_than_max_exits( + self, + mock_q, + mock_tf_dir, + mock_root, + mock_run_cmd, + mock_subproc, + tmp_path, + ): + from cli.commands.configure import configure + + tf_dir = tmp_path / "terraform" / "gcp" + tf_dir.mkdir(parents=True) + mock_root.return_value = tmp_path + mock_tf_dir.return_value = tf_dir + + mock_q.select.side_effect = _mock_q(["Start from scratch", "staging", "CKAN"]) + mock_q.text.side_effect = _mock_q( + [ + "City of Boston", + "Boston", + "https://data.boston.gov", + "https://data.boston.gov", + "Boston", + "120", + "us-central1", + "my-gcp-project", + "boston-mcp-staging", + "512", + "120", + "10", # min + "2", # max + "", # artifact bucket + ] + ) + + mock_subproc.return_value = subprocess.CompletedProcess( + args=[], returncode=0, stdout=" default\n", stderr="" + ) + mock_run_cmd.return_value = MagicMock(returncode=0) + + with pytest.raises((SystemExit, click.exceptions.Exit)): + configure(cloud="gcp") diff --git a/tests/test_cli_deploy.py b/tests/integration/cli/test_cli_deploy.py similarity index 100% rename from tests/test_cli_deploy.py rename to tests/integration/cli/test_cli_deploy.py diff --git a/tests/test_cli_deploy_extended.py b/tests/integration/cli/test_cli_deploy_extended.py similarity index 100% rename from tests/test_cli_deploy_extended.py rename to tests/integration/cli/test_cli_deploy_extended.py diff --git a/tests/test_cli_destroy.py b/tests/integration/cli/test_cli_destroy.py similarity index 100% rename from tests/test_cli_destroy.py rename to tests/integration/cli/test_cli_destroy.py diff --git a/tests/test_cli_domain.py b/tests/integration/cli/test_cli_domain.py similarity index 100% rename from tests/test_cli_domain.py rename to tests/integration/cli/test_cli_domain.py diff --git a/tests/test_cli_domain_extended.py b/tests/integration/cli/test_cli_domain_extended.py similarity index 100% rename from tests/test_cli_domain_extended.py rename to tests/integration/cli/test_cli_domain_extended.py diff --git a/tests/test_cli_logs.py b/tests/integration/cli/test_cli_logs.py similarity index 100% rename from tests/test_cli_logs.py rename to tests/integration/cli/test_cli_logs.py diff --git a/tests/test_cli_logs_extended.py b/tests/integration/cli/test_cli_logs_extended.py similarity index 100% rename from tests/test_cli_logs_extended.py rename to tests/integration/cli/test_cli_logs_extended.py diff --git a/tests/test_cli_serve.py b/tests/integration/cli/test_cli_serve.py similarity index 100% rename from tests/test_cli_serve.py rename to tests/integration/cli/test_cli_serve.py diff --git a/tests/integration/cli/test_cli_test_against_local_server.py b/tests/integration/cli/test_cli_test_against_local_server.py new file mode 100644 index 0000000..e69a970 --- /dev/null +++ b/tests/integration/cli/test_cli_test_against_local_server.py @@ -0,0 +1,26 @@ +"""Hermetic integration: CLI MCP probe against local aiohttp server.""" + +from __future__ import annotations + +import asyncio + +import pytest + +from cli.commands.test import _run_tests + +from tests.integration.local_server import start_local_mcp_server + + +@pytest.mark.asyncio +async def test_run_tests_protocol_against_local_server( + integration_fake_config_dict: dict, +) -> None: + base_url, shutdown = await start_local_mcp_server(integration_fake_config_dict) + try: + # _run_tests uses sync httpx; run it in a thread so the aiohttp loop stays responsive. + passed, total = await asyncio.to_thread(_run_tests, base_url) + finally: + await shutdown() + + assert total == 4 + assert passed == 4 diff --git a/tests/test_cli_validate.py b/tests/integration/cli/test_cli_validate.py similarity index 100% rename from tests/test_cli_validate.py rename to tests/integration/cli/test_cli_validate.py diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..669b10a --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,58 @@ +"""Fixtures for hermetic integration tests.""" + +from __future__ import annotations + +import json +from typing import Any, Dict, Generator + +import pytest + +pytestmark = pytest.mark.integration + + +@pytest.fixture +def integration_fake_config_dict() -> Dict[str, Any]: + """Minimal valid config with only the fake integration plugin enabled.""" + return { + "server_name": "Integration Test MCP", + "organization": "Test Org", + "plugins": { + "integration_test_fake": {"enabled": True}, + "ckan": {"enabled": False}, + "arcgis": {"enabled": False}, + "socrata": {"enabled": False}, + }, + "aws": {"region": "us-east-1"}, + "logging": {"level": "WARNING", "format": "json"}, + } + + +@pytest.fixture +def opencontext_config_env( + integration_fake_config_dict: Dict[str, Any], monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("OPENCONTEXT_CONFIG", json.dumps(integration_fake_config_dict)) + + +@pytest.fixture +def reset_http_handler_globals() -> Generator[None, None, None]: + """Clear UniversalHTTPHandler module singletons between tests.""" + import server.http_handler as hh + + hh._config = None + hh._plugin_manager = None + hh._mcp_server = None + yield + hh._config = None + hh._plugin_manager = None + hh._mcp_server = None + + +@pytest.fixture +def reset_lambda_adapter_handler() -> Generator[None, None, None]: + """Reset Lambda adapter handler singleton.""" + import server.adapters.aws_lambda as lam + + lam._handler = None + yield + lam._handler = None diff --git a/tests/integration/local_server.py b/tests/integration/local_server.py new file mode 100644 index 0000000..dbe5403 --- /dev/null +++ b/tests/integration/local_server.py @@ -0,0 +1,83 @@ +"""Ephemeral aiohttp MCP server for CLI integration tests.""" + +from __future__ import annotations + +import json +import time +import uuid +from typing import Any, Awaitable, Callable, Dict, Tuple + +from aiohttp import web + +from core.mcp_server import MCPServer +from core.plugin_manager import PluginManager + + +async def start_local_mcp_server( + config: Dict[str, Any], +) -> Tuple[str, Callable[[], Awaitable[None]]]: + """Start POST /mcp on a random port; return (base_url_without_path, shutdown_coro).""" + plugin_manager = PluginManager(config) + await plugin_manager.load_plugins() + mcp_server = MCPServer(plugin_manager) + + async def handle_mcp_request(request: web.Request) -> web.StreamResponse: + start_time = time.perf_counter() + try: + body = await request.text() + headers = dict(request.headers) + + try: + request_json = json.loads(body) + method = request_json.get("method", "unknown") + except (json.JSONDecodeError, AttributeError): + method = "unknown" + + is_initialize = method == "initialize" + session_id_to_return = None + if is_initialize: + session_id_to_return = str(uuid.uuid4()) + + response = await mcp_server.handle_http_request(body, headers) + + response_headers = dict(response.get("headers", {})) + if session_id_to_return: + response_headers["Mcp-Session-Id"] = session_id_to_return + + duration_ms = (time.perf_counter() - start_time) * 1000 + _ = duration_ms + + return web.Response( + text=response.get("body", "{}"), + status=response.get("statusCode", 200), + headers=response_headers, + ) + except Exception as e: + return web.Response( + text=json.dumps( + { + "jsonrpc": "2.0", + "id": None, + "error": {"code": -32603, "message": str(e)}, + } + ), + status=500, + headers={"Content-Type": "application/json"}, + ) + + app = web.Application() + app.router.add_post("/mcp", handle_mcp_request) + + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, "127.0.0.1", 0) + await site.start() + + host, port = runner.addresses[0] + base_url = f"http://{host}:{port}" + + async def shutdown() -> None: + await plugin_manager.shutdown() + await runner.cleanup() + + return base_url, shutdown diff --git a/tests/integration/test_lambda_adapter_pipeline.py b/tests/integration/test_lambda_adapter_pipeline.py new file mode 100644 index 0000000..edf2fda --- /dev/null +++ b/tests/integration/test_lambda_adapter_pipeline.py @@ -0,0 +1,90 @@ +"""Hermetic integration: AWS Lambda adapter event shapes.""" + +from __future__ import annotations + +import base64 +import json +from typing import Any +from unittest.mock import MagicMock + +from server.adapters.aws_lambda import lambda_handler + + +def test_apigw_proxy_event_base64_body_round_trip( + opencontext_config_env: None, + reset_http_handler_globals: None, + reset_lambda_adapter_handler: None, +) -> None: + ctx = MagicMock() + ctx.aws_request_id = "req-integration-1" + ctx.function_name = "fn" + ctx.memory_limit_in_mb = 128 + + payload = {"jsonrpc": "2.0", "id": 1, "method": "ping"} + raw = json.dumps(payload).encode() + event: dict[str, Any] = { + "httpMethod": "POST", + "path": "/mcp", + "headers": {"Content-Type": "application/json"}, + "body": base64.b64encode(raw).decode("ascii"), + "isBase64Encoded": True, + } + + resp = lambda_handler(event, ctx) + assert resp["statusCode"] == 200 + body = json.loads(resp["body"]) + assert body["result"] == {"status": "ok"} + assert resp["headers"].get("Access-Control-Allow-Origin") == "*" + + +def test_options_preflight( + opencontext_config_env: None, + reset_http_handler_globals: None, + reset_lambda_adapter_handler: None, +) -> None: + ctx = MagicMock() + ctx.aws_request_id = "req-integration-2" + + event = { + "httpMethod": "OPTIONS", + "path": "/mcp", + "headers": {}, + } + resp = lambda_handler(event, ctx) + assert resp["statusCode"] == 200 + assert resp["body"] == "" + assert "Access-Control-Allow-Methods" in resp["headers"] + + +def test_sequential_invocations_reinitialize_cleanly( + opencontext_config_env: None, + reset_http_handler_globals: None, + reset_lambda_adapter_handler: None, +) -> None: + """Lambda adapter asyncio.run + shutdown should allow a second invocation.""" + ctx = MagicMock() + ctx.aws_request_id = "req-integration-3" + + def make_event(rid: int) -> dict[str, Any]: + return { + "httpMethod": "POST", + "path": "/mcp", + "headers": {"content-type": "application/json"}, + "body": json.dumps( + { + "jsonrpc": "2.0", + "id": rid, + "method": "tools/list", + } + ), + } + + ctx.aws_request_id = "a" + r1 = lambda_handler(make_event(1), ctx) + assert r1["statusCode"] == 200 + + ctx.aws_request_id = "b" + r2 = lambda_handler(make_event(2), ctx) + assert r2["statusCode"] == 200 + tools = json.loads(r2["body"])["result"]["tools"] + assert any(t["name"] == "integration_test_fake__echo" for t in tools) diff --git a/tests/integration/test_mcp_http_pipeline.py b/tests/integration/test_mcp_http_pipeline.py new file mode 100644 index 0000000..0ed47f1 --- /dev/null +++ b/tests/integration/test_mcp_http_pipeline.py @@ -0,0 +1,112 @@ +"""Hermetic integration: UniversalHTTPHandler + real PluginManager + fake plugin.""" + +from __future__ import annotations + +import json + +import pytest + +from server.http_handler import UniversalHTTPHandler + + +@pytest.mark.asyncio +async def test_mcp_jsonrpc_pipeline_via_universal_handler( + opencontext_config_env: None, + reset_http_handler_globals: None, +) -> None: + handler = UniversalHTTPHandler() + + ping_body = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "ping"}) + status, headers, body = await handler.handle_request( + "POST", "/mcp", ping_body, {"content-type": "application/json"} + ) + assert status == 200 + assert "Access-Control-Allow-Origin" in headers + payload = json.loads(body) + assert payload["result"] == {"status": "ok"} + + init_body = json.dumps( + { + "jsonrpc": "2.0", + "id": 2, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "pytest", "version": "1"}, + }, + } + ) + status, headers, body = await handler.handle_request( + "POST", "/mcp", init_body, {"content-type": "application/json"} + ) + assert status == 200 + assert "Mcp-Session-Id" in headers + payload = json.loads(body) + assert "protocolVersion" in payload["result"] + + list_body = json.dumps({"jsonrpc": "2.0", "id": 3, "method": "tools/list"}) + status, _, body = await handler.handle_request( + "POST", "/mcp", list_body, {"content-type": "application/json"} + ) + assert status == 200 + tools_payload = json.loads(body) + tools = tools_payload["result"]["tools"] + names = {t["name"] for t in tools} + assert "integration_test_fake__echo" in names + assert "integration_test_fake__fail_me" in names + + call_body = json.dumps( + { + "jsonrpc": "2.0", + "id": 4, + "method": "tools/call", + "params": { + "name": "integration_test_fake__echo", + "arguments": {"msg": "hello-integration"}, + }, + } + ) + status, _, body = await handler.handle_request( + "POST", "/mcp", call_body, {"content-type": "application/json"} + ) + assert status == 200 + call_payload = json.loads(body) + content = call_payload["result"]["content"] + assert any(c.get("text") == "hello-integration" for c in content) + + fail_body = json.dumps( + { + "jsonrpc": "2.0", + "id": 5, + "method": "tools/call", + "params": { + "name": "integration_test_fake__fail_me", + "arguments": {}, + }, + } + ) + status, _, body = await handler.handle_request( + "POST", "/mcp", fail_body, {"content-type": "application/json"} + ) + assert status == 200 + fail_payload = json.loads(body) + assert fail_payload["result"].get("isError") is True + assert "integration fake failure" in fail_payload["result"].get("error", "") + + +@pytest.mark.asyncio +async def test_wrong_path_returns_404( + opencontext_config_env: None, + reset_http_handler_globals: None, +) -> None: + handler = UniversalHTTPHandler() + status, _, body = await handler.handle_request( + "POST", + "/wrong", + json.dumps({"jsonrpc": "2.0", "id": 1, "method": "ping"}), + {}, + ) + assert status == 404 + err = json.loads(body)["error"] + assert err["message"] == "Not Found" diff --git a/tests/integration/test_plugin_namespace_contract.py b/tests/integration/test_plugin_namespace_contract.py new file mode 100644 index 0000000..9c6bd77 --- /dev/null +++ b/tests/integration/test_plugin_namespace_contract.py @@ -0,0 +1,43 @@ +"""Hermetic integration: PluginManager discovery and namespaced tools.""" + +from __future__ import annotations + +import pytest + +from core.plugin_manager import PluginManager + + +@pytest.mark.asyncio +async def test_load_fake_plugin_registers_prefixed_tools( + integration_fake_config_dict: dict, +) -> None: + pm = PluginManager(integration_fake_config_dict) + await pm.load_plugins() + + names = {t["name"] for t in pm.get_all_tools()} + assert "integration_test_fake__echo" in names + assert pm.tools["integration_test_fake__echo"] == ( + "integration_test_fake", + "echo", + ) + + result = await pm.execute_tool( + "integration_test_fake__echo", {"msg": "namespace-ok"} + ) + assert result.success is True + await pm.shutdown() + + +@pytest.mark.asyncio +async def test_configuration_error_when_multiple_plugins_enabled( + integration_fake_config_dict: dict, +) -> None: + from core.validators import ConfigurationError + + bad = dict(integration_fake_config_dict) + bad["plugins"] = dict(bad["plugins"]) + bad["plugins"]["ckan"] = {"enabled": True, "base_url": "https://x.example"} + + pm = PluginManager(bad) + with pytest.raises(ConfigurationError): + await pm.load_plugins() diff --git a/tests/integration/test_terraform_contract.py b/tests/integration/test_terraform_contract.py new file mode 100644 index 0000000..fcb6336 --- /dev/null +++ b/tests/integration/test_terraform_contract.py @@ -0,0 +1,64 @@ +"""Hermetic integration: Terraform module declares CLI-required outputs and variables.""" + +from __future__ import annotations + +import re +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +TF_AWS = REPO_ROOT / "terraform" / "aws" +TF_GCP = REPO_ROOT / "terraform" / "gcp" + + +def _output_names(terraform_dir: Path) -> set[str]: + text = (terraform_dir / "outputs.tf").read_text(encoding="utf-8") + return set(re.findall(r'output\s+"([^"]+)"', text)) + + +def _variable_names(terraform_dir: Path) -> set[str]: + text = (terraform_dir / "variables.tf").read_text(encoding="utf-8") + return set(re.findall(r'variable\s+"([^"]+)"', text)) + + +def test_aws_outputs_include_api_gateway_url_and_lambda_name() -> None: + output_names = _output_names(TF_AWS) + assert "api_gateway_url" in output_names + assert "lambda_function_name" in output_names + + +def test_aws_variables_include_cli_deploy_inputs() -> None: + var_names = _variable_names(TF_AWS) + for required in ( + "config_file", + "stage_name", + "lambda_memory", + "lambda_timeout", + "aws_region", + ): + assert required in var_names + + +def test_gcp_outputs_include_mcp_endpoint_and_function_identifiers() -> None: + output_names = _output_names(TF_GCP) + for required in ( + "mcp_endpoint_url", + "function_uri", + "function_name", + "source_bucket", + ): + assert required in output_names + + +def test_gcp_variables_include_cli_deploy_inputs() -> None: + var_names = _variable_names(TF_GCP) + for required in ( + "config_file", + "stage_name", + "project_id", + "gcp_region", + "function_memory_mb", + "function_timeout_sec", + "min_instance_count", + "max_instance_count", + ): + assert required in var_names diff --git a/tests/security/conftest.py b/tests/security/conftest.py new file mode 100644 index 0000000..471bf5a --- /dev/null +++ b/tests/security/conftest.py @@ -0,0 +1,7 @@ +"""Security-focused tests (SSRF, SQL/SoQL guards).""" + +from __future__ import annotations + +import pytest + +pytestmark = pytest.mark.security diff --git a/tests/arcgis/test_ssrf_protection.py b/tests/security/test_arcgis_ssrf_protection.py similarity index 100% rename from tests/arcgis/test_ssrf_protection.py rename to tests/security/test_arcgis_ssrf_protection.py diff --git a/tests/ckan/test_aggregate_data_security.py b/tests/security/test_ckan_aggregate_sql_guards.py similarity index 100% rename from tests/ckan/test_aggregate_data_security.py rename to tests/security/test_ckan_aggregate_sql_guards.py diff --git a/tests/test_socrata_soql_validator.py b/tests/security/test_soql_validator.py similarity index 100% rename from tests/test_socrata_soql_validator.py rename to tests/security/test_soql_validator.py diff --git a/tests/test_sql_validator.py b/tests/security/test_sql_validator.py similarity index 100% rename from tests/test_sql_validator.py rename to tests/security/test_sql_validator.py diff --git a/tests/test_cli_test_cmd.py b/tests/smoke/cli/test_mcp_endpoint_smoke.py similarity index 100% rename from tests/test_cli_test_cmd.py rename to tests/smoke/cli/test_mcp_endpoint_smoke.py diff --git a/tests/smoke/conftest.py b/tests/smoke/conftest.py new file mode 100644 index 0000000..d9605e7 --- /dev/null +++ b/tests/smoke/conftest.py @@ -0,0 +1,7 @@ +"""Fast smoke checks.""" + +from __future__ import annotations + +import pytest + +pytestmark = pytest.mark.smoke diff --git a/tests/test_gcp_functions.py b/tests/test_gcp_functions.py new file mode 100644 index 0000000..9bbb6ba --- /dev/null +++ b/tests/test_gcp_functions.py @@ -0,0 +1,68 @@ +"""Tests for Google Cloud Functions (gen2) HTTP adapter.""" + +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from flask import Request +from werkzeug.test import EnvironBuilder + +import server.adapters.gcp_functions as gcp_functions +from server.adapters.gcp_functions import mcp_http + + +@pytest.fixture(autouse=True) +def reset_gcp_handler(): + gcp_functions._handler = None + yield + gcp_functions._handler = None + + +def _make_request(method: str, path: str, body: str | None = None) -> Request: + kwargs: dict = {"method": method, "path": path} + if body is not None: + kwargs["data"] = body + kwargs["content_type"] = "application/json" + builder = EnvironBuilder(**kwargs) + return Request(builder.get_environ()) + + +class TestMcpHttp: + def test_options_returns_cors(self): + req = _make_request("OPTIONS", "/mcp") + with patch("server.adapters.gcp_functions.get_handler") as mock_get: + mock_h = MagicMock() + mock_h.handle_options.return_value = ( + 200, + {"Access-Control-Allow-Origin": "*"}, + "", + ) + mock_get.return_value = mock_h + + out = mcp_http(req) + + assert out[1] == 200 + mock_h.handle_options.assert_called_once() + + def test_post_invokes_handle_request(self): + body = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "ping", "params": {}}) + req = _make_request("POST", "/mcp", body) + + with patch("server.adapters.gcp_functions.get_handler") as mock_get: + mock_h = MagicMock() + mock_h.handle_request = AsyncMock( + return_value=( + 200, + {"Content-Type": "application/json"}, + json.dumps({"jsonrpc": "2.0", "id": 1, "result": {}}), + ) + ) + mock_get.return_value = mock_h + + out = mcp_http(req) + + assert out[1] == 200 + mock_h.handle_request.assert_called_once() + call = mock_h.handle_request.call_args[1] + assert call["method"] == "POST" + assert call["path"] == "/mcp" diff --git a/tests/test_cli_architecture.py b/tests/unit/cli/test_cli_architecture.py similarity index 100% rename from tests/test_cli_architecture.py rename to tests/unit/cli/test_cli_architecture.py diff --git a/tests/test_cli_authenticate.py b/tests/unit/cli/test_cli_authenticate.py similarity index 82% rename from tests/test_cli_authenticate.py rename to tests/unit/cli/test_cli_authenticate.py index 3877c0c..1741f0f 100644 --- a/tests/test_cli_authenticate.py +++ b/tests/unit/cli/test_cli_authenticate.py @@ -216,3 +216,48 @@ def avail_side_effect(cmd, timeout=10): authenticate() mock_auto.assert_called_once() + + +class TestAuthenticateGcp: + @patch("cli.commands.authenticate._is_available") + def test_gcp_all_pass(self, mock_avail): + def side_effect(cmd, timeout=10): + if cmd == ["uv", "--version"]: + return _ok("uv 0.9.0") + if cmd == ["gcloud", "--version"]: + return _ok("Google Cloud SDK 500.0.0\nalpha 2026.01.01") + if cmd == ["gcloud", "auth", "application-default", "print-access-token"]: + return _ok("ya29.sample-token") + if cmd == ["terraform", "--version"]: + return _ok("Terraform v1.5.0") + return None + + mock_avail.side_effect = side_effect + + from cli.commands.authenticate import authenticate + + with patch("cli.commands.authenticate.sys") as mock_sys: + mock_sys.version_info = (3, 11, 5, "final", 0) + authenticate(cloud="gcp") + + @patch("cli.commands.authenticate._is_available") + def test_gcp_missing_adc_fails(self, mock_avail): + def side_effect(cmd, timeout=10): + if cmd == ["uv", "--version"]: + return _ok("uv 0.9.0") + if cmd == ["gcloud", "--version"]: + return _ok("Google Cloud SDK 500.0.0") + if cmd == ["gcloud", "auth", "application-default", "print-access-token"]: + return None + if cmd == ["terraform", "--version"]: + return _ok("Terraform v1.5.0") + return None + + mock_avail.side_effect = side_effect + + from cli.commands.authenticate import authenticate + + with patch("cli.commands.authenticate.sys") as mock_sys: + mock_sys.version_info = (3, 11, 5, "final", 0) + with pytest.raises((SystemExit, click.exceptions.Exit)): + authenticate(cloud="gcp") diff --git a/tests/test_cli_cost.py b/tests/unit/cli/test_cli_cost.py similarity index 100% rename from tests/test_cli_cost.py rename to tests/unit/cli/test_cli_cost.py diff --git a/tests/test_cli_main_and_utils.py b/tests/unit/cli/test_cli_main_and_utils.py similarity index 100% rename from tests/test_cli_main_and_utils.py rename to tests/unit/cli/test_cli_main_and_utils.py diff --git a/tests/test_cli_plugin.py b/tests/unit/cli/test_cli_plugin.py similarity index 100% rename from tests/test_cli_plugin.py rename to tests/unit/cli/test_cli_plugin.py diff --git a/tests/test_cli_security.py b/tests/unit/cli/test_cli_security.py similarity index 100% rename from tests/test_cli_security.py rename to tests/unit/cli/test_cli_security.py diff --git a/tests/test_cli_upgrade.py b/tests/unit/cli/test_cli_upgrade.py similarity index 97% rename from tests/test_cli_upgrade.py rename to tests/unit/cli/test_cli_upgrade.py index 53502dd..b2cbb3e 100644 --- a/tests/test_cli_upgrade.py +++ b/tests/unit/cli/test_cli_upgrade.py @@ -32,6 +32,16 @@ def test_prod_tfvars_is_protected(self): assert _is_protected("terraform/aws/prod.tfvars") is True + def test_gcp_staging_tfvars_is_protected(self): + from cli.commands.upgrade import _is_protected + + assert _is_protected("terraform/gcp/staging.tfvars") is True + + def test_gcp_prod_tfvars_is_protected(self): + from cli.commands.upgrade import _is_protected + + assert _is_protected("terraform/gcp/prod.tfvars") is True + def test_examples_prefix_is_protected(self): from cli.commands.upgrade import _is_protected diff --git a/tests/test_cli_utils.py b/tests/unit/cli/test_cli_utils.py similarity index 100% rename from tests/test_cli_utils.py rename to tests/unit/cli/test_cli_utils.py diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py new file mode 100644 index 0000000..3213be5 --- /dev/null +++ b/tests/unit/conftest.py @@ -0,0 +1,7 @@ +"""Unit-test subtree markers.""" + +from __future__ import annotations + +import pytest + +pytestmark = pytest.mark.unit diff --git a/tests/test_config_validation.py b/tests/unit/core/test_config_validation.py similarity index 100% rename from tests/test_config_validation.py rename to tests/unit/core/test_config_validation.py diff --git a/tests/test_mcp_server.py b/tests/unit/core/test_mcp_server.py similarity index 100% rename from tests/test_mcp_server.py rename to tests/unit/core/test_mcp_server.py diff --git a/tests/test_plugin_manager.py b/tests/unit/core/test_plugin_manager.py similarity index 96% rename from tests/test_plugin_manager.py rename to tests/unit/core/test_plugin_manager.py index 46f3a44..9a80c28 100644 --- a/tests/test_plugin_manager.py +++ b/tests/unit/core/test_plugin_manager.py @@ -655,3 +655,31 @@ class RegularClass: assert "does not define a class" in str(exc_info.value).lower() mock_import.assert_called_once_with("plugins.test_plugin.plugin") # Clean up any imported modules + + +class TestCustomPluginsModulePath: + """Regression: custom_plugins/ paths must not match the builtin plugins/ rule.""" + + def test_load_plugin_class_imports_custom_plugins_package(self) -> None: + from types import ModuleType + + from core.interfaces import MCPPlugin + + config = {"plugins": {"my_custom": {"enabled": True}}} + manager = PluginManager(config) + plugin_dir = Path("/repo/OpenContext/custom_plugins/my_custom") + + mock_module = ModuleType("custom_plugins.my_custom.plugin") + + class DummyPlugin(MCPPlugin): + plugin_name = "my_custom" + + DummyPlugin.__module__ = mock_module.__name__ + mock_module.DummyPlugin = DummyPlugin + + with patch("core.plugin_manager.importlib.import_module") as mock_import: + mock_import.return_value = mock_module + loaded = manager._load_plugin_class("my_custom", plugin_dir) + + assert loaded is DummyPlugin + mock_import.assert_called_once_with("custom_plugins.my_custom.plugin") diff --git a/tests/test_arcgis_plugin.py b/tests/unit/plugins/arcgis/test_arcgis_plugin.py similarity index 83% rename from tests/test_arcgis_plugin.py rename to tests/unit/plugins/arcgis/test_arcgis_plugin.py index 645b74a..5fe2872 100644 --- a/tests/test_arcgis_plugin.py +++ b/tests/unit/plugins/arcgis/test_arcgis_plugin.py @@ -349,65 +349,4 @@ def test_config_schema_rejects_invalid_url(self): ) -# ── _validate_feature_url ────────────────────────────────────────────── - - -class TestValidateFeatureUrl: - PORTAL_URL = "https://hub.arcgis.com" - - def test_allows_arcgis_com_subdomain(self): - url = "https://services.arcgis.com/xyz/FeatureServer/0" - result = ArcGISPlugin._validate_feature_url(url, self.PORTAL_URL) - assert result == url - - def test_allows_configured_portal_host(self): - url = "https://hub.arcgis.com/datasets/abc/FeatureServer/0" - result = ArcGISPlugin._validate_feature_url(url, self.PORTAL_URL) - assert result == url - - def test_rejects_aws_metadata_url(self): - with pytest.raises(ValueError, match="not within allowed domains"): - ArcGISPlugin._validate_feature_url( - "http://169.254.169.254/latest/meta-data/", - self.PORTAL_URL, - ) - - def test_rejects_arbitrary_external_host(self): - with pytest.raises(ValueError, match="not within allowed domains"): - ArcGISPlugin._validate_feature_url( - "https://evil.com/steal/data", - self.PORTAL_URL, - ) - - def test_rejects_non_http_scheme(self): - with pytest.raises(ValueError, match="invalid scheme"): - ArcGISPlugin._validate_feature_url( - "ftp://services.arcgis.com/xyz/FeatureServer/0", - self.PORTAL_URL, - ) - - -class TestQueryDataSSRFGuard: - @pytest.mark.asyncio - async def test_query_data_rejects_disallowed_service_url(self, arcgis_config): - """query_data raises ValueError for a disallowed service_url before any HTTP call.""" - plugin = ArcGISPlugin(arcgis_config) - plugin.plugin_config = ArcGISPluginConfig(**arcgis_config) - - mock_feature_client = AsyncMock() - plugin.feature_client = mock_feature_client - - with patch.object( - plugin, - "get_dataset", - new_callable=AsyncMock, - return_value={ - "id": "abc123", - "title": "Malicious Dataset", - "service_url": "https://evil.com/steal/FeatureServer/0", - }, - ): - with pytest.raises(ValueError, match="not within allowed domains"): - await plugin.query_data("abc123", {"where": "1=1"}, 100) - - mock_feature_client.get.assert_not_called() +# SSRF / feature URL validation tests live in tests/security/test_arcgis_ssrf_protection.py diff --git a/tests/test_arcgis_plugin_extended.py b/tests/unit/plugins/arcgis/test_plugin_extended.py similarity index 100% rename from tests/test_arcgis_plugin_extended.py rename to tests/unit/plugins/arcgis/test_plugin_extended.py diff --git a/tests/test_ckan_plugin.py b/tests/unit/plugins/ckan/test_ckan_plugin.py similarity index 100% rename from tests/test_ckan_plugin.py rename to tests/unit/plugins/ckan/test_ckan_plugin.py diff --git a/tests/test_socrata_plugin.py b/tests/unit/plugins/socrata/test_socrata_plugin.py similarity index 100% rename from tests/test_socrata_plugin.py rename to tests/unit/plugins/socrata/test_socrata_plugin.py diff --git a/tests/test_aws_lambda.py b/tests/unit/server/test_aws_lambda_adapter.py similarity index 100% rename from tests/test_aws_lambda.py rename to tests/unit/server/test_aws_lambda_adapter.py diff --git a/tests/test_http_handler.py b/tests/unit/server/test_http_handler.py similarity index 100% rename from tests/test_http_handler.py rename to tests/unit/server/test_http_handler.py diff --git a/tests/test_lambda_handler.py b/tests/unit/server/test_server_lambda_handler.py similarity index 100% rename from tests/test_lambda_handler.py rename to tests/unit/server/test_server_lambda_handler.py diff --git a/uv.lock b/uv.lock index 5b7a12a..ddd5596 100644 --- a/uv.lock +++ b/uv.lock @@ -166,6 +166,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, ] +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + [[package]] name = "boolean-py" version = "5.0" @@ -324,6 +333,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, ] +[[package]] +name = "cloudevents" +version = "1.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecation" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/aa/804bdb5f2f021fcc887eeabfa24bad0ffd4b150f60850ae88faa51d393a5/cloudevents-1.12.0.tar.gz", hash = "sha256:ebd5544ceb58c8378a0787b657a2ae895e929b80a82d6675cba63f0e8c5539e0", size = 34494, upload-time = "2025-06-02T18:58:45.104Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/b6/4e29b74bb40daa7580310a5ff0df5f121a08ce98340e01a960b668468aab/cloudevents-1.12.0-py3-none-any.whl", hash = "sha256:49196267f5f963d87ae156f93fc0fa32f4af69485f2c8e62e0db8b0b4b8b8921", size = 55762, upload-time = "2025-06-02T18:58:44.013Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -462,6 +483,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, ] +[[package]] +name = "deprecation" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/d3/8ae2869247df154b64c1884d7346d412fed0c49df84db635aab2d1c40e62/deprecation-2.1.0.tar.gz", hash = "sha256:72b3bde64e5d778694b0cf68178aed03d15e15477116add3fb773e581f9518ff", size = 173788, upload-time = "2020-04-20T14:23:38.738Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/c3/253a89ee03fc9b9682f1541728eb66db7db22148cd94f89ab22528cd1e1b/deprecation-2.1.0-py2.py3-none-any.whl", hash = "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a", size = 11178, upload-time = "2020-04-20T14:23:36.581Z" }, +] + [[package]] name = "distlib" version = "0.4.0" @@ -489,6 +522,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/04/a94ebfb4eaaa08db56725a40de2887e95de4e8641b9e902c311bfa00aa39/filelock-3.24.2-py3-none-any.whl", hash = "sha256:667d7dc0b7d1e1064dd5f8f8e80bdac157a6482e8d2e02cd16fd3b6b33bd6556", size = 24152, upload-time = "2026-02-16T02:50:44Z" }, ] +[[package]] +name = "flask" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, +] + [[package]] name = "frozenlist" version = "1.8.0" @@ -594,6 +644,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, ] +[[package]] +name = "functions-framework" +version = "3.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "cloudevents" }, + { name = "flask" }, + { name = "gunicorn", marker = "sys_platform != 'win32'" }, + { name = "starlette" }, + { name = "uvicorn" }, + { name = "uvicorn-worker" }, + { name = "watchdog" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/4e/63875081ead5db522d33d05fcc5bafad73944da841755ef8a1dc5d872b80/functions_framework-3.10.1.tar.gz", hash = "sha256:e60174022fc1b293dd0a33f1c6894dabd44852c0d440a51e7defd198e8d05ca5", size = 54148, upload-time = "2026-02-17T20:39:43.308Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/0e/f2cbbd4eb81646b3e09093b4df903c4df60c4791f2a4c6d41e5f3a56b491/functions_framework-3.10.1-py3-none-any.whl", hash = "sha256:48e7fd752d32dfeb528d1c9bf5d95960b6f0bb392f2a4da689f4d3c7a82c1230", size = 41406, upload-time = "2026-02-17T20:39:41.455Z" }, +] + +[[package]] +name = "gunicorn" +version = "25.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/f4/e78fa054248fab913e2eab0332c6c2cb07421fca1ce56d8fe43b6aef57a4/gunicorn-25.3.0.tar.gz", hash = "sha256:f74e1b2f9f76f6cd1ca01198968bd2dd65830edc24b6e8e4d78de8320e2fe889", size = 634883, upload-time = "2026-03-27T00:00:26.092Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/c8/8aaf447698c4d59aa853fd318eed300b5c9e44459f242ab8ead6c9c09792/gunicorn-25.3.0-py3-none-any.whl", hash = "sha256:cacea387dab08cd6776501621c295a904fe8e3b7aae9a1a3cbb26f4e7ed54660", size = 208403, upload-time = "2026-03-27T00:00:27.386Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -658,6 +740,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + [[package]] name = "jmespath" version = "1.1.0" @@ -691,6 +794,80 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, ] +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -888,6 +1065,7 @@ dependencies = [ { name = "boto3" }, { name = "botocore" }, { name = "click" }, + { name = "functions-framework" }, { name = "httpx" }, { name = "pre-commit" }, { name = "pydantic" }, @@ -898,6 +1076,7 @@ dependencies = [ { name = "requests" }, { name = "sqlparse" }, { name = "tenacity" }, + { name = "typing-extensions" }, ] [package.optional-dependencies] @@ -921,6 +1100,7 @@ requires-dist = [ { name = "boto3", specifier = ">=1.42.83" }, { name = "botocore", specifier = ">=1.42.83" }, { name = "click", specifier = ">=8.3.1" }, + { name = "functions-framework", specifier = ">=3.4.0" }, { name = "httpx", specifier = ">=0.27.0" }, { name = "pip-audit", marker = "extra == 'dev'", specifier = ">=2.0.0" }, { name = "pre-commit", specifier = ">=4.5.1" }, @@ -940,6 +1120,7 @@ requires-dist = [ { name = "sqlparse", specifier = ">=0.4.4" }, { name = "tenacity", specifier = ">=8.0.0" }, { name = "typer", marker = "extra == 'cli'", specifier = ">=0.9.0" }, + { name = "typing-extensions", specifier = "<4.14.0" }, ] provides-extras = ["dev", "cli"] @@ -1175,7 +1356,7 @@ wheels = [ [[package]] name = "pydantic" -version = "2.12.5" +version = "2.11.10" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -1183,106 +1364,74 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/54/ecab642b3bed45f7d5f59b38443dcb36ef50f85af192e6ece103dbfe9587/pydantic-2.11.10.tar.gz", hash = "sha256:dc280f0982fbda6c38fada4e476dc0a4f3aeaf9c6ad4c28df68a666ec3c61423", size = 788494, upload-time = "2025-10-04T10:40:41.338Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, + { url = "https://files.pythonhosted.org/packages/bd/1f/73c53fcbfb0b5a78f91176df41945ca466e71e9d9d836e5c522abda39ee7/pydantic-2.11.10-py3-none-any.whl", hash = "sha256:802a655709d49bd004c31e865ef37da30b540786a46bfce02333e0e24b5fe29a", size = 444823, upload-time = "2025-10-04T10:40:39.055Z" }, ] [[package]] name = "pydantic-core" -version = "2.41.5" +version = "2.33.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, - { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, - { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, - { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, - { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, - { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, - { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, - { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, - { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, - { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, - { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, - { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, - { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, - { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, - { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, - { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, - { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, - { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, - { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, - { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, - { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, - { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, - { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, - { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, - { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, - { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, - { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, - { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, - { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, - { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, - { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, - { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, - { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, - { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, - { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, - { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, - { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, - { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, - { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, - { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, - { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, - { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, - { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, - { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, - { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, - { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, - { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, - { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, - { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, - { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, - { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, - { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, - { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, - { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, - { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, - { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, - { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, - { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, - { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, - { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, - { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, - { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, - { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, - { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, - { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, - { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, - { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, - { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, - { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, - { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, - { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, - { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, - { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, - { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, - { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, - { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, - { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, - { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, - { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, - { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, - { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, - { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, - { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, - { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/8d/71db63483d518cbbf290261a1fc2839d17ff89fce7089e08cad07ccfce67/pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7", size = 2028584, upload-time = "2025-04-23T18:31:03.106Z" }, + { url = "https://files.pythonhosted.org/packages/24/2f/3cfa7244ae292dd850989f328722d2aef313f74ffc471184dc509e1e4e5a/pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246", size = 1855071, upload-time = "2025-04-23T18:31:04.621Z" }, + { url = "https://files.pythonhosted.org/packages/b3/d3/4ae42d33f5e3f50dd467761304be2fa0a9417fbf09735bc2cce003480f2a/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f", size = 1897823, upload-time = "2025-04-23T18:31:06.377Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f3/aa5976e8352b7695ff808599794b1fba2a9ae2ee954a3426855935799488/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc", size = 1983792, upload-time = "2025-04-23T18:31:07.93Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7a/cda9b5a23c552037717f2b2a5257e9b2bfe45e687386df9591eff7b46d28/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de", size = 2136338, upload-time = "2025-04-23T18:31:09.283Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/b8f9ec8dd1417eb9da784e91e1667d58a2a4a7b7b34cf4af765ef663a7e5/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a", size = 2730998, upload-time = "2025-04-23T18:31:11.7Z" }, + { url = "https://files.pythonhosted.org/packages/47/bc/cd720e078576bdb8255d5032c5d63ee5c0bf4b7173dd955185a1d658c456/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef", size = 2003200, upload-time = "2025-04-23T18:31:13.536Z" }, + { url = "https://files.pythonhosted.org/packages/ca/22/3602b895ee2cd29d11a2b349372446ae9727c32e78a94b3d588a40fdf187/pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e", size = 2113890, upload-time = "2025-04-23T18:31:15.011Z" }, + { url = "https://files.pythonhosted.org/packages/ff/e6/e3c5908c03cf00d629eb38393a98fccc38ee0ce8ecce32f69fc7d7b558a7/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d", size = 2073359, upload-time = "2025-04-23T18:31:16.393Z" }, + { url = "https://files.pythonhosted.org/packages/12/e7/6a36a07c59ebefc8777d1ffdaf5ae71b06b21952582e4b07eba88a421c79/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30", size = 2245883, upload-time = "2025-04-23T18:31:17.892Z" }, + { url = "https://files.pythonhosted.org/packages/16/3f/59b3187aaa6cc0c1e6616e8045b284de2b6a87b027cce2ffcea073adf1d2/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf", size = 2241074, upload-time = "2025-04-23T18:31:19.205Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ed/55532bb88f674d5d8f67ab121a2a13c385df382de2a1677f30ad385f7438/pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51", size = 1910538, upload-time = "2025-04-23T18:31:20.541Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1b/25b7cccd4519c0b23c2dd636ad39d381abf113085ce4f7bec2b0dc755eb1/pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab", size = 1952909, upload-time = "2025-04-23T18:31:22.371Z" }, + { url = "https://files.pythonhosted.org/packages/49/a9/d809358e49126438055884c4366a1f6227f0f84f635a9014e2deb9b9de54/pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65", size = 1897786, upload-time = "2025-04-23T18:31:24.161Z" }, + { url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" }, + { url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload-time = "2025-04-23T18:31:31.025Z" }, + { url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload-time = "2025-04-23T18:31:32.514Z" }, + { url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload-time = "2025-04-23T18:31:33.958Z" }, + { url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload-time = "2025-04-23T18:31:39.095Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload-time = "2025-04-23T18:31:41.034Z" }, + { url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload-time = "2025-04-23T18:31:42.757Z" }, + { url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload-time = "2025-04-23T18:31:44.304Z" }, + { url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload-time = "2025-04-23T18:31:45.891Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" }, + { url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" }, + { url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" }, + { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" }, + { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" }, + { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" }, + { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" }, + { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" }, + { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" }, + { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" }, + { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" }, + { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" }, + { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, + { url = "https://files.pythonhosted.org/packages/7b/27/d4ae6487d73948d6f20dddcd94be4ea43e74349b56eba82e9bdee2d7494c/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8", size = 2025200, upload-time = "2025-04-23T18:33:14.199Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b8/b3cb95375f05d33801024079b9392a5ab45267a63400bf1866e7ce0f0de4/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593", size = 1859123, upload-time = "2025-04-23T18:33:16.555Z" }, + { url = "https://files.pythonhosted.org/packages/05/bc/0d0b5adeda59a261cd30a1235a445bf55c7e46ae44aea28f7bd6ed46e091/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612", size = 1892852, upload-time = "2025-04-23T18:33:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/3e/11/d37bdebbda2e449cb3f519f6ce950927b56d62f0b84fd9cb9e372a26a3d5/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7", size = 2067484, upload-time = "2025-04-23T18:33:20.475Z" }, + { url = "https://files.pythonhosted.org/packages/8c/55/1f95f0a05ce72ecb02a8a8a1c3be0579bbc29b1d5ab68f1378b7bebc5057/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e", size = 2108896, upload-time = "2025-04-23T18:33:22.501Z" }, + { url = "https://files.pythonhosted.org/packages/53/89/2b2de6c81fa131f423246a9109d7b2a375e83968ad0800d6e57d0574629b/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8", size = 2069475, upload-time = "2025-04-23T18:33:24.528Z" }, + { url = "https://files.pythonhosted.org/packages/b8/e9/1f7efbe20d0b2b10f6718944b5d8ece9152390904f29a78e68d4e7961159/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf", size = 2239013, upload-time = "2025-04-23T18:33:26.621Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/5309c905a93811524a49b4e031e9851a6b00ff0fb668794472ea7746b448/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb", size = 2238715, upload-time = "2025-04-23T18:33:28.656Z" }, + { url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757, upload-time = "2025-04-23T18:33:30.645Z" }, ] [[package]] @@ -1561,6 +1710,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" }, ] +[[package]] +name = "starlette" +version = "0.52.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, +] + [[package]] name = "tenacity" version = "9.1.2" @@ -1650,11 +1812,11 @@ wheels = [ [[package]] name = "typing-extensions" -version = "4.15.0" +version = "4.13.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967, upload-time = "2025-04-10T14:19:05.416Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, + { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806, upload-time = "2025-04-10T14:19:03.967Z" }, ] [[package]] @@ -1678,6 +1840,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, ] +[[package]] +name = "uvicorn" +version = "0.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/da/6eee1ff8b6cbeed47eeb5229749168e81eb4b7b999a1a15a7176e51410c9/uvicorn-0.44.0.tar.gz", hash = "sha256:6c942071b68f07e178264b9152f1f16dfac5da85880c4ce06366a96d70d4f31e", size = 86947, upload-time = "2026-04-06T09:23:22.826Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/23/a5bbd9600dd607411fa644c06ff4951bec3a4d82c4b852374024359c19c0/uvicorn-0.44.0-py3-none-any.whl", hash = "sha256:ce937c99a2cc70279556967274414c087888e8cec9f9c94644dfca11bd3ced89", size = 69425, upload-time = "2026-04-06T09:23:21.524Z" }, +] + +[[package]] +name = "uvicorn-worker" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gunicorn" }, + { name = "uvicorn" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/59/9101b9c0680fd80e9d26c07deb822a5d18a324339fcf9cd017885ee808ad/uvicorn_worker-0.4.0.tar.gz", hash = "sha256:8ee5306070d8f38dce124adce488c3c0b50f20cf0c0222b12c66188da7214493", size = 9361, upload-time = "2025-09-20T10:47:01.218Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/25/09cd7a90c8bb7fb693be0d6704fccd5f9778d5513214b7a01cc4a94ff314/uvicorn_worker-0.4.0-py3-none-any.whl", hash = "sha256:e2ed952cef976f5e9e429d7269640bbcafbd36c80aa80f1003c8c77a6797abde", size = 5364, upload-time = "2025-09-20T10:46:59.776Z" }, +] + [[package]] name = "virtualenv" version = "21.2.0" @@ -1693,6 +1881,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c6/59/7d02447a55b2e55755011a647479041bc92a82e143f96a8195cb33bd0a1c/virtualenv-21.2.0-py3-none-any.whl", hash = "sha256:1bd755b504931164a5a496d217c014d098426cddc79363ad66ac78125f9d908f", size = 5825084, upload-time = "2026-03-09T17:24:35.378Z" }, ] +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, + { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, + { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + [[package]] name = "wcwidth" version = "0.6.0" @@ -1702,6 +1917,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" }, ] +[[package]] +name = "werkzeug" +version = "3.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, +] + [[package]] name = "yarl" version = "1.22.0"