diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 9b75f71..cdabe2c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -46,6 +46,9 @@ jobs: USER_STAC_ITEM_GEN_ROLE_ARN: ${{ vars.USER_STAC_ITEM_GEN_ROLE_ARN }} USER_STAC_INBOUND_TOPIC_ARNS: ${{ vars.USER_STAC_INBOUND_TOPIC_ARNS }} USER_STAC_COLLECTION_ID_REGISTRY: ${{ vars.USER_STAC_COLLECTION_ID_REGISTRY }} + USER_STAC_COLLECTION_TRANSACTIONS_AUTH_MODE: ${{ vars.USER_STAC_COLLECTION_TRANSACTIONS_AUTH_MODE }} + USER_STAC_COLLECTION_TRANSACTIONS_AUTH_SECRET_ARN: ${{ vars.USER_STAC_COLLECTION_TRANSACTIONS_AUTH_SECRET_ARN }} + USER_STAC_COLLECTION_TRANSACTIONS_ENABLED: ${{ vars.USER_STAC_COLLECTION_TRANSACTIONS_ENABLED }} USER_STAC_STAC_API_CUSTOM_DOMAIN_NAME: ${{ vars.USER_STAC_STAC_API_CUSTOM_DOMAIN_NAME }} USER_STAC_TITILER_PGSTAC_API_CUSTOM_DOMAIN_NAME: ${{ vars.USER_STAC_TITILER_PGSTAC_API_CUSTOM_DOMAIN_NAME }} WEB_ACL_ARN: ${{ vars.WEB_ACL_ARN }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4daec8d..01deae2 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,75 +1,71 @@ -name: tests +name: Unit and runtime tests permissions: - id-token: write # required for requesting the JWT - contents: read # required for actions/checkout + contents: read on: - # Uncomment below for running it manually on the github UI - workflow_dispatch: + pull_request: + push: + branches: [main] + workflow_dispatch: - # Uncomment below for running it on a push in a specific branch - # push: - # branches: - # - "change-stac-api-url-stage" +jobs: + node-tests: + name: node-tests + runs-on: ubuntu-latest - # Uncomment below for running it as a cron job - # schedule: - # - cron: '15 16 * * 5' + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false -jobs: - python-job: - name: "PyTest tests" - runs-on: ubuntu-latest - strategy: - matrix: - include: - - environment: test - - environment: dev - environment: ${{ matrix.environment }} + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "20" + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm test - steps: - - name: Checkout repository - uses: actions/checkout@v3 + python-runtime-tests: + name: pytest (${{ matrix.runtime.name }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + runtime: + - name: eoapi-stac + path: cdk/runtimes/eoapi/stac + - name: dps-stac-item-generator + path: cdk/constructs/DpsStacItemGenerator/runtime - - name: Setup Python - uses: actions/setup-python@v3 - with: - python-version: '3.12' + defaults: + run: + working-directory: ${{ matrix.runtime.path }} - - name: Assume Github OIDC role - uses: aws-actions/configure-aws-credentials@v2 - with: - aws-region: us-west-2 - role-to-assume: ${{ vars.MAAP_EOAPI_TEST_ROLE }} - role-session-name: maap-eoapi-tests-${{ matrix.environment }} + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r tests/requirements.txt + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.12" - - name: Run pytest - env: - INGESTOR_DOMAIN_NAME: ${{ vars.INGESTOR_DOMAIN_NAME }} - STAC_API_CUSTOM_DOMAIN_NAME: ${{ vars.STAC_API_CUSTOM_DOMAIN_NAME }} - TITILER_PGSTAC_API_CUSTOM_DOMAIN_NAME: ${{ vars.TITILER_PGSTAC_API_CUSTOM_DOMAIN_NAME }} - SECRET_ID: ${{ vars.SECRET_ID }} - run: | - pytest tests + - name: Set up uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + enable-cache: true - - name: slack - if: always() - id: slack - uses: slackapi/slack-github-action@v1.24.0 - with: - # Slack channel id, channel name, or user id to post message. - # See also: https://api.slack.com/methods/chat.postMessage#channels - # You can pass in multiple channels to post to by providing a comma-delimited list of channel IDs. - channel-id: ${{ vars.SLACK_CHANNEL_ID }} - # For posting a simple plain text message - slack-message: "GitHub build result: ${{ job.status }}\n${{ github.event.pull_request.html_url || github.event.head_commit.url }}" - env: - SLACK_BOT_TOKEN: ${{ vars.SLACK_BOT_TOKEN }} + - name: Sync dependencies + run: uv sync --locked --dev - \ No newline at end of file + - name: Run pytest + run: uv run pytest diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml deleted file mode 100644 index a35627f..0000000 --- a/.github/workflows/unit-tests.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: Unit Tests - -permissions: - contents: read # required for actions/checkout - -on: - pull_request: - branches: [ main ] - workflow_dispatch: # Allow manual triggering - -jobs: - unit-test: - name: "Run Node.js Unit Tests" - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: 'npm' - - - name: Install dependencies - run: npm ci - - - name: Run tests - run: npm test diff --git a/.gitignore b/.gitignore index 264593d..248242c 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ __pycache__ .venv .env .envrc +.env-test .test-env .DS_Store @@ -17,3 +18,8 @@ cdk.out # potentially installed packages stac-browser/ + +# local docker compose state +.pgdata/ + +dev-docs/plans/ diff --git a/README.md b/README.md index b0ef3f6..0ae3287 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,37 @@ This repository contains the AWS CDK code (written in typescript) used to deploy Deployment happens through a github workflow manually triggered and defined in `.github/workflows/deploy.yaml`. +## User STAC collection transactions + +The internal `userSTAC` deployment can now opt into collection-only STAC transactions. The public-facing stack stays on the same MAAP-owned runtime, but remains read-only unless transaction support is explicitly enabled. + +Enable them with: + +- `USER_STAC_COLLECTION_TRANSACTIONS_ENABLED=true` +- `USER_STAC_COLLECTION_TRANSACTIONS_AUTH_MODE=basic` + +When enabled, this CDK stack creates and manages the Secrets Manager secret used for STAC basic auth by default, grants the STAC Lambda read access to it, and publishes the secret ARN to SSM at: + +- `/maap-eoapi//internal/stac-collection-transaction-auth-secret-arn` + +You can still override the secret with `USER_STAC_COLLECTION_TRANSACTIONS_AUTH_SECRET_ARN` if you need to point at an existing secret instead. + +The transaction auth secret must be a JSON object with string `username` and `password` fields. + +### What to verify after deployment + +For a transaction-enabled internal deployment, verify: + +- `GET /conformance` includes `https://api.stacspec.org/v1.0.0/collections/extensions/transaction` +- OpenAPI advertises collection write routes only: + - `POST /collections` + - `PUT /collections/{collection_id}` + - `PATCH /collections/{collection_id}` + - `DELETE /collections/{collection_id}` +- unauthenticated collection writes return `401` +- authenticated collection writes succeed +- item write routes are absent from the contract and return `404` or `405` rather than exposing item transaction behavior + ## Networking and accessibility of the database. diff --git a/cdk/PgStacInfra.ts b/cdk/PgStacInfra.ts index 87f09e9..94afb19 100644 --- a/cdk/PgStacInfra.ts +++ b/cdk/PgStacInfra.ts @@ -6,6 +6,7 @@ import { aws_lambda as lambda, aws_rds as rds, aws_s3 as s3, + aws_secretsmanager as secretsmanager, aws_cloudfront as cloudfront, aws_cloudfront_origins as origins, aws_cloudwatch as cloudwatch, @@ -89,14 +90,75 @@ export class PgStacInfra extends Stack { : ec2.SubnetType.PRIVATE_WITH_EGRESS, }; + const transactionsConfig = stacApiConfig.transactions; + if (transactionsConfig && transactionsConfig.authMode !== "basic") { + throw new Error( + `Unsupported STAC collection transaction auth mode: ${transactionsConfig.authMode}`, + ); + } + + const transactionAuthSecret = transactionsConfig + ? transactionsConfig.authSecretArn + ? secretsmanager.Secret.fromSecretCompleteArn( + this, + "stac-collection-transaction-auth-secret", + transactionsConfig.authSecretArn, + ) + : new secretsmanager.Secret( + this, + "stac-collection-transaction-auth-secret", + { + description: `Basic auth secret for MAAP ${type} STAC collection transactions (${stage})`, + secretName: `/maap-eoapi/${stage}/${type}/stac-collection-transaction-basic-auth`, + generateSecretString: { + secretStringTemplate: JSON.stringify({ + username: `maap-${type}-stac-writer`, + }), + generateStringKey: "password", + excludePunctuation: true, + }, + }, + ) + : undefined; + + const stacEnabledExtensions = [ + "query", + "sort", + "fields", + "filter", + "free_text", + "pagination", + "collection_search", + ...(transactionsConfig ? ["collection_transaction"] : []), + ]; + + const stacApiEnv: Record = { + STAC_FASTAPI_TITLE: `MAAP ${type} STAC API (${stage})`, + STAC_FASTAPI_LANDING_ID: `maap-${type}-stac-api-${stage}`, + STAC_FASTAPI_DESCRIPTION: `The ${type} STAC API for the [MAAP project](https://maap-project.org)`, + STAC_FASTAPI_VERSION: version, + ENABLED_EXTENSIONS: stacEnabledExtensions.join(","), + ...(transactionsConfig + ? { + MAAP_TRANSACTION_AUTH_MODE: transactionsConfig.authMode, + MAAP_TRANSACTION_AUTH_SECRET_ARN: + transactionAuthSecret!.secretArn, + } + : {}), + }; + + const stacApiLambdaOptions: CustomLambdaFunctionProps = { + code: lambda.Code.fromDockerBuild(__dirname, { + file: "dockerfiles/Dockerfile.stac", + targetStage: "lambda", + buildArgs: { PYTHON_VERSION: "3.12" }, + }), + handler: "eoapi.stac.handler.handler", + }; + // STAC API const stacApiLambda = new PgStacApiLambda(this, "pgstac-api", { - apiEnv: { - STAC_FASTAPI_TITLE: `MAAP ${type} STAC API (${stage})`, - STAC_FASTAPI_LANDING_ID: `maap-${type}-stac-api-${stage}`, - STAC_FASTAPI_DESCRIPTION: `The ${type} STAC API for the [MAAP project](https://maap-project.org)`, - STAC_FASTAPI_VERSION: version, - }, + apiEnv: stacApiEnv, vpc, db: pgstacDb.connectionTarget, dbSecret: pgstacDb.pgstacSecret, @@ -113,6 +175,7 @@ export class PgStacInfra extends Stack { }) : undefined, enableSnapStart: true, + lambdaFunctionOptions: stacApiLambdaOptions, }); stacApiLambda.lambdaFunction.connections.allowTo( @@ -128,6 +191,16 @@ export class PgStacInfra extends Stack { }); } + if (transactionAuthSecret) { + transactionAuthSecret.grantRead(stacApiLambda.lambdaFunction); + + new ssm.StringParameter(this, "stac-collection-transaction-auth-secret-param", { + parameterName: `/maap-eoapi/${stage}/${type}/stac-collection-transaction-auth-secret-arn`, + stringValue: transactionAuthSecret.secretArn, + description: `Secrets Manager ARN for MAAP ${type} STAC collection transaction auth (${stage})`, + }); + } + // titiler-pgstac const titilerDataAccessRole = iam.Role.fromRoleArn( this, @@ -141,6 +214,7 @@ export class PgStacInfra extends Stack { const titilerPgstacLambdaOptions: CustomLambdaFunctionProps = { code: lambda.Code.fromDockerBuild(__dirname, { file: "dockerfiles/Dockerfile.raster", + targetStage: "lambda", buildArgs: { PYTHON_VERSION: "3.12" }, }), handler: "handler.handler", @@ -615,6 +689,15 @@ export interface Props extends StackProps { * STAC API api gateway source ARN to be granted STAC API lambda invoke permission. */ integrationApiArn?: string; + + /** + * Optional collection transaction support for the STAC API. + * When omitted, the API stays read-only. + */ + transactions?: { + authMode: "basic" | "jwt"; + authSecretArn?: string; + }; }; /** diff --git a/cdk/app.ts b/cdk/app.ts index 3a3e133..49ea236 100644 --- a/cdk/app.ts +++ b/cdk/app.ts @@ -30,6 +30,7 @@ const { userStacCollectionIdRegistry, userStacInboundTopicArns, userStacItemGenRoleArn, + userStacCollectionTransactions, userStacStacApiCustomDomainName, userStacTitilerPgStacApiCustomDomainName, version, @@ -108,6 +109,7 @@ const userInfrastructure = new PgStacInfra(app, buildStackName("userSTAC"), { }, stacApiConfig: { customDomainName: userStacStacApiCustomDomainName, + transactions: userStacCollectionTransactions, }, titilerPgstacConfig: { mosaicHost, diff --git a/cdk/config.ts b/cdk/config.ts index 995e791..7c278bc 100644 --- a/cdk/config.ts +++ b/cdk/config.ts @@ -1,5 +1,28 @@ import * as aws_ec2 from "aws-cdk-lib/aws-ec2"; +export interface CollectionTransactionsConfig { + authMode: "basic" | "jwt"; + authSecretArn?: string; +} + +function parseOptionalBooleanEnv(name: string): boolean | undefined { + const value = process.env[name]; + if (value === undefined || value === "") { + return undefined; + } + + const normalizedValue = value.trim().toLowerCase(); + if (normalizedValue === "true") { + return true; + } + + if (normalizedValue === "false") { + return false; + } + + throw new Error(`Invalid ${name}: ${value}. Expected "true" or "false".`); +} + export class Config { readonly stage: string; readonly version: string; @@ -25,9 +48,11 @@ export class Config { readonly userStacCollectionIdRegistry: Record | undefined; readonly userStacStacApiCustomDomainName: string | undefined; readonly userStacTitilerPgStacApiCustomDomainName: string | undefined; + readonly userStacCollectionTransactions: + | CollectionTransactionsConfig + | undefined; constructor() { - // These are required environment variables and cannot be undefined const requiredVariables = [ { name: "STAGE", value: process.env.STAGE }, { name: "DB_INSTANCE_TYPE", value: process.env.DB_INSTANCE_TYPE }, @@ -103,7 +128,7 @@ export class Config { this.stacBrowserCertificateArn = process.env.STAC_BROWSER_CERTIFICATE_ARN!; this.stacApiCustomDomainName = process.env.STAC_API_CUSTOM_DOMAIN_NAME!; - this.version = process.env.npm_package_version!; // Set by node.js + this.version = process.env.npm_package_version!; this.tags = { project: "MAAP", version: this.version, @@ -117,8 +142,12 @@ export class Config { this.pgstacVersion = process.env.PGSTAC_VERSION!; this.webAclArn = process.env.WEB_ACL_ARN!; this.userStacItemGenRoleArn = process.env.USER_STAC_ITEM_GEN_ROLE_ARN!; - this.userStacStacApiCustomDomainName = process.env.USER_STAC_STAC_API_CUSTOM_DOMAIN_NAME; - this.userStacTitilerPgStacApiCustomDomainName = process.env.USER_STAC_TITILER_PGSTAC_API_CUSTOM_DOMAIN_NAME; + this.userStacStacApiCustomDomainName = + process.env.USER_STAC_STAC_API_CUSTOM_DOMAIN_NAME; + this.userStacTitilerPgStacApiCustomDomainName = + process.env.USER_STAC_TITILER_PGSTAC_API_CUSTOM_DOMAIN_NAME; + this.userStacCollectionTransactions = + this.parseUserStacCollectionTransactions(); if (process.env.USER_STAC_INBOUND_TOPIC_ARNS) { try { @@ -128,7 +157,7 @@ export class Config { } catch (error) { throw new Error( `Invalid JSON format for USER_STAC_INBOUND_TOPIC_ARNS: ${error}. ` + - `Expected format: ["arn:aws:sns:us-west-2:123456789012:topic-name", ...]` + `Expected format: ["arn:aws:sns:us-west-2:123456789012:topic-name", ...]`, ); } } else { @@ -143,7 +172,7 @@ export class Config { } catch (error) { throw new Error( `Invalid JSON format for USER_STAC_COLLECTION_ID_REGISTRY: ${error}. ` + - `Expected format: {"collection-id": ["user1", "user2"]}` + `Expected format: {"collection-id": ["user1", "user2"]}`, ); } } else { @@ -158,4 +187,41 @@ export class Config { */ buildStackName = (serviceId: string): string => `MAAP-STAC-${this.stage}-${serviceId}`; + + private parseUserStacCollectionTransactions(): + | CollectionTransactionsConfig + | undefined { + const enabled = parseOptionalBooleanEnv( + "USER_STAC_COLLECTION_TRANSACTIONS_ENABLED", + ); + + if (enabled !== true) { + return undefined; + } + + const authMode = process.env.USER_STAC_COLLECTION_TRANSACTIONS_AUTH_MODE; + if (!authMode) { + throw new Error( + "Must provide USER_STAC_COLLECTION_TRANSACTIONS_AUTH_MODE when USER_STAC_COLLECTION_TRANSACTIONS_ENABLED=true", + ); + } + + if (authMode !== "basic") { + throw new Error( + `Unsupported USER_STAC_COLLECTION_TRANSACTIONS_AUTH_MODE: ${authMode}. Expected \"basic\".`, + ); + } + + const authSecretArn = + process.env.USER_STAC_COLLECTION_TRANSACTIONS_AUTH_SECRET_ARN; + + return authSecretArn + ? { + authMode, + authSecretArn, + } + : { + authMode, + }; + } } diff --git a/cdk/dockerfiles/Dockerfile.raster b/cdk/dockerfiles/Dockerfile.raster index a07facc..35922c2 100644 --- a/cdk/dockerfiles/Dockerfile.raster +++ b/cdk/dockerfiles/Dockerfile.raster @@ -1,6 +1,6 @@ ARG PYTHON_VERSION=3.12 -FROM --platform=linux/amd64 public.ecr.aws/lambda/python:${PYTHON_VERSION} +FROM --platform=linux/amd64 public.ecr.aws/lambda/python:${PYTHON_VERSION} AS lambda COPY --from=ghcr.io/astral-sh/uv:0.10.9 /uv /uvx /bin/ # Install system dependencies to compile (numexpr) @@ -32,4 +32,12 @@ RUN rm -rdf /asset/numpy/doc/ /asset/boto3* /asset/botocore* /asset/bin /asset/g COPY handlers/raster_handler.py /asset/handler.py +FROM lambda AS local +RUN uv pip install \ + --python /var/lang/bin/python \ + --target /asset \ + --no-cache-dir \ + --disable-pip-version-check \ + uvicorn + CMD ["echo", "hello world"] diff --git a/cdk/dockerfiles/Dockerfile.stac b/cdk/dockerfiles/Dockerfile.stac new file mode 100644 index 0000000..4ec3e16 --- /dev/null +++ b/cdk/dockerfiles/Dockerfile.stac @@ -0,0 +1,33 @@ +ARG PYTHON_VERSION=3.12 + +FROM --platform=linux/amd64 public.ecr.aws/lambda/python:${PYTHON_VERSION} AS lambda +COPY --from=ghcr.io/astral-sh/uv:0.10.9 /uv /uvx /bin/ + +RUN dnf install -y findutils && dnf clean all && rm -rf /var/cache/dnf + +WORKDIR /tmp + +COPY runtimes/eoapi/stac /tmp/stac +RUN cd /tmp/stac && uv export --locked --no-editable --no-dev --format requirements.txt -o requirements.txt && \ + uv pip install \ + --compile-bytecode \ + --no-binary pydantic \ + --target /asset \ + --no-cache-dir \ + --disable-pip-version-check \ + -r /tmp/stac/requirements.txt + +RUN cd /asset && find . -type f -name '*.pyc' | while read f; do n=$(echo $f | sed 's/__pycache__\///' | sed 's/.cpython-[0-9]*//'); cp $f $n; done; +RUN cd /asset && find . -type d -a -name '__pycache__' -print0 | xargs -0 rm -rf +RUN cd /asset && find . -type f -a -name '*.py' -print0 | xargs -0 rm -f +RUN find /asset -type d -a -name 'tests' -print0 | xargs -0 rm -rf + +FROM lambda AS local +RUN uv pip install \ + --python /var/lang/bin/python \ + --target /asset \ + --no-cache-dir \ + --disable-pip-version-check \ + uvicorn + +CMD ["echo", "hello world"] diff --git a/cdk/runtimes/eoapi/raster/pyproject.toml b/cdk/runtimes/eoapi/raster/pyproject.toml index 18776ad..1c3aa4c 100644 --- a/cdk/runtimes/eoapi/raster/pyproject.toml +++ b/cdk/runtimes/eoapi/raster/pyproject.toml @@ -4,7 +4,6 @@ description = "Custom raster tiling service for MAAP" readme = "README.md" requires-python = ">=3.12" authors = [ - {name = "Vincent Sarago", email = "vincent@developmentseed.com"}, {name = "Henry Rodman", email = "henry@developmentseed.com"}, ] license = {text = "MIT"} diff --git a/cdk/runtimes/eoapi/raster/uv.lock b/cdk/runtimes/eoapi/raster/uv.lock index 188cda1..71a4297 100644 --- a/cdk/runtimes/eoapi/raster/uv.lock +++ b/cdk/runtimes/eoapi/raster/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.12" [[package]] diff --git a/cdk/runtimes/eoapi/stac/.python-version b/cdk/runtimes/eoapi/stac/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/cdk/runtimes/eoapi/stac/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/cdk/runtimes/eoapi/stac/README.md b/cdk/runtimes/eoapi/stac/README.md new file mode 100644 index 0000000..4677793 --- /dev/null +++ b/cdk/runtimes/eoapi/stac/README.md @@ -0,0 +1,81 @@ +## eoapi.stac + +MAAP-owned STAC API runtime scaffolding. + +This package is the local home for the custom STAC runtime used by MAAP deployments. In this first slice it provides the packaging, Docker build path, and local development wiring needed to iterate on the runtime in-repo. + +### Local development + +Start the local pgSTAC + STAC + raster stack from the repository root: + +```bash +docker compose up --build stac raster database +``` + +The local compose setup bind-mounts `cdk/runtimes/eoapi/stac/` into the container and runs `uvicorn --reload`, so changes under `cdk/runtimes/eoapi/stac/eoapi/stac/` are picked up without rebuilding the image. Add or override environment variables with `.stac.env`, `.raster.env`, or `.env` as needed. + +When you enable collection transactions, the runtime now fails closed unless these env vars are present: + +- `MAAP_TRANSACTION_AUTH_MODE=basic` +- one of: + - `MAAP_TRANSACTION_AUTH_SECRET_ARN`, or + - both `MAAP_TRANSACTION_AUTH_USERNAME` and `MAAP_TRANSACTION_AUTH_PASSWORD` + +The secret form is intended for Lambda deployments. The username/password env-var form is intended for local docker-compose development. If a secret ARN is present, it takes precedence. + +The secret must be a JSON object with `username` and `password` string fields. + +### Running tests + +From this directory, run: + +```bash +uv run pytest +``` + +These tests cover app construction, OpenAPI and conformance output, auth behavior, and the custom Lambda handler lifecycle. + +### Environment shape + +The local STAC service uses the same pgSTAC-style environment variables already used elsewhere in eoapi development: + +- `POSTGRES_USER` +- `POSTGRES_PASS` +- `POSTGRES_DBNAME` +- `POSTGRES_HOST_READER` +- `POSTGRES_HOST_WRITER` +- `POSTGRES_PORT` +- `DB_MIN_CONN_SIZE` +- `DB_MAX_CONN_SIZE` +- `ENABLED_EXTENSIONS` +- `TITILER_ENDPOINT` +- `MAAP_TRANSACTION_AUTH_MODE` +- `MAAP_TRANSACTION_AUTH_USERNAME` +- `MAAP_TRANSACTION_AUTH_PASSWORD` +- `MAAP_TRANSACTION_AUTH_SECRET_ARN` + +The local raster service also expects mosaic settings, so the compose file provides development defaults for: + +- `MOSAIC_BACKEND` +- `MOSAIC_HOST` + +### Packaging notes + +- `cdk/dockerfiles/Dockerfile.stac` has separate `lambda` and `local` targets. +- The Docker build context for local and CDK builds is `cdk/`. +- `docker-compose.yml` builds the `local` target, which layers `uvicorn` on top of the runtime asset for local development only. +- Lambda builds should continue using the default `lambda` target without `uvicorn`. +- The local compose stack runs the MAAP app via `uvicorn eoapi.stac.main:app --reload --reload-dir /workspace/eoapi/stac`. +- The Lambda runtime entrypoint is `eoapi.stac.handler.handler` and preserves the upstream SnapStart-aware connection lifecycle. +- Collection write-route auth is attached with FastAPI security dependencies on `POST /collections` plus `PUT`, `PATCH`, and `DELETE /collections/{collection_id}`. +- Those dependencies are declared as HTTP Basic auth in OpenAPI, so Swagger UI shows the protected routes with the built-in auth flow instead of relying only on the browser challenge popup. + +### Post-deploy smoke checks + +For a transaction-enabled deployment, verify: + +- `GET /conformance` advertises only the collection transaction conformance class. +- OpenAPI includes collection write routes and does not advertise item transaction write routes. +- `POST /collections` without auth returns `401` with `WWW-Authenticate: Basic`. +- Authenticated `POST`, `PUT`, `PATCH`, and `DELETE` requests against `/collections` succeed when the backing pgSTAC deployment is healthy. +- Item write routes such as `POST /collections/{collection_id}/items` remain unavailable. diff --git a/cdk/runtimes/eoapi/stac/eoapi/stac/__init__.py b/cdk/runtimes/eoapi/stac/eoapi/stac/__init__.py new file mode 100644 index 0000000..77dc59f --- /dev/null +++ b/cdk/runtimes/eoapi/stac/eoapi/stac/__init__.py @@ -0,0 +1,3 @@ +"""eoapi.stac.""" + +__version__ = "0.1.0" diff --git a/cdk/runtimes/eoapi/stac/eoapi/stac/auth.py b/cdk/runtimes/eoapi/stac/eoapi/stac/auth.py new file mode 100644 index 0000000..06c44bc --- /dev/null +++ b/cdk/runtimes/eoapi/stac/eoapi/stac/auth.py @@ -0,0 +1,138 @@ +"""Authentication helpers for collection transaction routes.""" + +from __future__ import annotations + +import json +from functools import lru_cache +from hmac import compare_digest +from typing import Annotated, Any + +from eoapi.stac.settings import ( + TransactionAuthSettings, +) +from fastapi import HTTPException, Security, status +from fastapi.params import Depends +from fastapi.security import HTTPBasic, HTTPBasicCredentials + +_BASIC_AUTH_CHALLENGE_HEADERS = {"WWW-Authenticate": "Basic"} +basic_auth_scheme = HTTPBasic( + auto_error=False, + description="HTTP Basic authentication for collection transaction routes.", +) +transaction_auth_settings = TransactionAuthSettings() + + +@lru_cache(maxsize=8) +def load_secret_dict(secret_arn: str) -> dict[str, Any]: + """Load and parse a JSON secret from AWS Secrets Manager.""" + try: + import boto3 + except ImportError as error: + raise RuntimeError( + "boto3 is required to load Secrets Manager credentials" + ) from error + + response = boto3.client("secretsmanager").get_secret_value(SecretId=secret_arn) + secret_string = response.get("SecretString") + if not secret_string: + raise RuntimeError(f"Secret {secret_arn} did not contain SecretString") + + try: + secret_dict = json.loads(secret_string) + except json.JSONDecodeError as error: + raise RuntimeError(f"Secret {secret_arn} did not contain valid JSON") from error + + if not isinstance(secret_dict, dict): + raise RuntimeError(f"Secret {secret_arn} must decode to a JSON object") + + return secret_dict + + +def _unauthorized_basic_auth() -> HTTPException: + """Build the standard HTTP Basic auth challenge response.""" + return HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or missing basic authentication credentials", + headers=_BASIC_AUTH_CHALLENGE_HEADERS, + ) + + +@lru_cache(maxsize=1) +def get_basic_auth_credentials() -> tuple[str, str]: + """Load basic-auth credentials from Secrets Manager or local env vars.""" + if transaction_auth_settings.secret_arn: + secret = load_secret_dict(transaction_auth_settings.secret_arn) + username = secret.get("username") + password = secret.get("password") + if not isinstance(username, str) or not isinstance(password, str): + raise RuntimeError( + "Transaction auth secret must contain string fields 'username' and " + "'password'" + ) + return username, password + + if transaction_auth_settings.username and transaction_auth_settings.password: + return transaction_auth_settings.username, transaction_auth_settings.password + + raise RuntimeError( + "Collection transactions require either MAAP_TRANSACTION_AUTH_SECRET_ARN or " + "both MAAP_TRANSACTION_AUTH_USERNAME and MAAP_TRANSACTION_AUTH_PASSWORD when " + "MAAP_TRANSACTION_AUTH_MODE=basic" + ) + + +def validate_transaction_auth_config() -> str: + """Validate transaction auth configuration and return the selected mode.""" + auth_mode = transaction_auth_settings.mode + if auth_mode != "basic": + raise RuntimeError( + "Collection transactions require MAAP_TRANSACTION_AUTH_MODE=basic for " + "this runtime version" + ) + + get_basic_auth_credentials() + return auth_mode + + +async def require_transaction_auth( + credentials: Annotated[ + HTTPBasicCredentials | None, + Security(basic_auth_scheme), + ], +) -> None: + """Require valid transaction auth for collection write routes.""" + auth_mode = validate_transaction_auth_config() + if auth_mode != "basic": + raise RuntimeError(f"Unsupported transaction auth mode: {auth_mode}") + + if credentials is None: + raise _unauthorized_basic_auth() + + expected_username, expected_password = get_basic_auth_credentials() + if not compare_digest( + credentials.username, expected_username + ) or not compare_digest( + credentials.password, + expected_password, + ): + raise _unauthorized_basic_auth() + + +def reset_transaction_auth_state() -> None: + """Reset cached auth state after configuration changes.""" + cache_clear = getattr(load_secret_dict, "cache_clear", None) + if cache_clear is not None: + cache_clear() + + cache_clear = getattr(get_basic_auth_credentials, "cache_clear", None) + if cache_clear is not None: + cache_clear() + + global transaction_auth_settings + transaction_auth_settings = TransactionAuthSettings() + + +def build_transaction_route_dependencies() -> list[Depends]: + """Build validated route dependencies for transaction routes.""" + validate_transaction_auth_config() + return [Depends(require_transaction_auth)] diff --git a/cdk/runtimes/eoapi/stac/eoapi/stac/handler.py b/cdk/runtimes/eoapi/stac/eoapi/stac/handler.py new file mode 100644 index 0000000..56a50f5 --- /dev/null +++ b/cdk/runtimes/eoapi/stac/eoapi/stac/handler.py @@ -0,0 +1,165 @@ +"""AWS Lambda handler for the MAAP STAC runtime.""" + +from __future__ import annotations + +import asyncio +import logging +import os + +from mangum import Mangum +from stac_fastapi.pgstac.config import PostgresSettings +from stac_fastapi.pgstac.db import close_db_connection, connect_to_db + +from eoapi.stac.auth import load_secret_dict +from eoapi.stac.main import ( + COLLECTION_TRANSACTION_EXTENSION, + app, + parse_enabled_extensions, +) + +try: + from snapshot_restore_py import register_after_restore, register_before_snapshot +except ImportError: + + def register_before_snapshot(func): + """Fallback decorator when snapshot_restore_py is unavailable.""" + return func + + def register_after_restore(func): + """Fallback decorator when snapshot_restore_py is unavailable.""" + return func + + +logger = logging.getLogger(__name__) + + +TEXT_MIME_TYPES = [ + "text/", + "application/json", + "application/geo+json", + "application/xml", + "application/vnd.api+json", + "application/vnd.oai.openapi", +] + +_CONNECTIONS_INITIALIZED = False +WITH_COLLECTION_TRANSACTIONS = ( + COLLECTION_TRANSACTION_EXTENSION + in parse_enabled_extensions(os.environ.get("ENABLED_EXTENSIONS")) +) + + +def _build_postgres_settings() -> PostgresSettings: + """Fetch pgSTAC credentials from Secrets Manager.""" + secret_arn = os.getenv("PGSTAC_SECRET_ARN") + if not secret_arn: + raise RuntimeError("PGSTAC_SECRET_ARN must be set for the STAC Lambda runtime") + + logger.info("Loading pgSTAC connection secret") + secret = load_secret_dict(secret_arn) + return PostgresSettings( + pghost=secret["host"], + pgdatabase=secret["dbname"], + pguser=secret["username"], + pgpassword=secret["password"], + pgport=int(secret["port"]), + ) + + +def _close_pool(pool_name: str) -> None: + """Close a database pool on the global app state if it exists.""" + pool = getattr(app.state, pool_name, None) + if not pool: + return + + try: + pool.close() + except Exception: + logger.exception("SnapStart: error closing %s", pool_name) + finally: + setattr(app.state, pool_name, None) + + +def _close_pools() -> None: + """Close both read and write pools if they exist.""" + _close_pool("readpool") + _close_pool("writepool") + + +async def _initialize_db_connections() -> None: + """Initialize database pools for the Lambda runtime.""" + await connect_to_db( + app, + postgres_settings=_build_postgres_settings(), + add_write_connection_pool=WITH_COLLECTION_TRANSACTIONS, + ) + + +def _get_or_create_event_loop() -> asyncio.AbstractEventLoop: + """Return the current event loop, or create one if none exists.""" + try: + return asyncio.get_running_loop() + except RuntimeError: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + return loop + + +def _initialize_db_connections_sync(*, close_existing_pools: bool = False) -> None: + """Initialize database pools from synchronous Lambda hooks.""" + global _CONNECTIONS_INITIALIZED + + if close_existing_pools: + _close_pools() + + loop = _get_or_create_event_loop() + loop.run_until_complete(_initialize_db_connections()) + _CONNECTIONS_INITIALIZED = True + + +@register_before_snapshot +def on_snapshot(): + """Close DB pools before the Lambda snapshot is taken.""" + _close_pools() + return {"statusCode": 200} + + +@register_after_restore +def on_snap_restore(): + """Recreate DB pools after a Lambda snapshot restore.""" + try: + _initialize_db_connections_sync(close_existing_pools=True) + except Exception: + logger.exception("SnapStart: failed to initialize database connection") + raise + + return {"statusCode": 200} + + +@app.on_event("startup") +async def startup_event() -> None: + """Connect to the database when the app starts.""" + logger.info("Setting up DB connection") + await _initialize_db_connections() + logger.info("DB connection setup complete") + + +@app.on_event("shutdown") +async def shutdown_event() -> None: + """Close database pools during shutdown.""" + logger.info("Closing DB connection") + await close_db_connection(app) + logger.info("DB connection closed") + + +handler = Mangum( + app, + lifespan="off", + text_mime_types=TEXT_MIME_TYPES, +) + + +if "AWS_EXECUTION_ENV" in os.environ and not _CONNECTIONS_INITIALIZED: + logger.info("Cold start: initializing database connection") + _initialize_db_connections_sync() + logger.info("Database connection initialized") diff --git a/cdk/runtimes/eoapi/stac/eoapi/stac/main.py b/cdk/runtimes/eoapi/stac/eoapi/stac/main.py new file mode 100644 index 0000000..5f34f72 --- /dev/null +++ b/cdk/runtimes/eoapi/stac/eoapi/stac/main.py @@ -0,0 +1,282 @@ +"""MAAP-owned STAC API application assembly.""" + +from __future__ import annotations + +import os +from contextlib import asynccontextmanager +from typing import cast + +from brotli_asgi import BrotliMiddleware +from eoapi.stac.auth import build_transaction_route_dependencies +from eoapi.stac.transactions import CollectionTransactionExtension +from fastapi import APIRouter, FastAPI +from stac_fastapi.api.app import StacApi +from stac_fastapi.api.middleware import ProxyHeaderMiddleware +from stac_fastapi.api.models import ( + EmptyRequest, + ItemCollectionUri, + JSONResponse, + create_get_request_model, + create_post_request_model, + create_request_model, +) +from stac_fastapi.api.routes import Scope +from stac_fastapi.extensions.core import ( + CollectionSearchExtension, + CollectionSearchFilterExtension, + FieldsExtension, + ItemCollectionFilterExtension, + OffsetPaginationExtension, + SearchFilterExtension, + SortExtension, + TokenPaginationExtension, +) +from stac_fastapi.extensions.core.fields import FieldsConformanceClasses +from stac_fastapi.extensions.core.free_text import FreeTextConformanceClasses +from stac_fastapi.extensions.core.query import QueryConformanceClasses +from stac_fastapi.extensions.core.sort import SortConformanceClasses +from stac_fastapi.pgstac.config import Settings +from stac_fastapi.pgstac.core import CoreCrudClient, health_check +from stac_fastapi.pgstac.db import close_db_connection, connect_to_db +from stac_fastapi.pgstac.extensions import FreeTextExtension, QueryExtension +from stac_fastapi.pgstac.extensions.filter import FiltersClient +from stac_fastapi.pgstac.transactions import TransactionsClient +from stac_fastapi.pgstac.types.search import PgstacSearch +from stac_fastapi.types.extension import ApiExtension +from stac_fastapi.types.search import APIRequest +from starlette.middleware import Middleware +from starlette.middleware.cors import CORSMiddleware + +settings = Settings() + +COLLECTION_TRANSACTION_EXTENSION = "collection_transaction" + +SEARCH_EXTENSIONS_MAP: dict[str, ApiExtension] = { + "query": QueryExtension(), + "sort": SortExtension(), + "fields": FieldsExtension(), + "filter": SearchFilterExtension(client=FiltersClient()), + "pagination": TokenPaginationExtension(), +} + +COLLECTION_SEARCH_EXTENSIONS_MAP: dict[str, ApiExtension] = { + "query": QueryExtension(conformance_classes=[QueryConformanceClasses.COLLECTIONS]), + "sort": SortExtension(conformance_classes=[SortConformanceClasses.COLLECTIONS]), + "fields": FieldsExtension( + conformance_classes=[FieldsConformanceClasses.COLLECTIONS] + ), + "filter": CollectionSearchFilterExtension(client=FiltersClient()), + "free_text": FreeTextExtension( + conformance_classes=[FreeTextConformanceClasses.COLLECTIONS] + ), + "pagination": OffsetPaginationExtension(), +} + +ITEM_COLLECTION_EXTENSIONS_MAP: dict[str, ApiExtension] = { + "query": QueryExtension(conformance_classes=[QueryConformanceClasses.ITEMS]), + "sort": SortExtension(conformance_classes=[SortConformanceClasses.ITEMS]), + "fields": FieldsExtension(conformance_classes=[FieldsConformanceClasses.ITEMS]), + "filter": ItemCollectionFilterExtension(client=FiltersClient()), + "pagination": TokenPaginationExtension(), +} + +DEFAULT_ENABLED_EXTENSIONS: set[str] = { + *SEARCH_EXTENSIONS_MAP.keys(), + *COLLECTION_SEARCH_EXTENSIONS_MAP.keys(), + *ITEM_COLLECTION_EXTENSIONS_MAP.keys(), + "collection_search", +} + +KNOWN_EXTENSIONS: set[str] = { + *DEFAULT_ENABLED_EXTENSIONS, + COLLECTION_TRANSACTION_EXTENSION, +} + +TRANSACTION_ROUTE_SCOPES: list[Scope] = [ + {"path": "/collections", "method": "POST"}, + {"path": "/collections/{collection_id}", "method": "PUT"}, + {"path": "/collections/{collection_id}", "method": "PATCH"}, + {"path": "/collections/{collection_id}", "method": "DELETE"}, +] + + +def parse_enabled_extensions(raw_value: str | None) -> set[str]: + """Parse and validate the ENABLED_EXTENSIONS environment value.""" + if raw_value is None: + return set(DEFAULT_ENABLED_EXTENSIONS) + + enabled_extensions = {part.strip() for part in raw_value.split(",")} + if "" in enabled_extensions: + raise ValueError("Invalid ENABLED_EXTENSIONS: empty extension name") + + unknown_extensions = enabled_extensions - KNOWN_EXTENSIONS + if unknown_extensions: + joined_unknown_extensions = ", ".join(sorted(unknown_extensions)) + raise ValueError( + f"Invalid ENABLED_EXTENSIONS: unsupported extensions: {joined_unknown_extensions}" + ) + + return enabled_extensions + + +def _build_middlewares() -> list[Middleware]: + """Build the middleware stack used by the upstream pgSTAC app.""" + return [ + Middleware(BrotliMiddleware), + Middleware(ProxyHeaderMiddleware), + Middleware( + CORSMiddleware, + allow_origins=settings.cors_origins, + allow_origin_regex=settings.cors_origin_regex, + allow_methods=settings.cors_methods, + allow_credentials=settings.cors_credentials, + allow_headers=settings.cors_headers, + max_age=600, + ), + ] + + +def _build_lifespan(with_collection_transactions: bool): + """Build the FastAPI lifespan for local app execution.""" + + @asynccontextmanager + async def lifespan(app: FastAPI): + await connect_to_db( + app, + add_write_connection_pool=with_collection_transactions, + ) + yield + await close_db_connection(app) + + return lifespan + + +def create_app( + *, + enabled_extensions: set[str] | None = None, + connect_to_database: bool = True, +) -> FastAPI: + """Create the MAAP STAC app with optional collection transactions.""" + resolved_extensions = ( + enabled_extensions + if enabled_extensions is not None + else parse_enabled_extensions(os.environ.get("ENABLED_EXTENSIONS")) + ) + application_extensions: list[ApiExtension] = [] + with_collection_transactions = ( + COLLECTION_TRANSACTION_EXTENSION in resolved_extensions + ) + transaction_route_dependencies = [] + + if with_collection_transactions: + transaction_route_dependencies = build_transaction_route_dependencies() + application_extensions.append( + CollectionTransactionExtension( + client=TransactionsClient(), + settings=settings, + response_class=JSONResponse, + ) + ) + + search_extensions = [ + extension + for key, extension in SEARCH_EXTENSIONS_MAP.items() + if key in resolved_extensions + ] + post_request_model = create_post_request_model( + search_extensions, + base_model=PgstacSearch, + ) + get_request_model = create_get_request_model(search_extensions) + application_extensions.extend(search_extensions) + + items_get_request_model: type[APIRequest] = ItemCollectionUri + item_collection_extensions = [ + extension + for key, extension in ITEM_COLLECTION_EXTENSIONS_MAP.items() + if key in resolved_extensions + ] + if item_collection_extensions: + items_get_request_model = cast( + type[APIRequest], + create_request_model( + model_name="ItemCollectionUri", + base_model=ItemCollectionUri, + extensions=item_collection_extensions, + request_type="GET", + ), + ) + application_extensions.extend(item_collection_extensions) + + collections_get_request_model: type[APIRequest] = EmptyRequest + if "collection_search" in resolved_extensions: + collection_search_extensions = [ + extension + for key, extension in COLLECTION_SEARCH_EXTENSIONS_MAP.items() + if key in resolved_extensions + ] + collection_search_extension = CollectionSearchExtension.from_extensions( + collection_search_extensions + ) + collections_get_request_model = collection_search_extension.GET + application_extensions.append(collection_search_extension) + + api = StacApi( + app=FastAPI( + openapi_url=settings.openapi_url, + docs_url=settings.docs_url, + redoc_url=None, + root_path=settings.root_path, + title=settings.stac_fastapi_title, + version=settings.stac_fastapi_version, + description=settings.stac_fastapi_description, + lifespan=( + _build_lifespan(with_collection_transactions) + if connect_to_database + else None + ), + ), + router=APIRouter(prefix=settings.prefix_path), + settings=settings, + extensions=application_extensions, + client=CoreCrudClient(pgstac_search_model=post_request_model), # type: ignore[arg-type] + response_class=JSONResponse, + items_get_request_model=items_get_request_model, + search_get_request_model=get_request_model, + search_post_request_model=post_request_model, + collections_get_request_model=collections_get_request_model, + middlewares=_build_middlewares(), + health_check=health_check, # type: ignore[arg-type] + ) + if transaction_route_dependencies: + api.add_route_dependencies( + scopes=TRANSACTION_ROUTE_SCOPES, + dependencies=transaction_route_dependencies, + ) + return api.app + + +def run() -> None: + """Run the app locally with uvicorn if it is installed.""" + try: + import uvicorn + except ImportError as error: + raise RuntimeError( + "Uvicorn must be installed in order to use command" + ) from error + + uvicorn.run( + "eoapi.stac.main:app", + host=settings.app_host, + port=settings.app_port, + log_level="info", + reload=settings.reload, + root_path=os.getenv("UVICORN_ROOT_PATH", ""), + ) + + +app = create_app() + + +if __name__ == "__main__": + run() diff --git a/cdk/runtimes/eoapi/stac/eoapi/stac/settings.py b/cdk/runtimes/eoapi/stac/eoapi/stac/settings.py new file mode 100644 index 0000000..aaa2df5 --- /dev/null +++ b/cdk/runtimes/eoapi/stac/eoapi/stac/settings.py @@ -0,0 +1,21 @@ +"""Settings for the MAAP STAC runtime.""" + +from __future__ import annotations + +from typing import Literal + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class TransactionAuthSettings(BaseSettings): + """Configuration for collection transaction authentication.""" + + mode: Literal["basic"] | None = None + secret_arn: str | None = None + username: str | None = None + password: str | None = None + + model_config = SettingsConfigDict( + env_prefix="MAAP_TRANSACTION_AUTH_", + extra="ignore", + ) diff --git a/cdk/runtimes/eoapi/stac/eoapi/stac/transactions.py b/cdk/runtimes/eoapi/stac/eoapi/stac/transactions.py new file mode 100644 index 0000000..2290ab5 --- /dev/null +++ b/cdk/runtimes/eoapi/stac/eoapi/stac/transactions.py @@ -0,0 +1,40 @@ +"""Collection-only transaction extension for the MAAP STAC runtime.""" + +from typing import Any + +import attr +from fastapi import APIRouter, FastAPI +from starlette.responses import Response + +from stac_fastapi.api.models import JSONResponse +from stac_fastapi.extensions.core.transaction import ( + AsyncBaseTransactionsClient, + TransactionConformanceClasses, + TransactionExtension, +) +from stac_fastapi.types.config import ApiSettings + + +@attr.s +class CollectionTransactionExtension(TransactionExtension): + """Register only collection transaction routes and conformance classes.""" + + client: AsyncBaseTransactionsClient = attr.ib() + settings: ApiSettings = attr.ib() + conformance_classes: list[str] = attr.ib( + factory=lambda: [TransactionConformanceClasses.COLLECTIONS] + ) + schema_href: str | None = attr.ib(default=None) + router: APIRouter = attr.ib(factory=APIRouter) + response_class: type[Response] = attr.ib(default=JSONResponse) + route_dependencies: list[Any] = attr.ib(factory=list) + + def register(self, app: FastAPI) -> None: + """Register collection transaction routes with the target app.""" + self.router.prefix = app.state.router_prefix + self.router.dependencies = list(self.route_dependencies) + self.register_create_collection() + self.register_update_collection() + self.register_patch_collection() + self.register_delete_collection() + app.include_router(self.router, tags=["Collection Transaction Extension"]) diff --git a/cdk/runtimes/eoapi/stac/pyproject.toml b/cdk/runtimes/eoapi/stac/pyproject.toml new file mode 100644 index 0000000..f3da87b --- /dev/null +++ b/cdk/runtimes/eoapi/stac/pyproject.toml @@ -0,0 +1,41 @@ +[project] +name = "eoapi.stac" +description = "Custom STAC API runtime for MAAP" +readme = "README.md" +requires-python = ">=3.12" +authors = [ + {name = "Henry Rodman", email = "henry@developmentseed.com"}, +] +license = {text = "MIT"} +classifiers = [ + "Intended Audience :: Information Technology", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3.12", + "Topic :: Scientific/Engineering :: GIS", +] +dynamic = ["version"] +dependencies = [ + "mangum==0.19", + "pydantic-settings>=2,<3", + "stac-fastapi-pgstac>=6.2,<6.3", + "starlette-cramjam>=0.4,<0.5", +] + +[build-system] +requires = ["pdm-pep517"] +build-backend = "pdm.pep517.api" + +[tool.pdm.version] +source = "file" +path = "eoapi/stac/__init__.py" + +[tool.pdm.build] +includes = ["eoapi/stac"] +excludes = ["tests/", "**/.mypy_cache", "**/.DS_Store"] + +[dependency-groups] +dev = [ + "httpx>=0.28.1", + "pytest>=9.0.3", +] diff --git a/cdk/runtimes/eoapi/stac/tests/test_app.py b/cdk/runtimes/eoapi/stac/tests/test_app.py new file mode 100644 index 0000000..d278b50 --- /dev/null +++ b/cdk/runtimes/eoapi/stac/tests/test_app.py @@ -0,0 +1,136 @@ +"""Application tests for the MAAP STAC runtime.""" + +from collections.abc import Iterator + +import pytest +from fastapi.testclient import TestClient + +from eoapi.stac import auth +from eoapi.stac.main import COLLECTION_TRANSACTION_EXTENSION, create_app, parse_enabled_extensions + + +@pytest.fixture(autouse=True) +def reload_transaction_auth_settings() -> None: + """Refresh auth settings after env changes in each test.""" + auth.reset_transaction_auth_state() + yield + auth.reset_transaction_auth_state() + + +@pytest.fixture +def collection_transaction_app(monkeypatch: pytest.MonkeyPatch) -> Iterator[TestClient]: + """Build a test client with collection transactions enabled.""" + monkeypatch.setenv("MAAP_TRANSACTION_AUTH_MODE", "basic") + monkeypatch.delenv("MAAP_TRANSACTION_AUTH_SECRET_ARN", raising=False) + monkeypatch.setenv("MAAP_TRANSACTION_AUTH_USERNAME", "bob") + monkeypatch.setenv("MAAP_TRANSACTION_AUTH_PASSWORD", "builder") + auth.reset_transaction_auth_state() + app = create_app( + enabled_extensions={"query", "sort", "collection_search", COLLECTION_TRANSACTION_EXTENSION}, + connect_to_database=False, + ) + with TestClient(app) as client: + yield client + + +def test_read_only_app_omits_collection_transaction_routes() -> None: + """The default app should stay read-only when collection transactions are disabled.""" + app = create_app( + enabled_extensions={"query", "sort", "collection_search"}, + connect_to_database=False, + ) + openapi = app.openapi() + + assert "/collections" in openapi["paths"] + assert "get" in openapi["paths"]["/collections"] + assert "post" not in openapi["paths"]["/collections"] + assert "/collections/{collection_id}" in openapi["paths"] + assert set(openapi["paths"]["/collections/{collection_id}"].keys()) == {"get"} + assert "/collections/{collection_id}/items" in openapi["paths"] + assert set(openapi["paths"]["/collections/{collection_id}/items"].keys()) == {"get"} + assert "/collections/{collection_id}/items/{item_id}" in openapi["paths"] + assert set(openapi["paths"]["/collections/{collection_id}/items/{item_id}"].keys()) == {"get"} + + +def test_collection_transaction_app_registers_collection_only_routes( + collection_transaction_app: TestClient, +) -> None: + """Enabling collection transactions should expose only collection write routes.""" + openapi = collection_transaction_app.app.openapi() + + assert set(openapi["paths"]["/collections"].keys()) == {"get", "post"} + assert set(openapi["paths"]["/collections/{collection_id}"].keys()) == { + "get", + "put", + "patch", + "delete", + } + assert set(openapi["paths"]["/collections/{collection_id}/items"].keys()) == {"get"} + assert set(openapi["paths"]["/collections/{collection_id}/items/{item_id}"].keys()) == {"get"} + assert {parameter["name"] for parameter in openapi["paths"]["/collections"]["get"]["parameters"]} >= { + "query", + "sortby", + } + + +@pytest.mark.parametrize( + ("method", "path"), + [ + ("post", "/collections/test/items"), + ("put", "/collections/test/items/item-1"), + ("patch", "/collections/test/items/item-1"), + ("delete", "/collections/test/items/item-1"), + ], +) +def test_item_transaction_write_methods_are_not_registered( + collection_transaction_app: TestClient, + method: str, + path: str, +) -> None: + """Item transaction write methods should stay unregistered.""" + request_kwargs = {"json": {}} if method != "delete" else {} + response = getattr(collection_transaction_app, method)(path, **request_kwargs) + + assert response.status_code == 405 + + +def test_openapi_and_conformance_advertise_collection_transactions_only( + collection_transaction_app: TestClient, +) -> None: + """OpenAPI and conformance output should match the collection-only contract.""" + openapi = collection_transaction_app.app.openapi() + + assert "/collections/test/items" not in openapi["paths"] + assert "/collections/{collection_id}/items/{item_id}" in openapi["paths"] + assert "put" not in openapi["paths"]["/collections/{collection_id}/items/{item_id}"] + assert "patch" not in openapi["paths"]["/collections/{collection_id}/items/{item_id}"] + assert "delete" not in openapi["paths"]["/collections/{collection_id}/items/{item_id}"] + assert openapi["components"]["securitySchemes"]["HTTPBasic"] == { + "type": "http", + "scheme": "basic", + "description": "HTTP Basic authentication for collection transaction routes.", + } + assert openapi["paths"]["/collections"]["post"]["security"] == [{"HTTPBasic": []}] + assert openapi["paths"]["/collections/{collection_id}"]["put"]["security"] == [ + {"HTTPBasic": []} + ] + assert openapi["paths"]["/collections/{collection_id}"]["patch"]["security"] == [ + {"HTTPBasic": []} + ] + assert openapi["paths"]["/collections/{collection_id}"]["delete"]["security"] == [ + {"HTTPBasic": []} + ] + assert "security" not in openapi["paths"]["/collections"]["get"] + + response = collection_transaction_app.get("/conformance") + + assert response.status_code == 200 + conformance_classes = response.json()["conformsTo"] + assert "https://api.stacspec.org/v1.0.0/collections/extensions/transaction" in conformance_classes + assert "https://api.stacspec.org/v1.0.0/ogcapi-features/extensions/transaction" not in conformance_classes + + +def test_parse_enabled_extensions_rejects_malformed_values() -> None: + """Malformed extension configuration should fail clearly.""" + with pytest.raises(ValueError, match="Invalid ENABLED_EXTENSIONS"): + parse_enabled_extensions("query,,collection_transaction") diff --git a/cdk/runtimes/eoapi/stac/tests/test_auth.py b/cdk/runtimes/eoapi/stac/tests/test_auth.py new file mode 100644 index 0000000..7b316d8 --- /dev/null +++ b/cdk/runtimes/eoapi/stac/tests/test_auth.py @@ -0,0 +1,175 @@ +"""Authentication tests for collection transaction routes.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Iterator + +import pytest +from fastapi.security import HTTPBasicCredentials +from fastapi.testclient import TestClient +from pydantic import ValidationError + +from eoapi.stac import auth +from eoapi.stac.main import COLLECTION_TRANSACTION_EXTENSION, create_app + + +@pytest.fixture(autouse=True) +def reload_transaction_auth_settings() -> None: + """Refresh auth settings after env changes in each test.""" + auth.reset_transaction_auth_state() + yield + auth.reset_transaction_auth_state() + + +@pytest.fixture +def basic_auth_secret_env(monkeypatch: pytest.MonkeyPatch) -> None: + """Configure basic auth to read credentials from Secrets Manager.""" + monkeypatch.setenv("MAAP_TRANSACTION_AUTH_MODE", "basic") + monkeypatch.setenv("MAAP_TRANSACTION_AUTH_SECRET_ARN", "test-secret-arn") + monkeypatch.delenv("MAAP_TRANSACTION_AUTH_USERNAME", raising=False) + monkeypatch.delenv("MAAP_TRANSACTION_AUTH_PASSWORD", raising=False) + auth.reset_transaction_auth_state() + + +@pytest.fixture +def basic_auth_env_credentials(monkeypatch: pytest.MonkeyPatch) -> None: + """Configure basic auth to read credentials directly from env vars.""" + monkeypatch.setenv("MAAP_TRANSACTION_AUTH_MODE", "basic") + monkeypatch.delenv("MAAP_TRANSACTION_AUTH_SECRET_ARN", raising=False) + monkeypatch.setenv("MAAP_TRANSACTION_AUTH_USERNAME", "bob") + monkeypatch.setenv("MAAP_TRANSACTION_AUTH_PASSWORD", "builder") + auth.reset_transaction_auth_state() + + +@pytest.fixture +def collection_transaction_app( + monkeypatch: pytest.MonkeyPatch, + basic_auth_env_credentials: None, +) -> Iterator[TestClient]: + """Build a transaction-enabled app using env-provided credentials.""" + app = create_app( + enabled_extensions={"query", "collection_search", COLLECTION_TRANSACTION_EXTENSION}, + connect_to_database=False, + ) + with TestClient(app) as client: + yield client + + +def test_require_transaction_auth_accepts_valid_basic_credentials( + monkeypatch: pytest.MonkeyPatch, + basic_auth_env_credentials: None, +) -> None: + """Valid basic auth credentials should satisfy the dependency.""" + + credentials = HTTPBasicCredentials(username="bob", password="builder") + + asyncio.run(auth.require_transaction_auth(credentials)) + + +def test_collection_transaction_routes_require_auth( + collection_transaction_app: TestClient, +) -> None: + """Transaction routes should challenge unauthenticated requests.""" + response = collection_transaction_app.post("/collections", json={}) + + assert response.status_code == 401 + assert response.headers["WWW-Authenticate"] == "Basic" + + +def test_invalid_basic_auth_is_rejected( + collection_transaction_app: TestClient, +) -> None: + """Invalid basic auth credentials should be rejected.""" + response = collection_transaction_app.post( + "/collections", + json={}, + auth=("alice", "wonderland"), + ) + + assert response.status_code == 401 + assert response.headers["WWW-Authenticate"] == "Basic" + + +def test_read_routes_do_not_require_transaction_auth( + collection_transaction_app: TestClient, +) -> None: + """Read routes should not inherit the transaction auth dependency.""" + collections_get_route = next( + route + for route in collection_transaction_app.app.routes + if getattr(route, "path", None) == "/collections" + and "GET" in getattr(route, "methods", set()) + ) + + assert collections_get_route.dependencies == [] + + +def test_collection_write_routes_receive_transaction_auth_dependency( + collection_transaction_app: TestClient, +) -> None: + """Collection write routes should receive the auth dependency.""" + protected_routes = { + (route.path, next(iter(route.methods))): route + for route in collection_transaction_app.app.routes + if getattr(route, "path", None) in {"/collections", "/collections/{collection_id}"} + and getattr(route, "methods", None) + and next(iter(route.methods)) in {"POST", "PUT", "PATCH", "DELETE"} + } + + assert set(protected_routes) == { + ("/collections", "POST"), + ("/collections/{collection_id}", "PUT"), + ("/collections/{collection_id}", "PATCH"), + ("/collections/{collection_id}", "DELETE"), + } + for route in protected_routes.values(): + assert len(route.dependencies) == 1 + assert route.dependencies[0].dependency == auth.require_transaction_auth + + +def test_transaction_enabled_app_accepts_secret_manager_credentials( + monkeypatch: pytest.MonkeyPatch, + basic_auth_secret_env: None, +) -> None: + """Secrets Manager credentials should still be supported.""" + monkeypatch.setattr( + auth, + "load_secret_dict", + lambda secret_arn: {"username": "bob", "password": "builder"}, + ) + + app = create_app( + enabled_extensions={COLLECTION_TRANSACTION_EXTENSION}, + connect_to_database=False, + ) + + assert app is not None + + +def test_transaction_enabled_app_fails_closed_without_any_basic_auth_credentials( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Missing basic-auth config should fail app creation.""" + monkeypatch.setenv("MAAP_TRANSACTION_AUTH_MODE", "basic") + monkeypatch.delenv("MAAP_TRANSACTION_AUTH_SECRET_ARN", raising=False) + monkeypatch.delenv("MAAP_TRANSACTION_AUTH_USERNAME", raising=False) + monkeypatch.delenv("MAAP_TRANSACTION_AUTH_PASSWORD", raising=False) + auth.reset_transaction_auth_state() + + with pytest.raises(RuntimeError, match="MAAP_TRANSACTION_AUTH_USERNAME"): + create_app( + enabled_extensions={COLLECTION_TRANSACTION_EXTENSION}, + connect_to_database=False, + ) + + +def test_transaction_enabled_app_fails_closed_for_unsupported_auth_mode( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Unsupported auth modes should fail app creation.""" + monkeypatch.setenv("MAAP_TRANSACTION_AUTH_MODE", "none") + monkeypatch.setenv("MAAP_TRANSACTION_AUTH_SECRET_ARN", "test-secret-arn") + + with pytest.raises(ValidationError, match="Input should be 'basic'"): + auth.reset_transaction_auth_state() diff --git a/cdk/runtimes/eoapi/stac/tests/test_handler.py b/cdk/runtimes/eoapi/stac/tests/test_handler.py new file mode 100644 index 0000000..851114a --- /dev/null +++ b/cdk/runtimes/eoapi/stac/tests/test_handler.py @@ -0,0 +1,238 @@ +"""Handler tests for the MAAP STAC runtime.""" + +from __future__ import annotations + +import asyncio + +import pytest +from stac_fastapi.pgstac.config import PostgresSettings + +from eoapi.stac import handler + + +class FakePool: + """Simple pool stub that records close calls.""" + + def __init__(self) -> None: + self.closed = False + + def close(self) -> None: + """Mark the pool as closed.""" + self.closed = True + + +@pytest.fixture(autouse=True) +def clear_handler_state() -> None: + """Reset handler globals and app state between tests.""" + original_readpool = getattr(handler.app.state, "readpool", None) + original_writepool = getattr(handler.app.state, "writepool", None) + original_initialized = handler._CONNECTIONS_INITIALIZED + original_with_transactions = handler.WITH_COLLECTION_TRANSACTIONS + handler.app.state.readpool = None + handler.app.state.writepool = None + handler._CONNECTIONS_INITIALIZED = False + yield + handler.app.state.readpool = original_readpool + handler.app.state.writepool = original_writepool + handler._CONNECTIONS_INITIALIZED = original_initialized + handler.WITH_COLLECTION_TRANSACTIONS = original_with_transactions + + +def test_build_postgres_settings_requires_secret_arn( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The handler should fail clearly without PGSTAC_SECRET_ARN.""" + monkeypatch.delenv("PGSTAC_SECRET_ARN", raising=False) + + with pytest.raises(RuntimeError, match="PGSTAC_SECRET_ARN"): + handler._build_postgres_settings() + + +def test_build_postgres_settings_loads_secret_fields( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The handler should map secret fields into Postgres settings.""" + monkeypatch.setenv("PGSTAC_SECRET_ARN", "pg-secret") + monkeypatch.setattr( + handler, + "load_secret_dict", + lambda secret_arn: { + "host": "db.internal", + "dbname": "pgstac", + "username": "reader", + "password": "secret", + "port": "5432", + }, + ) + + settings = handler._build_postgres_settings() + + assert settings == PostgresSettings( + pghost="db.internal", + pgdatabase="pgstac", + pguser="reader", + pgpassword="secret", + pgport=5432, + ) + + +def test_on_snapshot_closes_existing_pools() -> None: + """Snapshot preparation should close and clear both pools.""" + readpool = FakePool() + writepool = FakePool() + handler.app.state.readpool = readpool + handler.app.state.writepool = writepool + + response = handler.on_snapshot() + + assert response == {"statusCode": 200} + assert readpool.closed is True + assert writepool.closed is True + assert handler.app.state.readpool is None + assert handler.app.state.writepool is None + + +@pytest.mark.parametrize("with_transactions", [False, True]) +def test_on_snap_restore_reconnects_with_expected_write_pool_setting( + monkeypatch: pytest.MonkeyPatch, + with_transactions: bool, +) -> None: + """Snapshot restore should reconnect with the correct write-pool flag.""" + captured: dict[str, object] = {} + + async def fake_connect_to_db( + app: object, + *, + postgres_settings: object, + add_write_connection_pool: bool, + ) -> None: + captured["app"] = app + captured["postgres_settings"] = postgres_settings + captured["add_write_connection_pool"] = add_write_connection_pool + + settings = PostgresSettings( + pghost="db.internal", + pgdatabase="pgstac", + pguser="reader", + pgpassword="secret", + pgport=5432, + ) + handler.WITH_COLLECTION_TRANSACTIONS = with_transactions + monkeypatch.setattr(handler, "connect_to_db", fake_connect_to_db) + monkeypatch.setattr(handler, "_build_postgres_settings", lambda: settings) + + response = handler.on_snap_restore() + + assert response == {"statusCode": 200} + assert handler._CONNECTIONS_INITIALIZED is True + assert captured == { + "app": handler.app, + "postgres_settings": settings, + "add_write_connection_pool": with_transactions, + } + + +@pytest.mark.parametrize("with_transactions", [False, True]) +def test_initialize_db_connections_sync_leaves_current_event_loop_available( + monkeypatch: pytest.MonkeyPatch, + with_transactions: bool, +) -> None: + """Sync init should preserve a current event loop for Mangum.""" + captured: dict[str, object] = {} + + async def fake_connect_to_db( + app: object, + *, + postgres_settings: object, + add_write_connection_pool: bool, + ) -> None: + captured["app"] = app + captured["postgres_settings"] = postgres_settings + captured["add_write_connection_pool"] = add_write_connection_pool + + settings = PostgresSettings( + pghost="db.internal", + pgdatabase="pgstac", + pguser="reader", + pgpassword="secret", + pgport=5432, + ) + handler.WITH_COLLECTION_TRANSACTIONS = with_transactions + monkeypatch.setattr(handler, "connect_to_db", fake_connect_to_db) + monkeypatch.setattr(handler, "_build_postgres_settings", lambda: settings) + + handler._initialize_db_connections_sync() + + assert handler._CONNECTIONS_INITIALIZED is True + assert asyncio.get_event_loop() is not None + assert captured == { + "app": handler.app, + "postgres_settings": settings, + "add_write_connection_pool": with_transactions, + } + + +@pytest.mark.parametrize("with_transactions", [False, True]) +def test_startup_event_connects_with_expected_write_pool_setting( + monkeypatch: pytest.MonkeyPatch, + with_transactions: bool, +) -> None: + """Startup should reuse the same write-pool gate as restore.""" + captured: dict[str, object] = {} + + async def fake_connect_to_db( + app: object, + *, + postgres_settings: object, + add_write_connection_pool: bool, + ) -> None: + captured["app"] = app + captured["postgres_settings"] = postgres_settings + captured["add_write_connection_pool"] = add_write_connection_pool + + settings = PostgresSettings( + pghost="db.internal", + pgdatabase="pgstac", + pguser="reader", + pgpassword="secret", + pgport=5432, + ) + handler.WITH_COLLECTION_TRANSACTIONS = with_transactions + monkeypatch.setattr(handler, "connect_to_db", fake_connect_to_db) + monkeypatch.setattr(handler, "_build_postgres_settings", lambda: settings) + + asyncio.run(handler.startup_event()) + + assert captured == { + "app": handler.app, + "postgres_settings": settings, + "add_write_connection_pool": with_transactions, + } + + +def test_handler_uses_specific_text_mime_types() -> None: + """Mangum should treat expected text-based API types as non-binary.""" + assert handler.handler.config["text_mime_types"] == [ + "text/", + "application/json", + "application/geo+json", + "application/xml", + "application/vnd.api+json", + "application/vnd.oai.openapi", + ] + + +def test_shutdown_event_closes_db_connection( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Shutdown should delegate to the shared close helper.""" + captured: dict[str, object] = {} + + async def fake_close_db_connection(app: object) -> None: + captured["app"] = app + + monkeypatch.setattr(handler, "close_db_connection", fake_close_db_connection) + + asyncio.run(handler.shutdown_event()) + + assert captured == {"app": handler.app} diff --git a/cdk/runtimes/eoapi/stac/uv.lock b/cdk/runtimes/eoapi/stac/uv.lock new file mode 100644 index 0000000..4d96eb5 --- /dev/null +++ b/cdk/runtimes/eoapi/stac/uv.lock @@ -0,0 +1,779 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "asyncpg" +version = "0.31.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/a6/59d0a146e61d20e18db7396583242e32e0f120693b67a8de43f1557033e2/asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad", size = 662042, upload-time = "2025-11-24T23:25:49.578Z" }, + { url = "https://files.pythonhosted.org/packages/36/01/ffaa189dcb63a2471720615e60185c3f6327716fdc0fc04334436fbb7c65/asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d", size = 638504, upload-time = "2025-11-24T23:25:51.501Z" }, + { url = "https://files.pythonhosted.org/packages/9f/62/3f699ba45d8bd24c5d65392190d19656d74ff0185f42e19d0bbd973bb371/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a", size = 3426241, upload-time = "2025-11-24T23:25:53.278Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d1/a867c2150f9c6e7af6462637f613ba67f78a314b00db220cd26ff559d532/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671", size = 3520321, upload-time = "2025-11-24T23:25:54.982Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1a/cce4c3f246805ecd285a3591222a2611141f1669d002163abef999b60f98/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec", size = 3316685, upload-time = "2025-11-24T23:25:57.43Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/0fc961179e78cc579e138fad6eb580448ecae64908f95b8cb8ee2f241f67/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20", size = 3471858, upload-time = "2025-11-24T23:25:59.636Z" }, + { url = "https://files.pythonhosted.org/packages/52/b2/b20e09670be031afa4cbfabd645caece7f85ec62d69c312239de568e058e/asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8", size = 527852, upload-time = "2025-11-24T23:26:01.084Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f0/f2ed1de154e15b107dc692262395b3c17fc34eafe2a78fc2115931561730/asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186", size = 597175, upload-time = "2025-11-24T23:26:02.564Z" }, + { url = "https://files.pythonhosted.org/packages/95/11/97b5c2af72a5d0b9bc3fa30cd4b9ce22284a9a943a150fdc768763caf035/asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b", size = 661111, upload-time = "2025-11-24T23:26:04.467Z" }, + { url = "https://files.pythonhosted.org/packages/1b/71/157d611c791a5e2d0423f09f027bd499935f0906e0c2a416ce712ba51ef3/asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e", size = 636928, upload-time = "2025-11-24T23:26:05.944Z" }, + { url = "https://files.pythonhosted.org/packages/2e/fc/9e3486fb2bbe69d4a867c0b76d68542650a7ff1574ca40e84c3111bb0c6e/asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403", size = 3424067, upload-time = "2025-11-24T23:26:07.957Z" }, + { url = "https://files.pythonhosted.org/packages/12/c6/8c9d076f73f07f995013c791e018a1cd5f31823c2a3187fc8581706aa00f/asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4", size = 3518156, upload-time = "2025-11-24T23:26:09.591Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3b/60683a0baf50fbc546499cfb53132cb6835b92b529a05f6a81471ab60d0c/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2", size = 3319636, upload-time = "2025-11-24T23:26:11.168Z" }, + { url = "https://files.pythonhosted.org/packages/50/dc/8487df0f69bd398a61e1792b3cba0e47477f214eff085ba0efa7eac9ce87/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602", size = 3472079, upload-time = "2025-11-24T23:26:13.164Z" }, + { url = "https://files.pythonhosted.org/packages/13/a1/c5bbeeb8531c05c89135cb8b28575ac2fac618bcb60119ee9696c3faf71c/asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696", size = 527606, upload-time = "2025-11-24T23:26:14.78Z" }, + { url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569, upload-time = "2025-11-24T23:26:16.189Z" }, + { url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867, upload-time = "2025-11-24T23:26:17.631Z" }, + { url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349, upload-time = "2025-11-24T23:26:19.689Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428, upload-time = "2025-11-24T23:26:21.275Z" }, + { url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678, upload-time = "2025-11-24T23:26:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505, upload-time = "2025-11-24T23:26:25.235Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744, upload-time = "2025-11-24T23:26:26.944Z" }, + { url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251, upload-time = "2025-11-24T23:26:28.404Z" }, + { url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901, upload-time = "2025-11-24T23:26:30.34Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280, upload-time = "2025-11-24T23:26:32Z" }, + { url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931, upload-time = "2025-11-24T23:26:33.572Z" }, + { url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608, upload-time = "2025-11-24T23:26:35.638Z" }, + { url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738, upload-time = "2025-11-24T23:26:37.275Z" }, + { url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026, upload-time = "2025-11-24T23:26:39.423Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426, upload-time = "2025-11-24T23:26:41.032Z" }, + { url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495, upload-time = "2025-11-24T23:26:42.659Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "brotli" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632, upload-time = "2025-11-05T18:39:42.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/ee/b0a11ab2315c69bb9b45a2aaed022499c9c24a205c3a49c3513b541a7967/brotli-1.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:35d382625778834a7f3061b15423919aa03e4f5da34ac8e02c074e4b75ab4f84", size = 861543, upload-time = "2025-11-05T18:38:24.183Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2f/29c1459513cd35828e25531ebfcbf3e92a5e49f560b1777a9af7203eb46e/brotli-1.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a61c06b334bd99bc5ae84f1eeb36bfe01400264b3c352f968c6e30a10f9d08b", size = 444288, upload-time = "2025-11-05T18:38:25.139Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/feba03130d5fceadfa3a1bb102cb14650798c848b1df2a808356f939bb16/brotli-1.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acec55bb7c90f1dfc476126f9711a8e81c9af7fb617409a9ee2953115343f08d", size = 1528071, upload-time = "2025-11-05T18:38:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/2b/38/f3abb554eee089bd15471057ba85f47e53a44a462cfce265d9bf7088eb09/brotli-1.2.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:260d3692396e1895c5034f204f0db022c056f9e2ac841593a4cf9426e2a3faca", size = 1626913, upload-time = "2025-11-05T18:38:27.284Z" }, + { url = "https://files.pythonhosted.org/packages/03/a7/03aa61fbc3c5cbf99b44d158665f9b0dd3d8059be16c460208d9e385c837/brotli-1.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:072e7624b1fc4d601036ab3f4f27942ef772887e876beff0301d261210bca97f", size = 1419762, upload-time = "2025-11-05T18:38:28.295Z" }, + { url = "https://files.pythonhosted.org/packages/21/1b/0374a89ee27d152a5069c356c96b93afd1b94eae83f1e004b57eb6ce2f10/brotli-1.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adedc4a67e15327dfdd04884873c6d5a01d3e3b6f61406f99b1ed4865a2f6d28", size = 1484494, upload-time = "2025-11-05T18:38:29.29Z" }, + { url = "https://files.pythonhosted.org/packages/cf/57/69d4fe84a67aef4f524dcd075c6eee868d7850e85bf01d778a857d8dbe0a/brotli-1.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7a47ce5c2288702e09dc22a44d0ee6152f2c7eda97b3c8482d826a1f3cfc7da7", size = 1593302, upload-time = "2025-11-05T18:38:30.639Z" }, + { url = "https://files.pythonhosted.org/packages/d5/3b/39e13ce78a8e9a621c5df3aeb5fd181fcc8caba8c48a194cd629771f6828/brotli-1.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:af43b8711a8264bb4e7d6d9a6d004c3a2019c04c01127a868709ec29962b6036", size = 1487913, upload-time = "2025-11-05T18:38:31.618Z" }, + { url = "https://files.pythonhosted.org/packages/62/28/4d00cb9bd76a6357a66fcd54b4b6d70288385584063f4b07884c1e7286ac/brotli-1.2.0-cp312-cp312-win32.whl", hash = "sha256:e99befa0b48f3cd293dafeacdd0d191804d105d279e0b387a32054c1180f3161", size = 334362, upload-time = "2025-11-05T18:38:32.939Z" }, + { url = "https://files.pythonhosted.org/packages/1c/4e/bc1dcac9498859d5e353c9b153627a3752868a9d5f05ce8dedd81a2354ab/brotli-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:b35c13ce241abdd44cb8ca70683f20c0c079728a36a996297adb5334adfc1c44", size = 369115, upload-time = "2025-11-05T18:38:33.765Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d4/4ad5432ac98c73096159d9ce7ffeb82d151c2ac84adcc6168e476bb54674/brotli-1.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9e5825ba2c9998375530504578fd4d5d1059d09621a02065d1b6bfc41a8e05ab", size = 861523, upload-time = "2025-11-05T18:38:34.67Z" }, + { url = "https://files.pythonhosted.org/packages/91/9f/9cc5bd03ee68a85dc4bc89114f7067c056a3c14b3d95f171918c088bf88d/brotli-1.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0cf8c3b8ba93d496b2fae778039e2f5ecc7cff99df84df337ca31d8f2252896c", size = 444289, upload-time = "2025-11-05T18:38:35.6Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b6/fe84227c56a865d16a6614e2c4722864b380cb14b13f3e6bef441e73a85a/brotli-1.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8565e3cdc1808b1a34714b553b262c5de5fbda202285782173ec137fd13709f", size = 1528076, upload-time = "2025-11-05T18:38:36.639Z" }, + { url = "https://files.pythonhosted.org/packages/55/de/de4ae0aaca06c790371cf6e7ee93a024f6b4bb0568727da8c3de112e726c/brotli-1.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:26e8d3ecb0ee458a9804f47f21b74845cc823fd1bb19f02272be70774f56e2a6", size = 1626880, upload-time = "2025-11-05T18:38:37.623Z" }, + { url = "https://files.pythonhosted.org/packages/5f/16/a1b22cbea436642e071adcaf8d4b350a2ad02f5e0ad0da879a1be16188a0/brotli-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67a91c5187e1eec76a61625c77a6c8c785650f5b576ca732bd33ef58b0dff49c", size = 1419737, upload-time = "2025-11-05T18:38:38.729Z" }, + { url = "https://files.pythonhosted.org/packages/46/63/c968a97cbb3bdbf7f974ef5a6ab467a2879b82afbc5ffb65b8acbb744f95/brotli-1.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ecdb3b6dc36e6d6e14d3a1bdc6c1057c8cbf80db04031d566eb6080ce283a48", size = 1484440, upload-time = "2025-11-05T18:38:39.916Z" }, + { url = "https://files.pythonhosted.org/packages/06/9d/102c67ea5c9fc171f423e8399e585dabea29b5bc79b05572891e70013cdd/brotli-1.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3e1b35d56856f3ed326b140d3c6d9db91740f22e14b06e840fe4bb1923439a18", size = 1593313, upload-time = "2025-11-05T18:38:41.24Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/9526d14fa6b87bc827ba1755a8440e214ff90de03095cacd78a64abe2b7d/brotli-1.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54a50a9dad16b32136b2241ddea9e4df159b41247b2ce6aac0b3276a66a8f1e5", size = 1487945, upload-time = "2025-11-05T18:38:42.277Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e8/3fe1ffed70cbef83c5236166acaed7bb9c766509b157854c80e2f766b38c/brotli-1.2.0-cp313-cp313-win32.whl", hash = "sha256:1b1d6a4efedd53671c793be6dd760fcf2107da3a52331ad9ea429edf0902f27a", size = 334368, upload-time = "2025-11-05T18:38:43.345Z" }, + { url = "https://files.pythonhosted.org/packages/ff/91/e739587be970a113b37b821eae8097aac5a48e5f0eca438c22e4c7dd8648/brotli-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:b63daa43d82f0cdabf98dee215b375b4058cce72871fd07934f179885aad16e8", size = 369116, upload-time = "2025-11-05T18:38:44.609Z" }, + { url = "https://files.pythonhosted.org/packages/17/e1/298c2ddf786bb7347a1cd71d63a347a79e5712a7c0cba9e3c3458ebd976f/brotli-1.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21", size = 863080, upload-time = "2025-11-05T18:38:45.503Z" }, + { url = "https://files.pythonhosted.org/packages/84/0c/aac98e286ba66868b2b3b50338ffbd85a35c7122e9531a73a37a29763d38/brotli-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac", size = 445453, upload-time = "2025-11-05T18:38:46.433Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f1/0ca1f3f99ae300372635ab3fe2f7a79fa335fee3d874fa7f9e68575e0e62/brotli-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e", size = 1528168, upload-time = "2025-11-05T18:38:47.371Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a6/2ebfc8f766d46df8d3e65b880a2e220732395e6d7dc312c1e1244b0f074a/brotli-1.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7", size = 1627098, upload-time = "2025-11-05T18:38:48.385Z" }, + { url = "https://files.pythonhosted.org/packages/f3/2f/0976d5b097ff8a22163b10617f76b2557f15f0f39d6a0fe1f02b1a53e92b/brotli-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63", size = 1419861, upload-time = "2025-11-05T18:38:49.372Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/d76df7176a2ce7616ff94c1fb72d307c9a30d2189fe877f3dd99af00ea5a/brotli-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b", size = 1484594, upload-time = "2025-11-05T18:38:50.655Z" }, + { url = "https://files.pythonhosted.org/packages/d3/93/14cf0b1216f43df5609f5b272050b0abd219e0b54ea80b47cef9867b45e7/brotli-1.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361", size = 1593455, upload-time = "2025-11-05T18:38:51.624Z" }, + { url = "https://files.pythonhosted.org/packages/b3/73/3183c9e41ca755713bdf2cc1d0810df742c09484e2e1ddd693bee53877c1/brotli-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888", size = 1488164, upload-time = "2025-11-05T18:38:53.079Z" }, + { url = "https://files.pythonhosted.org/packages/64/6a/0c78d8f3a582859236482fd9fa86a65a60328a00983006bcf6d83b7b2253/brotli-1.2.0-cp314-cp314-win32.whl", hash = "sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d", size = 339280, upload-time = "2025-11-05T18:38:54.02Z" }, + { url = "https://files.pythonhosted.org/packages/f5/10/56978295c14794b2c12007b07f3e41ba26acda9257457d7085b0bb3bb90c/brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3", size = 375639, upload-time = "2025-11-05T18:38:55.67Z" }, +] + +[[package]] +name = "brotli-asgi" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "brotli" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7b/df/b1fee43d30ac579f1faa5ff3773765927f2671794d647cc8f80aae96130b/brotli_asgi-1.6.0.tar.gz", hash = "sha256:f9985d99ecb082cf5e67486a58c27b7f39b2d3be8d9d13c38abc12328cedce9a", size = 5900, upload-time = "2026-01-02T08:00:53.146Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/8a/067e8546ea69e6999c2e7e6655acea039e9353ace0b8bd205a87991fb5c4/brotli_asgi-1.6.0-py3-none-any.whl", hash = "sha256:09d956bdc3cdfc495758fe6485f644731a9523a5f85696ea7a9227783ab363ef", size = 4847, upload-time = "2026-01-02T08:00:52.232Z" }, +] + +[[package]] +name = "buildpg" +version = "0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/48/f2/ff0e51a3c2390538da6eb4f85e30d87aafbcc6d057c6c9bb9fa222c8f2fc/buildpg-0.4.tar.gz", hash = "sha256:3a6c1f40fb6c826caa819d84727e36a1372f7013ba696637b492e5935916d479", size = 12493, upload-time = "2022-03-01T17:00:53.993Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/5a/c5ecd08a0c9b4dfece3b41aeefc3770968b4a2da1784941c9c8dd1c65347/buildpg-0.4-py3-none-any.whl", hash = "sha256:20d539976c81ea6f5529d3930016b0482ed0ff06def3d6da79d0fc0a3bbaeeb1", size = 11746, upload-time = "2022-03-01T17:00:52.19Z" }, +] + +[[package]] +name = "certifi" +version = "2026.4.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, +] + +[[package]] +name = "click" +version = "8.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cql2" +version = "0.5.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/4e/7fe4e5e8ead84e4adda9af4ea7b20773b47f357d66549d792803bd498b7b/cql2-0.5.6.tar.gz", hash = "sha256:90fd10cf222d15999899dc67b2310814574d7471fef6beb05ed25c2a9820ef09", size = 176636, upload-time = "2026-05-08T13:22:30.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/3c/ee4d7b6b5d2f2bae527d23bc8098e2b43279d3b4f382c83ac5b6f4b3c50a/cql2-0.5.6-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:714d597bf307372ba68284c895ec79c32ee81d3d6430ef041b3fb8a89ebcaf29", size = 3097993, upload-time = "2026-05-08T13:22:22.76Z" }, + { url = "https://files.pythonhosted.org/packages/c2/fb/33ca4ad5abc13df26339543c3d2833e5cce5006316edea539a45ddd7722c/cql2-0.5.6-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:7c1fc266664a3f68ce9fc94fb63ccb43ca41f031eda5c0aa6f12571b988153a2", size = 3056885, upload-time = "2026-05-08T13:22:20.836Z" }, + { url = "https://files.pythonhosted.org/packages/1e/36/687ddea044b238130b88742a8f2657d161f747b1664f58c40bb48413a547/cql2-0.5.6-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:362ca54af3794748fb5904aa183bdcdfb077a536dec55c29723bc0f0bfeb97c2", size = 3333320, upload-time = "2026-05-08T13:22:09.198Z" }, + { url = "https://files.pythonhosted.org/packages/c7/62/bf3c2080bcfb09de01d550e4dc792fa590aeb7939d63bdceb0a8ea1eedad/cql2-0.5.6-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:914a8eaa6609808d629ccc2a3825609bd72a7f2e4c4e2a8ed69af42943435be6", size = 3201076, upload-time = "2026-05-08T13:22:11.045Z" }, + { url = "https://files.pythonhosted.org/packages/ee/26/11eb534576b02e0844d76b6598ff33a34b1a02e93198e2950f43a21b46bb/cql2-0.5.6-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d360a9ab787af7110f63982ffe80a2d82b27a52fe7ce40af814b28bf5158c250", size = 3595586, upload-time = "2026-05-08T13:22:16.501Z" }, + { url = "https://files.pythonhosted.org/packages/0d/a2/1c824016b329171b62b99ae9e7d9f9680a7bcaefca850c69ad063d3c8495/cql2-0.5.6-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:662476f269fab592ceb0d9e4876499d72a0e3a132f7a764385dce62ba3d79fe1", size = 4301284, upload-time = "2026-05-08T13:22:12.607Z" }, + { url = "https://files.pythonhosted.org/packages/53/7f/7ac2eda713052f2a207e0981ebd942d4aa4ee508f8c179fc1cecb88a1393/cql2-0.5.6-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:240392a707d3a76db19b91c1185f56285d71b5aef3655a36a44709f83e2e74d3", size = 3278639, upload-time = "2026-05-08T13:22:15.025Z" }, + { url = "https://files.pythonhosted.org/packages/ae/82/d05aa9f07babfb73292719770188369b4c456349da6d8a30361b50815dc5/cql2-0.5.6-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6a480bf1261b93c049f251c741c7da33c21821799c85619188764e4a0f0d62b5", size = 3394476, upload-time = "2026-05-08T13:22:18.435Z" }, + { url = "https://files.pythonhosted.org/packages/ed/31/a75c7a8c634eb5509a90a8a5ad29936be483bdb11a765c5895eb056066be/cql2-0.5.6-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:370baf8fd0a54973531fbdb2cb78f279085d24d94454d199e2397f19d61b4abc", size = 3506307, upload-time = "2026-05-08T13:22:24.145Z" }, + { url = "https://files.pythonhosted.org/packages/9e/45/00353b1516a1a8c0326f11d9e442b5d88025523bef6ea5a6a14e2f490fe0/cql2-0.5.6-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:526e2e371469e775d3affed20a033e3924dcded8d022ca7e4f28dd73dce77e27", size = 3474116, upload-time = "2026-05-08T13:22:25.721Z" }, + { url = "https://files.pythonhosted.org/packages/0e/1d/99da637a52fcfa46e5f871ea2edc5b93158fadc0c556b9ab4cf565f226ed/cql2-0.5.6-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:4f0e3e333be08430ef68d1db9731726c9ec5714b53ba989fb639720dff99d24d", size = 3661512, upload-time = "2026-05-08T13:22:27.602Z" }, + { url = "https://files.pythonhosted.org/packages/a0/ea/c13b0c3a80e71f51602459510c05592d1a67cb2b780d1d5ed6e6cfe54fe0/cql2-0.5.6-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d657b1a571c482bbd2c8f124b9f1730ffd433a57ef61caa7855c56af998b4b78", size = 3637784, upload-time = "2026-05-08T13:22:29.248Z" }, + { url = "https://files.pythonhosted.org/packages/e4/44/411b8da44d983e140721327ecf54b48db523f0345cd060135ebbf40e0eff/cql2-0.5.6-cp310-abi3-win32.whl", hash = "sha256:7d9cbb92d372d5f5516f35ab34ea6c62ca3f88f184f7a16968fc373844374443", size = 2400943, upload-time = "2026-05-08T13:22:33.403Z" }, + { url = "https://files.pythonhosted.org/packages/3c/93/154e095f3d9af711c62e659dffaf3681464b566ec9cff375d8565e6e512d/cql2-0.5.6-cp310-abi3-win_amd64.whl", hash = "sha256:074672d50812b7c73110e70175d3c575a096540bdd947925f43a4b8a6a5fd10f", size = 2601041, upload-time = "2026-05-08T13:22:31.908Z" }, +] + +[[package]] +name = "cramjam" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/68/09b6b5603d21a0c7d4362d513217a5079c47b1b7a88967c52dbef13db183/cramjam-2.9.1.tar.gz", hash = "sha256:336cc591d86cbd225d256813779f46624f857bc9c779db126271eff9ddc524ae", size = 47892, upload-time = "2024-12-12T13:40:44.838Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/66/69a1c17331e38b02c78c923262fc315272de7c2618ef7eac8b3358969d90/cramjam-2.9.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:79417957972553502b217a0093532e48893c8b4ca30ccc941cefe9c72379df7c", size = 2132273, upload-time = "2024-12-12T13:38:05.648Z" }, + { url = "https://files.pythonhosted.org/packages/3d/17/23d0b1d3301480e924545cdd27f2b949c50438949f64c74e800a09c12c37/cramjam-2.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce2b94117f373defc876f88e74e44049a9969223dbca3240415b71752d0422fb", size = 1926919, upload-time = "2024-12-12T13:38:08.928Z" }, + { url = "https://files.pythonhosted.org/packages/8e/da/e9565f4abbbaa14645ccd7ce83f9631e90955454b87dc3ef9208aebc72e6/cramjam-2.9.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:67040e0fd84404885ec716a806bee6110f9960c3647e0ef1670aab3b7375a70a", size = 2271776, upload-time = "2024-12-12T13:38:11.096Z" }, + { url = "https://files.pythonhosted.org/packages/88/ac/e6e0794ac01deb52e7a6a3e59720699abdee08d9b9c63a8d8874201d8155/cramjam-2.9.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bedb84e068b53c944bd08dcb501fd00d67daa8a917922356dd559b484ce7eab", size = 2109248, upload-time = "2024-12-12T13:38:14.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/0f/c3724b2dcdfbe7e07917803cf7a6db4a874818a6f8d2b95ca1ceaf177170/cramjam-2.9.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:06e3f97a379386d97debf08638a78b3d3850fdf6124755eb270b54905a169930", size = 2088611, upload-time = "2024-12-12T13:38:17.464Z" }, + { url = "https://files.pythonhosted.org/packages/ce/16/929a5ae899ad6298f58e66622dc223476fe8e1d4e8dae608f4e1a34bfd09/cramjam-2.9.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:11118675e9c7952ececabc62f023290ee4f8ecf0bee0d2c7eb8d1c402ee9769d", size = 2438373, upload-time = "2024-12-12T13:38:20.419Z" }, + { url = "https://files.pythonhosted.org/packages/2a/2a/ad473f1ca65d3285e8c1d99fc0289f5856224c0d452dabcf856fd4dcdd77/cramjam-2.9.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6b7de6b61b11545570e4d6033713f3599525efc615ee353a822be8f6b0c65b77", size = 2836669, upload-time = "2024-12-12T13:38:22.403Z" }, + { url = "https://files.pythonhosted.org/packages/9b/5a/e9b4868ee27099a2a21646cf5ea5cf08c660eae90b55a395ada974dcf3fb/cramjam-2.9.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:57ca8f3775324a9de3ee6f05ca172687ba258c0dea79f7e3a6b4112834982f2a", size = 2343995, upload-time = "2024-12-12T13:38:24.266Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c4/870a9b4524107bf85a207b82a42613318881238b20f2d237e62815af646a/cramjam-2.9.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9847dd6f288f1c56359f52acb48ff2df848ff3e3bff34d23855bbcf7016427cc", size = 2374270, upload-time = "2024-12-12T13:38:29.136Z" }, + { url = "https://files.pythonhosted.org/packages/70/4b/b69e8e3951b7cec5e7da2539b7573bb396bed66af07d760b1878b00fd120/cramjam-2.9.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d1248dfa7f151e893ce819670f00879e4b7650b8d4c01279ce4f12140d68dd2", size = 2388789, upload-time = "2024-12-12T13:38:31.194Z" }, + { url = "https://files.pythonhosted.org/packages/05/1a/af02f6192060413314735c0db61259d7279b0d8d99eee29eff2af09c5892/cramjam-2.9.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:9da6d970281083bae91b914362de325414aa03c01fc806f6bb2cc006322ec834", size = 2402459, upload-time = "2024-12-12T13:38:33.831Z" }, + { url = "https://files.pythonhosted.org/packages/20/9a/a4ab3e90d72eb4f2c1b983fa32b4050ba676f533ba15bd78158f0632295a/cramjam-2.9.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1c33bc095db5733c841a102b8693062be5db8cdac17b9782ebc00577c6a94480", size = 2518440, upload-time = "2024-12-12T13:38:37.385Z" }, + { url = "https://files.pythonhosted.org/packages/35/3b/e632dd7e2c5c8a2af2d83144b00d6840f1afcf9c6959ed59ec5b0f925288/cramjam-2.9.1-cp312-cp312-win32.whl", hash = "sha256:9e9193cd4bb57e7acd3af24891526299244bfed88168945efdaa09af4e50720f", size = 1822630, upload-time = "2024-12-12T13:38:39.385Z" }, + { url = "https://files.pythonhosted.org/packages/0e/a2/d1c46618b81b83578d58a62f3709046c4f3b4ddba10df4b9797cfe096b98/cramjam-2.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:15955dd75e80f66c1ea271167a5347661d9bdc365f894a57698c383c9b7d465c", size = 2094684, upload-time = "2024-12-12T13:38:41.345Z" }, + { url = "https://files.pythonhosted.org/packages/85/45/f1d1e6ffdceb3b0c18511df2f8e779e03972459fb71d7c1ab0f6a5c063a3/cramjam-2.9.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5a7797a2fff994fc5e323f7a967a35a3e37e3006ed21d64dcded086502f482af", size = 2131814, upload-time = "2024-12-12T13:38:43.484Z" }, + { url = "https://files.pythonhosted.org/packages/3a/96/36bbd431fbf0fa2ff51fd2db4c3bead66e9e373693a8455d411d45125a68/cramjam-2.9.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d51b9b140b1df39a44bff7896d98a10da345b7d5f5ce92368d328c1c2c829167", size = 1926380, upload-time = "2024-12-12T13:38:46.749Z" }, + { url = "https://files.pythonhosted.org/packages/67/c4/99b6507ec697d5f56d32c9c04614775004b05b7fa870725a492dc6b639eb/cramjam-2.9.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:07ac76b7f992556e7aa910244be11ece578cdf84f4d5d5297461f9a895e18312", size = 2271581, upload-time = "2024-12-12T13:38:48.563Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1b/6d55dff244fb22c0b686dd5a96a754c0638f8a94056beb27c457c6035cc5/cramjam-2.9.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d90a72608c7550cd7eba914668f6277bfb0b24f074d1f1bd9d061fcb6f2adbd6", size = 2109255, upload-time = "2024-12-12T13:38:50.436Z" }, + { url = "https://files.pythonhosted.org/packages/ca/fb/b9fcf492a21a8d978c6f999025fce2c6656399448c017ed2fc859425f37f/cramjam-2.9.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56495975401b1821dbe1f29cf222e23556232209a2fdb809fe8156d120ca9c7f", size = 2088323, upload-time = "2024-12-12T13:38:52.254Z" }, + { url = "https://files.pythonhosted.org/packages/88/1f/69b523395aeaa201dbd53d203453288205a0c651e7c910161892d694eb4d/cramjam-2.9.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b695259e71fde6d5be66b77a4474523ced9ffe9fe8a34cb9b520ec1241a14d3", size = 2437930, upload-time = "2024-12-12T13:38:55.081Z" }, + { url = "https://files.pythonhosted.org/packages/b0/2c/d07e802f1786c4082e8286db1087563e4fab31cd6534ed31523f1f9584d1/cramjam-2.9.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ab1e69dc4831bbb79b6d547077aae89074c83e8ad94eba1a3d80e94d2424fd02", size = 2836655, upload-time = "2024-12-12T13:38:58.323Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f5/6b425e82395c078bc95a7437b685e6bdba39d28c2b2986d79374fc1681aa/cramjam-2.9.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:440b489902bfb7a26d3fec1ca888007615336ff763d2a32a2fc40586548a0dbf", size = 2387107, upload-time = "2024-12-12T13:39:01.892Z" }, + { url = "https://files.pythonhosted.org/packages/33/65/7bf97d89ba7607aaea5464af6f249e3d94c291acf73d72768367a3e361c0/cramjam-2.9.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:217fe22b41f8c3dce03852f828b059abfad11d1344a1df2f43d3eb8634b18d75", size = 2374006, upload-time = "2024-12-12T13:39:03.993Z" }, + { url = "https://files.pythonhosted.org/packages/29/11/8b6c82eda6d0affbc15d7ab4dc758856eb4308e8ddae73300c1648f5aa0f/cramjam-2.9.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:95f3646ddc98af25af25d5692ae65966488a283813336ea9cf41b22e542e7c0d", size = 2388731, upload-time = "2024-12-12T13:39:05.996Z" }, + { url = "https://files.pythonhosted.org/packages/48/25/6cdd57c0b1a83c98aec9029310d09a6c1a31e9e9fb8efd9001bd0cbea992/cramjam-2.9.1-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:6b19fc60ead1cae9795a5b359599da3a1c95d38f869bdfb51c441fd76b04e926", size = 2402131, upload-time = "2024-12-12T13:39:08Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e7/cbf80c9647fa582432aa833c4bdd20cf437917c8066ce653e3b78deff658/cramjam-2.9.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:8dc5207567459d049696f62a1fdfb220f3fe6aa0d722285d44753e12504dac6c", size = 2555296, upload-time = "2024-12-12T13:39:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/18/a6/fabe1959a980f5d2783a6c138311509dd168bd76e62018624a91cd1cbb41/cramjam-2.9.1-cp313-cp313-win32.whl", hash = "sha256:fbfe35929a61b914de9e5dbacde0cfbba86cbf5122f9285a24c14ed0b645490b", size = 1822484, upload-time = "2024-12-12T13:39:13.539Z" }, + { url = "https://files.pythonhosted.org/packages/55/d5/24e4562771711711c466768c92097640ed97b0283abe9043ffb6c6d4cf04/cramjam-2.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:06068bd191a82ad4fc1ac23d6f8627fb5e37ec4be0431711b9a2dbacaccfeddb", size = 2094445, upload-time = "2024-12-12T13:39:15.421Z" }, +] + +[[package]] +name = "eoapi-stac" +source = { editable = "." } +dependencies = [ + { name = "mangum" }, + { name = "pydantic-settings" }, + { name = "stac-fastapi-pgstac" }, + { name = "starlette-cramjam" }, +] + +[package.dev-dependencies] +dev = [ + { name = "httpx" }, + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "mangum", specifier = "==0.19" }, + { name = "pydantic-settings", specifier = ">=2,<3" }, + { name = "stac-fastapi-pgstac", specifier = ">=6.2,<6.3" }, + { name = "starlette-cramjam", specifier = ">=0.4,<0.5" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "httpx", specifier = ">=0.28.1" }, + { name = "pytest", specifier = ">=9.0.3" }, +] + +[[package]] +name = "fastapi" +version = "0.136.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/45/c130091c2dfa061bbfe3150f2a5091ef1adf149f2a8d2ae769ecaf6e99a2/fastapi-0.136.1.tar.gz", hash = "sha256:7af665ad7acfa0a3baf8983d393b6b471b9da10ede59c60045f49fbc89a0fa7f", size = 397448, upload-time = "2026-04-23T16:49:44.046Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" }, +] + +[[package]] +name = "geojson-pydantic" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/76/77c6f2e608028a2885e4d9d9a28cb6801879972d4bb3106c1dca02201df6/geojson_pydantic-2.1.1.tar.gz", hash = "sha256:3b64fa2dcd98108ff8a19bfb01eee3dab41bc230be2481804aefd7c1659d1c23", size = 8156, upload-time = "2026-04-07T09:48:07.252Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/97/d59d999f00aec90fc7a4efb742fc11e6c160552aa1b313438d3497998644/geojson_pydantic-2.1.1-py3-none-any.whl", hash = "sha256:55354f22ededc3c070e3210fec6f518d784b65c4368c1763f2c6dc4bab79b898", size = 9456, upload-time = "2026-04-07T09:48:05.772Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "hydraters" +version = "0.1.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/ca/dadb0e01677b47ba9a71bfebbcb47c3236d5f8cb7abccfe969278f59cedb/hydraters-0.1.3.tar.gz", hash = "sha256:d5b60a535cc911e7f42875e26ed2a39014b6d22dd0c4f3f441cae3a3b0e6baf3", size = 95732, upload-time = "2025-10-27T19:32:12.194Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/c065a89387259e8f284a85f417e22e1bdda45dc081a54da4cf24b95e1213/hydraters-0.1.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:36e12d7d425076be040914f40b69109e451473fd809f827eee6c4122adb26640", size = 220772, upload-time = "2025-10-27T19:31:26.699Z" }, + { url = "https://files.pythonhosted.org/packages/16/07/13e6361f61a24eae6e7531264312ca1bb1008cb4a4e077fcb3bc62c97817/hydraters-0.1.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:97743c9102f522441489fe50ab91b986508d6d08c782adcdb48b755fa6fb85bd", size = 213779, upload-time = "2025-10-27T19:31:21.642Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7f/a153ca7847072ad68631715d853c3726d4ddbd51f1b6c8593abadcf5b420/hydraters-0.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08207aabf3023f6be2709f8cdc6c13e2597c38c4116acca1dbf5fb49898da45d", size = 239934, upload-time = "2025-10-27T19:30:20.867Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d3/c153ddac7d4682f64c85fa19b233819730870753ee40016d4ec8f9f30157/hydraters-0.1.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:16b97157193236232b620ddbfae8cd1f01c85b3162792c879d79b3e38124606b", size = 249126, upload-time = "2025-10-27T19:30:32.473Z" }, + { url = "https://files.pythonhosted.org/packages/53/56/62f79e65e84a49d5d46f1d858f01e33e13352151e6eeb610cb5d22442382/hydraters-0.1.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f92f661ed47e9e26ecfb94f52afc7712e3f62705f36ce6e68c4d2acc8586c08", size = 384590, upload-time = "2025-10-27T19:30:42.344Z" }, + { url = "https://files.pythonhosted.org/packages/8b/93/1c669ced9ad0cf9b9df30d1a73fa542cb7fcaf9d66e08467a97944d98a65/hydraters-0.1.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4fa47f4be90d4712ecdf3dfed8161d9e874f8ea8dcf56dae40dca458d1ffd972", size = 269407, upload-time = "2025-10-27T19:30:53.028Z" }, + { url = "https://files.pythonhosted.org/packages/1d/b2/dc5d3d66c94129b8cbbb53bbc210090ef721eecd7f67f861286204a29ede/hydraters-0.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:908e390c4c9acf197993c403836d66e27c23baf6cdd7ec8ac1334e657b0144bc", size = 247803, upload-time = "2025-10-27T19:31:12.56Z" }, + { url = "https://files.pythonhosted.org/packages/dd/19/97574bf14990e9ae3775ee50f29c53d869a0d3e7986df4461938c1530bb4/hydraters-0.1.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4cd7dc42df7ac0875acfa852900f74fa1a28d22e413d4c62f9f22a2055f8edd9", size = 258436, upload-time = "2025-10-27T19:31:03.133Z" }, + { url = "https://files.pythonhosted.org/packages/a0/9f/9ee6ac36e71f4b323ed3f9b3cb47d687b34fdfb97b54fab98111dc985d91/hydraters-0.1.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f84e967631033102bfabd4d36c75eeba145b667a1593f11fcdd0e042c45075c2", size = 420283, upload-time = "2025-10-27T19:31:32.294Z" }, + { url = "https://files.pythonhosted.org/packages/44/5f/63069d1f431a8df0dedff63d55ff0c67dc7157982dbee05b5faac2696996/hydraters-0.1.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d28daf0c040ac8a9d03cf04b5ef2f9f65d286340dcecc1c9c9e1f7cdb0ef63e0", size = 512864, upload-time = "2025-10-27T19:31:41.861Z" }, + { url = "https://files.pythonhosted.org/packages/5e/cb/376061358062a607b97111fdf87774b42d87025785f971b219707c849845/hydraters-0.1.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c5beb9d7639b873f7f2afe562fdd2934d864f99a56f007d31b11bad9c32bae9f", size = 438142, upload-time = "2025-10-27T19:31:52.776Z" }, + { url = "https://files.pythonhosted.org/packages/48/2a/323c7a0d2e4b79dcd6a0bd5a1df06ad7d4a1dcacad17a96c6ef38675a9ff/hydraters-0.1.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9dbedfd0ef43811c49c7fe530e74a6c3e16116bd233ce35ee4243835fb807f4c", size = 409586, upload-time = "2025-10-27T19:32:02.641Z" }, + { url = "https://files.pythonhosted.org/packages/05/e2/b915dcf4b27fdcd04c4bf7214fd40f0fee113f8ba6390a6009f68d9e369b/hydraters-0.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:44614e67823ddf40f5599e3b83f863c535dbc817c4d7dd125f2d7e0cbb104178", size = 109393, upload-time = "2025-10-27T19:32:15.397Z" }, + { url = "https://files.pythonhosted.org/packages/de/13/2e1bac20baea51a9cf570d834ec212617bc90e146a05d6410d56239d5261/hydraters-0.1.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:210fb5095e4a43473c46095f4e56e847f6829b1aabf57bea4656f16030b4815b", size = 220426, upload-time = "2025-10-27T19:31:28.247Z" }, + { url = "https://files.pythonhosted.org/packages/59/da/d25b72c7982e796a444e15fa55c1ebe8c2821eb0207658ae28c4109c6c04/hydraters-0.1.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:397d9a9e41d81012a85ae0325b1ad52c66bf6099e231ad63ce1db2c304b0d8a1", size = 213516, upload-time = "2025-10-27T19:31:22.909Z" }, + { url = "https://files.pythonhosted.org/packages/a8/60/526afd05c96cb91ac90c6603a64e3461e38cb3edf5c7768a7b61ac38d3c1/hydraters-0.1.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79e8c5c366c3aac6c412f3ff081cfe2f791f84c2d0a60bdc6fc7c786b3cd07c3", size = 239739, upload-time = "2025-10-27T19:30:22.444Z" }, + { url = "https://files.pythonhosted.org/packages/ef/eb/48af59c0771a7552e87f4b583fa7e21afdcb17d903527b5f53e374bbebc0/hydraters-0.1.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dee4b03c4586dc735279bbeb6410dbfd75e61b650f40afb6edd80cd5c6d375e8", size = 248905, upload-time = "2025-10-27T19:30:33.95Z" }, + { url = "https://files.pythonhosted.org/packages/50/b4/494deced877176c77f9155539f1826a1c1b785a1610072fb5dc0cb8c14ff/hydraters-0.1.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8dbba2b794a071670a46a5ab69c812eabaddc058fc7ea23530a2c88a22dba3a9", size = 383697, upload-time = "2025-10-27T19:30:44.03Z" }, + { url = "https://files.pythonhosted.org/packages/ed/43/5c0aabb8e5ec3a53cc723853aef484a5a8e8e8992f65d80725598839759c/hydraters-0.1.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18f3ecb740a638f4ac80524e59cc53e07aac6251665faefb47345c93a47c0717", size = 269271, upload-time = "2025-10-27T19:30:54.222Z" }, + { url = "https://files.pythonhosted.org/packages/32/e5/6c38922814f31ba24a904cc52acbc98f200803f817e9a00dc8557d282908/hydraters-0.1.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:256b51fcffe2473ee0a16117e5e8660278d38cfa4a607276241bfcccb6510cd6", size = 247518, upload-time = "2025-10-27T19:31:13.845Z" }, + { url = "https://files.pythonhosted.org/packages/0b/d6/b296999fc6f9b4bbe55c9bb3dfb614ae53291ca0327a174a9c898f27c629/hydraters-0.1.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:01af0bec141c86bed5662c57c2029912411b17d0608390e36b8c1f6991662842", size = 258390, upload-time = "2025-10-27T19:31:04.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/a6/3f9e8e5c4459cfcb2fd949030df3b119345d544e45aa4a24ffba0e0823d5/hydraters-0.1.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ea025d64fa30b9e8b716d8b810f5eed8056f136d34016677da085da3d62835ec", size = 420114, upload-time = "2025-10-27T19:31:33.577Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6a/6b91766e312f17ab9b2bc0d353f9fd272e7e9cd489c9f86027275a195caf/hydraters-0.1.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8620a3493dfafb9317cda7f6c8ce9eb80d53bcd59feef79228c2e1590f5b3bd6", size = 512653, upload-time = "2025-10-27T19:31:43.368Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c8/39899df4da19ed2c01bd4aa0f2f58ecc107335430654c13a9910efbd8692/hydraters-0.1.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:3b4c12c7cd8a582decf6a6f57fe85e75eb1046e28a48075995dcefcca11cf009", size = 437930, upload-time = "2025-10-27T19:31:54.13Z" }, + { url = "https://files.pythonhosted.org/packages/bb/24/250943a5013e064d7fc3f9939c6b25281abfe2cfddbdfb7121ff394524b2/hydraters-0.1.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a4d3ab310279af941831a50efad346b5cffcb688aa0644477eea92a724c830ee", size = 409535, upload-time = "2025-10-27T19:32:05.266Z" }, + { url = "https://files.pythonhosted.org/packages/f5/58/d69abce2ecc0547297f0b6952c16a587ef26be8a00fa7ea7638e283dc4f8/hydraters-0.1.3-cp313-cp313-win_amd64.whl", hash = "sha256:a718e570a390cc1fd625be039c4fb1979c8236e5996ccb1272c95659f0eca246", size = 109110, upload-time = "2025-10-27T19:32:16.494Z" }, + { url = "https://files.pythonhosted.org/packages/de/5e/ae2465a2b3043eef9ca306c9542d858ef74c3124f79060edf21343eac922/hydraters-0.1.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f3d1b0cbea0d935a55d139617956feaf433e68d229239b0e2cdc22e6566d891", size = 238086, upload-time = "2025-10-27T19:30:23.655Z" }, + { url = "https://files.pythonhosted.org/packages/36/e5/6fa3f1a62efd9dbb75faa1ba621945573e5a48bfff35294a8ccea91913a0/hydraters-0.1.3-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:88dbaf32a311ae1b6249f67e6c56f7d9078cb1c9ffdb3e4e4b5aec04dfb0bc22", size = 247478, upload-time = "2025-10-27T19:30:35.27Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/7d4fbea5d9a299ee88b5e708ba5d6fa0fa949093249996f8ee7d8bc0242f/hydraters-0.1.3-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ff1d32b5bf5cf60e52bd72a74410745d79b2acde43394dbcc707084171bd6ba8", size = 384876, upload-time = "2025-10-27T19:30:45.413Z" }, + { url = "https://files.pythonhosted.org/packages/80/1e/1c4877c9b15c9a9f9ba8242af1b1dc3fb644711b8a58e03e63872eecbee5/hydraters-0.1.3-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2072dbf3165ef6d6a5ecdb7161207358926679b74d8aa285f151974879f0ad5a", size = 268190, upload-time = "2025-10-27T19:30:55.794Z" }, + { url = "https://files.pythonhosted.org/packages/a2/4f/65f95fdf6c6939f131f7e903606792ecefc64700476819cf0c23dcc48e86/hydraters-0.1.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8bfd5ee925e525861c4513afd4aaa56455ca5f9f3daf458846de4888860bfe8c", size = 418617, upload-time = "2025-10-27T19:31:34.949Z" }, + { url = "https://files.pythonhosted.org/packages/b1/cc/87033e41f13fd4bdc003248a6fe65e880c406581605de7d2234c15a0317b/hydraters-0.1.3-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:0249bcfbdfca7bd41103bb162c3f51af200d430ff3203f9dced35c62ec5ecc72", size = 511151, upload-time = "2025-10-27T19:31:44.772Z" }, + { url = "https://files.pythonhosted.org/packages/ca/27/5c9bcebf946842d8289f1591407b3a1387541eb220cf27c9b32378037f03/hydraters-0.1.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3a96e9b0a3be6ec2064f8556406fc4f225237fcaed1c118837a0ac9be2e62692", size = 437101, upload-time = "2025-10-27T19:31:55.632Z" }, + { url = "https://files.pythonhosted.org/packages/ce/53/558519cd9b2888ef2859bef96d7338930377f3f629ec0f3b4df242083ec7/hydraters-0.1.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e4add7c611aae45e17eebda71f5d5d918dbb5515f4cbf33a3e8635a915f9b94", size = 408715, upload-time = "2025-10-27T19:32:06.629Z" }, + { url = "https://files.pythonhosted.org/packages/60/18/f5df44b3883295c342f8ba8bb7478ce36bd8de0513bc5c9d2aafa517dd88/hydraters-0.1.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:015770cba7cee9899f15fafc9486b77293c65dfd091da167d529546c8f2a4eee", size = 212845, upload-time = "2025-10-27T19:31:24.147Z" }, + { url = "https://files.pythonhosted.org/packages/4a/65/6f775dd489fb85d5c2dbb9f50f7570130753b6a66b0263e9bc5dc0e42246/hydraters-0.1.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab670257e094d42e7cee1886109ea5af8ccb40eb4a3e8fc574550cad864276c2", size = 246841, upload-time = "2025-10-27T19:31:15.101Z" }, + { url = "https://files.pythonhosted.org/packages/73/c1/0f2adcb91f0066b2ddcb59922adb2423a6b54d99d8c9169fea3f246e3082/hydraters-0.1.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0ed6a9760a7c5dd9323b354d26fc93b7819257f072300897efd37b518babbb9b", size = 257948, upload-time = "2025-10-27T19:31:05.641Z" }, + { url = "https://files.pythonhosted.org/packages/9e/37/c894a1e3cc2c19ac92d401957d703a620f726d2bab32c376c05c5ce3c75a/hydraters-0.1.3-cp314-cp314-win32.whl", hash = "sha256:be4492c6ed1d96cfa2cbc76b8e63163b878c51165c53712033906fb4539fd63a", size = 104209, upload-time = "2025-10-27T19:32:20.289Z" }, + { url = "https://files.pythonhosted.org/packages/08/78/0ab4d10a51f512fa607501b67b1db53389465457b65cd275891537aebb7c/hydraters-0.1.3-cp314-cp314-win_amd64.whl", hash = "sha256:0475e847adad810228f544da7b5e3ae9ab5c49e91f6fe37d39e47ac5e847a426", size = 109215, upload-time = "2025-10-27T19:32:17.724Z" }, +] + +[[package]] +name = "idna" +version = "3.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +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 = "iso8601" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/f3/ef59cee614d5e0accf6fd0cbba025b93b272e626ca89fb70a3e9187c5d15/iso8601-2.1.0.tar.gz", hash = "sha256:6b1d3829ee8921c4301998c909f7829fa9ed3cbdac0d3b16af2d743aed1ba8df", size = 6522, upload-time = "2023-10-03T00:25:39.317Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/0c/f37b6a241f0759b7653ffa7213889d89ad49a2b76eb2ddf3b57b2738c347/iso8601-2.1.0-py3-none-any.whl", hash = "sha256:aac4145c4dcb66ad8b648a02830f5e2ff6c24af20f4f482689be402db2429242", size = 7545, upload-time = "2023-10-03T00:25:32.304Z" }, +] + +[[package]] +name = "json-merge-patch" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/eb/776e896abba05e810022a2ddd907f0b761164330f259279079ee81fe1898/json_merge_patch-0.3.0.tar.gz", hash = "sha256:4a022d78fc2f09cb49d96c646efc380d3b5ead2b5c7dabe22c3928c2c2e9c4e0", size = 5026, upload-time = "2025-03-25T09:48:41.542Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/c1/fb2c66d92b5d0167c57042b784456ee3f8531a997726c88cf6f012a22da6/json_merge_patch-0.3.0-py3-none-any.whl", hash = "sha256:e0a593719b293ff63858ecaae3afbcb4ff0b57f785453c6783d7b0c3e2708b76", size = 5482, upload-time = "2025-03-25T09:48:40.4Z" }, +] + +[[package]] +name = "jsonpatch" +version = "1.33" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonpointer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/78/18813351fe5d63acad16aec57f94ec2b70a09e53ca98145589e185423873/jsonpatch-1.33.tar.gz", hash = "sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c", size = 21699, upload-time = "2023-06-26T12:07:29.144Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/07/02e16ed01e04a374e644b575638ec7987ae846d25ad97bcc9945a3ee4b0e/jsonpatch-1.33-py2.py3-none-any.whl", hash = "sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade", size = 12898, upload-time = "2023-06-16T21:01:28.466Z" }, +] + +[[package]] +name = "jsonpointer" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/c7/af399a2e7a67fd18d63c40c5e62d3af4e67b836a2107468b6a5ea24c4304/jsonpointer-3.1.1.tar.gz", hash = "sha256:0b801c7db33a904024f6004d526dcc53bbb8a4a0f4e32bfd10beadf60adf1900", size = 9068, upload-time = "2026-03-23T22:32:32.458Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/6a/a83720e953b1682d2d109d3c2dbb0bc9bf28cc1cbc205be4ef4be5da709d/jsonpointer-3.1.1-py3-none-any.whl", hash = "sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca", size = 7659, upload-time = "2026-03-23T22:32:31.568Z" }, +] + +[[package]] +name = "mangum" +version = "0.19.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/97/e0/6ee9bfa27226252a449cba12fc57d3f1c3ce661813377ab33e29245389a4/mangum-0.19.0.tar.gz", hash = "sha256:e388e7c491b7b67970f8234e46fd4a7b21ff87785848f418de08148f71cf0bd6", size = 85792, upload-time = "2024-09-26T20:44:49.773Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/ec/dd1cae5f6b1b4a08c01de587b45e889036b2f8c06408621e0cb273909965/mangum-0.19.0-py3-none-any.whl", hash = "sha256:e500b35f495d5e68ac98bc97334896d6101523f2ee2c57ba6a61893b65266e59", size = 17083, upload-time = "2024-09-26T20:44:48.357Z" }, +] + +[[package]] +name = "orjson" +version = "3.11.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/0c/964746fcafbd16f8ff53219ad9f6b412b34f345c75f384ad434ceaadb538/orjson-3.11.9.tar.gz", hash = "sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f", size = 5599163, upload-time = "2026-05-06T15:11:08.309Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/6d/11867a3ffa3a3608d84a4de51ef4dd0896d6b5cc9132fbe1daf593e677bc/orjson-3.11.9-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9ef6fe90aadef185c7b128859f40beb24720b4ecea95379fc9000931179c3a49", size = 228515, upload-time = "2026-05-06T15:09:57.265Z" }, + { url = "https://files.pythonhosted.org/packages/24/75/05912954c8b288f34fcf5cd4b9b071cb4f6e77b9961e175e56ebb258089f/orjson-3.11.9-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:e5c9b8f28e726e97d97696c826bc7bea5d71cecd63576dba92924a32c1961291", size = 128409, upload-time = "2026-05-06T15:09:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/ab/86/1c3a47df3bc8191ea9ac51603bbb872a95167a364320c269f2557911f406/orjson-3.11.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26a473dbb4162108b27901492546f83c76fdcea3d0eadff00ae7a07e18dcce09", size = 132106, upload-time = "2026-05-06T15:10:00.798Z" }, + { url = "https://files.pythonhosted.org/packages/d7/cf/b33b5f3e695ae7d63feef9d915c37cc3b8f465493dcd4f8e0b4c697a2366/orjson-3.11.9-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:011382e2a60fda9d46f1cdee31068cfc52ffe952b587d683ec0463002802a0f4", size = 127864, upload-time = "2026-05-06T15:10:02.15Z" }, + { url = "https://files.pythonhosted.org/packages/31/6a/6cf69385a58208024fcb8c014e2141b8ce838aba6492b589f8acfff97fab/orjson-3.11.9-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c2d3dc759490128c5c1711a53eeaa8ee1d437fd0038ffd2b6008abf46db3f882", size = 135213, upload-time = "2026-05-06T15:10:03.515Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f8/0b1bd3e8f2efcdd376af5c8cfd79eaf13f018080c0089c80ebd724e3c7fb/orjson-3.11.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8ea516b3726d190e1b4297e6f4e7a8650347ae053868a18163b4dd3641d1fff", size = 145994, upload-time = "2026-05-06T15:10:05.083Z" }, + { url = "https://files.pythonhosted.org/packages/f3/59/dab79f61044c529d2c81aecdc589b1f833a1c8dec11ba3b1c2498a02ca7e/orjson-3.11.9-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:380cdce7ba24989af81d0a7013d0aaec5d0e2a21734c0e2681b1bc4f141957fe", size = 132744, upload-time = "2026-05-06T15:10:06.853Z" }, + { url = "https://files.pythonhosted.org/packages/0e/a4/82b7a2fe5d8a67a59ed831b24d59a3d46ea7d207b66e1602d376541d94a6/orjson-3.11.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be4fa4f0af7fa18951f7ab3fc2148e223af211bf03f59e1c6034ec3f97f21d61", size = 134014, upload-time = "2026-05-06T15:10:08.213Z" }, + { url = "https://files.pythonhosted.org/packages/50/c7/375e83a76851b73b2e39f3bcf0e5a19e2b89bad13e5bca97d0b293d27f24/orjson-3.11.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a8f5f8bc7ce7d59f08d9f99fa510c06496164a24cb5f3d34537dbd9ca30132e2", size = 141509, upload-time = "2026-05-06T15:10:09.595Z" }, + { url = "https://files.pythonhosted.org/packages/7f/7c/49d5d82a3d3097f641f094f552131f1e2723b0b8cb0fa2874ab65ecfffa6/orjson-3.11.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:4d7fde5501b944f83b3e665e1b31343ff6e154b15560a16b7130ea1e594a4206", size = 415127, upload-time = "2026-05-06T15:10:11.049Z" }, + { url = "https://files.pythonhosted.org/packages/3a/dc/7446c538590d55f455647e5f3c61fc33f7108714e7afcffa6a2a033f8350/orjson-3.11.9-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cde1a448023ba7d5bb4c01c5afb48894380b5e4956e0627266526587ef4e535f", size = 148025, upload-time = "2026-05-06T15:10:12.842Z" }, + { url = "https://files.pythonhosted.org/packages/df/e5/4d2d8af06f788329b4f78f8cc3679bb395392fcaa1e4d8d3c33e85308fa4/orjson-3.11.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e63adb0e1f1ed5d9e168f50a91ceb93ae6420731d222dc7da5c69409aa47aa", size = 136943, upload-time = "2026-05-06T15:10:14.405Z" }, + { url = "https://files.pythonhosted.org/packages/06/69/850264ccf6d80f6b174620d30a87f65c9b1490aba33fe6b62798e618cad3/orjson-3.11.9-cp312-cp312-win32.whl", hash = "sha256:2d057a602cdd19a0ad680417527c45b6961a095081c0f46fe0e03e304aac6470", size = 131606, upload-time = "2026-05-06T15:10:15.791Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/973a43fc9c55e20f2051e9830997649f669be0cb3ca52192087c0143f118/orjson-3.11.9-cp312-cp312-win_amd64.whl", hash = "sha256:59e403b1cc5a676da8eaf31f6254801b7341b3e29efa85f92b48d272637e77be", size = 127101, upload-time = "2026-05-06T15:10:17.129Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ae/495470f0e4a18f73fa10b7f6b84b464ec4cc5291c4e0c7c2a6c400bef006/orjson-3.11.9-cp312-cp312-win_arm64.whl", hash = "sha256:9af678d6488357948f1f84c6cd1c1d397c014e1ae2f98ae082a44eb48f602624", size = 126736, upload-time = "2026-05-06T15:10:18.645Z" }, + { url = "https://files.pythonhosted.org/packages/32/33/93fcc25907235c344ae73122f8a4e01d2d393ef062b4af7d2e2487a32c37/orjson-3.11.9-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:4bab1b2d6141fe7b32ae71dac905666ece4f94936efbfb13d55bb7739a3a6021", size = 228458, upload-time = "2026-05-06T15:10:20.079Z" }, + { url = "https://files.pythonhosted.org/packages/8f/27/b1e6dadb3c080313c03fdd8067b85e6a0460c7d8d6a1c3984ef77b904e4d/orjson-3.11.9-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:844417969855fc7a41be124aafe83dc424592a7f77cd4501900c67307122b92c", size = 128368, upload-time = "2026-05-06T15:10:21.549Z" }, + { url = "https://files.pythonhosted.org/packages/21/0f/c9ede0bf052f6b4051e64a7d4fa91b725cccf8321a6a786e86eb03519f00/orjson-3.11.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffe02797b5e9f3a9d8292ddcd289b474ad13e81ad83cd1891a240811f1d2cb81", size = 132070, upload-time = "2026-05-06T15:10:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/fd/26/d398e28048dc18205bbe812f2c88cb9b40313db2470778e25964796458fe/orjson-3.11.9-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e4eed3b200023042814d2fc8a5d2e880f13b52e1ed2485e83da4f3962f7dc1a", size = 127892, upload-time = "2026-05-06T15:10:24.714Z" }, + { url = "https://files.pythonhosted.org/packages/66/60/52b0054c4c700d5aa7fc5b7ca96917400d8f061307778578e67a10e25852/orjson-3.11.9-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8aff7da9952a5ad1cef8e68017724d96c7b9a66e99e91d6252e1b133d67a7b10", size = 135217, upload-time = "2026-05-06T15:10:26.084Z" }, + { url = "https://files.pythonhosted.org/packages/d5/97/1e3dc2b2a28b7b2528f403d2fc1d79ec5f39af3bc143ab65d3ec26426385/orjson-3.11.9-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d4e98d6f3b8afed8bc8cd9718ec0cdf46661826beefb53fe8eafb37f2bf0362", size = 145980, upload-time = "2026-05-06T15:10:28.062Z" }, + { url = "https://files.pythonhosted.org/packages/fc/39/31fbfe7850f2de32dee7e7e5c09f26d403ab01e440ac96001c6b01ad3c99/orjson-3.11.9-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a81d52442a7c99b3662333235b3adf96a1715864658b35bb797212be7bddb97", size = 132738, upload-time = "2026-05-06T15:10:29.727Z" }, + { url = "https://files.pythonhosted.org/packages/a1/08/dca0082dd2a194acb93e5457e73455388e2e2ca464a2672449a9ddbb679d/orjson-3.11.9-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e39364e726a8fff737309aff059ff67d8a8c8d5b677be7bb49a8b3e84b7e218", size = 134033, upload-time = "2026-05-06T15:10:31.152Z" }, + { url = "https://files.pythonhosted.org/packages/11/d4/5bdb0626801230139987385554c5d4c42255218ac906525bf4347f22cd95/orjson-3.11.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4fd66214623f1b17501df9f0543bef0b833979ab5b6ded1e1d123222866aa8c9", size = 141492, upload-time = "2026-05-06T15:10:32.641Z" }, + { url = "https://files.pythonhosted.org/packages/fa/88/a21fb53b3ede6703aede6dce4710ed4111e5b201cfa6bbff5e544f9d47d7/orjson-3.11.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8ecc30f10465fa1e0ce13fd01d9e22c316e5053a719a8d915d4545a09a5ff677", size = 415087, upload-time = "2026-05-06T15:10:34.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/57/1b30daf70f0d8180e9a73cefbfbdd99e4bf19eb020466502b01fba7e0e50/orjson-3.11.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:97db4c94a7db398a5bd636273324f0b3fd58b350bbbac8bb380ceb825a9b40f4", size = 148031, upload-time = "2026-05-06T15:10:36.358Z" }, + { url = "https://files.pythonhosted.org/packages/04/83/45fbb6d962e260807f99441db9613cee868ceda4baceda59b3720a563f97/orjson-3.11.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f78cf8fec5bd627f4082b8dfeac7871b43d7f3274904492a43dab39f18a19a0", size = 136915, upload-time = "2026-05-06T15:10:38.013Z" }, + { url = "https://files.pythonhosted.org/packages/5f/cc/2d10025f9056d376e4127ec05a5808b218d46f035fdc08178a5411b34250/orjson-3.11.9-cp313-cp313-win32.whl", hash = "sha256:d4087e5c0209a0a8efe4de3303c234b9c44d1174161dcd851e8eea07c7560b32", size = 131613, upload-time = "2026-05-06T15:10:39.569Z" }, + { url = "https://files.pythonhosted.org/packages/67/bd/2775ff28bfe883b9aa1ff348300542eb2ef1ee18d8ae0e3a49846817a865/orjson-3.11.9-cp313-cp313-win_amd64.whl", hash = "sha256:051b102c93b4f634e89f3866b07b9a9a98915ada541f4ec30f177067b2694979", size = 127086, upload-time = "2026-05-06T15:10:41.262Z" }, + { url = "https://files.pythonhosted.org/packages/91/2b/d26799e580939e32a7da9a39531bc9e58e15ca32ffaa6a8cb3e9bb0d22cd/orjson-3.11.9-cp313-cp313-win_arm64.whl", hash = "sha256:cce9127885941bd28f080cecf1f1d288336b7e0d812c345b08be88b572796254", size = 126696, upload-time = "2026-05-06T15:10:42.651Z" }, + { url = "https://files.pythonhosted.org/packages/8e/eb/5da01e356015aee6ecfa1187ced87aef51364e306f5e695dd52719bf0e78/orjson-3.11.9-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b6ef1979adc4bc243523f1a2ba91418030a8e29b0a99cbe7e0e2d6807d4dce6e", size = 228465, upload-time = "2026-05-06T15:10:44.097Z" }, + { url = "https://files.pythonhosted.org/packages/64/62/3e0e0c14c957133bcd855395c62b55ed4e3b0af23ffea11b032cb1dcbdb1/orjson-3.11.9-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:f36b7f32c7c0db4a719f1fc5824db4a9c6f8bd1a354debb91faf26ebf3a4c71e", size = 128364, upload-time = "2026-05-06T15:10:45.839Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5a/07d8aa117211a8ed7630bda80c8c0b14d04e0f8dcf99bcf49656e4a710eb/orjson-3.11.9-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08f4d8ebb44925c794e535b2bebc507cebf32209df81de22ae285fb0d8d66de0", size = 132063, upload-time = "2026-05-06T15:10:47.267Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ec/4acaf21483e18aa945be74a474c74b434f284b549f275a0a39b9f98956e9/orjson-3.11.9-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6cc7923789694fd58f001cbcac7e47abc13af4d560ebbfcf3b41a8b1a0748124", size = 122356, upload-time = "2026-05-06T15:10:48.765Z" }, + { url = "https://files.pythonhosted.org/packages/13/d8/5f0555e7638801323b7a75850f92e7dfa891bc84fe27a1ba4449170d1200/orjson-3.11.9-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea5c46eb2d3af39e806b986f4b09d5c2706a1f5afde3cbf7544ce6616127173c", size = 129592, upload-time = "2026-05-06T15:10:50.13Z" }, + { url = "https://files.pythonhosted.org/packages/b6/30/ed9860412a3603ceb3c5955bfd72d28b9d0e7ba6ed81add14f83d7114236/orjson-3.11.9-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5d89a2ed90731df3be64bab0aa44f78bff39fdc9d71c291f4a8023aa46425b7", size = 140491, upload-time = "2026-05-06T15:10:51.582Z" }, + { url = "https://files.pythonhosted.org/packages/d0/17/adc514dea7ac7c505527febf884934b815d34f0c7b8693c1a8b39c5c4a57/orjson-3.11.9-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25e4aed0312d292c09f61af25bba34e0b2c88546041472b09088c39a4d828af1", size = 127309, upload-time = "2026-05-06T15:10:53.329Z" }, + { url = "https://files.pythonhosted.org/packages/76/3e/c0b690253f0b82d86e99949af13533363acfb5432ecb5d53dd5b3bce9c34/orjson-3.11.9-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaea64f3f467d22e70eeed68bdccb3bc4f83f650446c4a03c59f2cba28a108db", size = 134030, upload-time = "2026-05-06T15:10:54.988Z" }, + { url = "https://files.pythonhosted.org/packages/c1/7a/bc82a0bb25e9faaf92dc4d9ef002732efc09737706af83e346788641d4a7/orjson-3.11.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a028425d1b440c5d92a6be1e1a020739dfe67ea87d96c6dbe828c1b30041728b", size = 141482, upload-time = "2026-05-06T15:10:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/01/55/e69188b939f77d5d32a9833745ace31ea5ccae3ab613a1ec185d3cd2c4fb/orjson-3.11.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5b192c6cf397e4455b11523c5cf2b18ed084c1bbd61b6c0926344d2129481972", size = 415178, upload-time = "2026-05-06T15:10:58.446Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/b8a5a7ac527e80b9cb11d51e3f6689b709279183264b9ec5c7bc680bb8b5/orjson-3.11.9-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea407d4ccf5891d667d045fecae97a7a1e5e87b3b97f97ae1803c2e741130be0", size = 148089, upload-time = "2026-05-06T15:11:00.441Z" }, + { url = "https://files.pythonhosted.org/packages/97/4e/00503f64204bf859b37213a63927028f30fb6268cd8677fb0a5ad48155e1/orjson-3.11.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f63aaf97afd9f6dec5b1a68e1b8da12bfccb4cb9a9a65c3e0b6c847849e7586", size = 136921, upload-time = "2026-05-06T15:11:02.176Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ba/a23b82a0a8d0ed7bed4e5f5035aae751cad4ff6a1e8d2ecd14d8860f5929/orjson-3.11.9-cp314-cp314-win32.whl", hash = "sha256:e30ab17845bb9fa54ccf67fa4f9f5282652d54faa6d17452f47d0f369d038673", size = 131638, upload-time = "2026-05-06T15:11:03.696Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/0c6798456bade745c75c452342dabacce5798196483e77e643be1f53877d/orjson-3.11.9-cp314-cp314-win_amd64.whl", hash = "sha256:32ef5f4283a3be81913947d19608eacb7c6608026851123790cd9cc8982af34b", size = 127078, upload-time = "2026-05-06T15:11:05.123Z" }, + { url = "https://files.pythonhosted.org/packages/16/21/5a3f1e8913103b703a436a5664238e5b965ec392b555fe68943ea3691e6b/orjson-3.11.9-cp314-cp314-win_arm64.whl", hash = "sha256:eebdbdeef0094e4f5aefa20dcd4eb2368ab5e7a3b4edea27f1e7b2892e009cf9", size = 126687, upload-time = "2026-05-06T15:11:06.602Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "stac-fastapi-api" +version = "6.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "brotli-asgi" }, + { name = "stac-fastapi-types" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/13/5cb1ecd4ccec6f4dd9e1bf8ffe7dda257d31da73603e43e4c2805c33f631/stac_fastapi_api-6.2.1.tar.gz", hash = "sha256:049b52530d56c6f1ab4da6249a112f0031b3cf19ee95af9c212a011f137b9e17", size = 11816, upload-time = "2026-02-10T15:34:46.135Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/7a/89ed5747787473c2904ad1177fcaff049ee3bd796b788e492d54c4ff69da/stac_fastapi_api-6.2.1-py3-none-any.whl", hash = "sha256:c2b9ea71d0f0eb97510eb00a2fb6fdb9b03838c6fee940d8ea5602923e664a63", size = 14153, upload-time = "2026-02-10T15:34:46.803Z" }, +] + +[[package]] +name = "stac-fastapi-extensions" +version = "6.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "stac-fastapi-api" }, + { name = "stac-fastapi-types" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7b/62/c71166b1f959ada3ea72e12ed5270e6b59b4e18f1b2f1825f4f8e58896c4/stac_fastapi_extensions-6.2.1.tar.gz", hash = "sha256:355c0c5f0d2c9b87d0238fff031963b2878d66a67faec93ff6f47d530aa370c0", size = 16673, upload-time = "2026-02-10T15:34:43.402Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/0e/f6306c9a984934eb17ff27f61b57c37942adda86302833f3dd9ec64eede8/stac_fastapi_extensions-6.2.1-py3-none-any.whl", hash = "sha256:d72f5c9323df3f54a9731f3813f9b036b81235e8728a17a6c99ca202751e4308", size = 34011, upload-time = "2026-02-10T15:34:44.239Z" }, +] + +[[package]] +name = "stac-fastapi-pgstac" +version = "6.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asyncpg" }, + { name = "attrs" }, + { name = "brotli-asgi" }, + { name = "buildpg" }, + { name = "cql2" }, + { name = "hydraters" }, + { name = "json-merge-patch" }, + { name = "jsonpatch" }, + { name = "orjson" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "stac-fastapi-api" }, + { name = "stac-fastapi-extensions" }, + { name = "stac-fastapi-types" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b8/4d/8c2c19d7516c877d0ea511b666dcfaccdd0a29e3dc16eae05d6b5919eba8/stac_fastapi_pgstac-6.2.2.tar.gz", hash = "sha256:bdefccbcadb5c1247c545ac92095bae6c06fc6a307b73849c7848eaa368cae69", size = 22300, upload-time = "2026-02-10T11:46:10.747Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/83/aedffbd2ee320e2fe4c24c60b2d5cc5071aba29c27c8253b2990e7a783f2/stac_fastapi_pgstac-6.2.2-py3-none-any.whl", hash = "sha256:1f2d044beefe6b2fd8882607c50951c57400d8f77e7d0fbb37118bffcad08feb", size = 26723, upload-time = "2026-02-10T11:46:12.145Z" }, +] + +[[package]] +name = "stac-fastapi-types" +version = "6.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "fastapi" }, + { name = "iso8601" }, + { name = "pydantic-settings" }, + { name = "stac-pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/c6/34035e9db29f441d461c72df7d97b2f2ff322cd39fcac4057c5c8d070f9e/stac_fastapi_types-6.2.1.tar.gz", hash = "sha256:08e0a2f5304afcc65820861946b21f77b4511810d5e877f41736cdc51a471489", size = 10620, upload-time = "2026-02-10T15:34:41.991Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/5c/e8826215add172cdea041390be03eb58eeb8da3bf37eec2ad1589ebbeca2/stac_fastapi_types-6.2.1-py3-none-any.whl", hash = "sha256:0cd0b697431c13a335df5ff99b6d84303187f1612979021912d9a952fa38e3bc", size = 13555, upload-time = "2026-02-10T15:34:40.699Z" }, +] + +[[package]] +name = "stac-pydantic" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "geojson-pydantic" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/fd/4cf716bf9a7fc2cd288b3bebb7e720f4fb214076352a5a4e6336ee63f0d4/stac_pydantic-3.5.1.tar.gz", hash = "sha256:1332136b5b4b80a8fbb0b40b02d1ea6ff220b9f7f33f72886d4cfb4966fca3b9", size = 16303, upload-time = "2026-05-05T07:57:35.574Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/ae/3804a96068df879cce3d28c95b1be5d0186e44b40e6c52348b60082a05b9/stac_pydantic-3.5.1-py3-none-any.whl", hash = "sha256:3dd981ea150f5ee99836a7bbb43bc14ad2b12cba95f28839d6a969a9a6d89b9b", size = 25649, upload-time = "2026-05-05T07:57:36.64Z" }, +] + +[[package]] +name = "starlette" +version = "1.0.0" +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/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, +] + +[[package]] +name = "starlette-cramjam" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cramjam" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2b/05/4a478b9d43b73496b2f22b9af8239902065f1e1cd39d7f98cf269f776041/starlette_cramjam-0.4.0.tar.gz", hash = "sha256:bd36e68109b13c29d1e7aa0ddb7eaf614bfd144be99d8dcb5ece95c96dbcec17", size = 7946, upload-time = "2024-10-17T16:10:53.844Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/46/de94ca00de3505c2d7b802ac5e581eef1fb0b3039966d7655aa7b53f6821/starlette_cramjam-0.4.0-py3-none-any.whl", hash = "sha256:c1943087641c8ed5a08fc166664875a1f44c6f1de4301ed21f23a261df821c1b", size = 7201, upload-time = "2024-10-17T16:10:51.203Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +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" } +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" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] diff --git a/dev-docs/specs/stac-api-collection-transactions.md b/dev-docs/specs/stac-api-collection-transactions.md new file mode 100644 index 0000000..85150ee --- /dev/null +++ b/dev-docs/specs/stac-api-collection-transactions.md @@ -0,0 +1,454 @@ +# Spec: STAC API collection transactions runtime + +## Context +MAAP eoAPI currently deploys the stock `eoapi-cdk` pgSTAC STAC API Lambda through `PgStacApiLambda` in `cdk/PgStacInfra.ts`. That runtime exposes the standard read-only STAC API and can enable the upstream STAC transaction extension. + +The problem is that upstream `stac-fastapi` transaction support is all-or-nothing: +- enabling `TransactionExtension` registers both collection and item write routes +- it advertises both collection and item transaction conformance classes +- it exposes those routes in OpenAPI docs + +For MAAP, we only want collection-level transactions for now. We do not want item-management transaction routes exposed, documented, or accidentally usable. We also need an auth layer on the collection write routes, with HTTP Basic acceptable now and JWT expected later. Finally, this must be switchable per deployment so it can be enabled for `userInfrastructure` while other deployments stay read-only on the same runtime. + +We also want to preserve a clean path to using `developmentseed/stac-auth-proxy` inside the runtime as in-process middleware rather than only as a separate reverse proxy. That project already provides OIDC/JWT enforcement, OpenAPI security augmentation, STAC Authentication Extension responses, and policy-driven filtering. Its docs explicitly support applying the middleware stack to an existing FastAPI app via `configure_app(...)`, which makes it relevant to the long-term auth design here. + +## Goals +- Add a custom STAC API runtime under `cdk/runtimes/eoapi/stac/`. +- Support collection transaction routes only: + - `POST /collections` + - `PUT /collections/{collection_id}` + - `PATCH /collections/{collection_id}` + - `DELETE /collections/{collection_id}` +- Do not expose item transaction routes: + - no route registration + - no OpenAPI entries + - no item transaction conformance class +- Add an auth layer for collection transaction routes. +- Preserve a clean path to optional in-process `stac-auth-proxy` middleware for future OIDC/JWT auth. +- Make transaction support opt-in per `PgStacInfra` deployment. +- Keep the existing read-only STAC API behavior unchanged when the feature is disabled. +- Add a local `docker-compose.yml` for running the MAAP custom STAC and raster runtimes together during development. +- Leave a clean path to JWT-based auth later. + +## Non-goals +- Implement JWT auth now. +- Deploy `stac-auth-proxy` as a separate standalone reverse proxy in front of the Lambda as part of this first change. +- Add item-level transaction support. +- Build tenant- or collection-specific authorization rules. +- Change ingestion flows outside the STAC API Lambda. +- Upstream a general-purpose fix to `stac-fastapi` as part of this change. + +## Constraints and Assumptions +- Current STAC API deployment uses `eoapi-cdk` `PgStacApiLambda`. +- `eoapi-cdk` already supports overriding Lambda code via `lambdaFunctionOptions.code` and `handler`. +- Upstream `stac-fastapi-pgstac` v6.2 runtime imports `app` from `stac_fastapi.pgstac.app`, not `stac_fastapi.pgstac.main`. +- Upstream transaction wiring currently couples collection and item transaction routes in one extension. +- `stac-auth-proxy` is primarily packaged as a reverse proxy, but its middleware stack can also be applied directly to an existing FastAPI app via `configure_app(...)`. +- Current `stac-auth-proxy` auth enforcement is OIDC/JWT-oriented. It does not replace the need for a simple first-pass Basic auth path. +- Running the full proxy app in front of the Lambda would add an extra hop and duplicate some request/response shaping that we already control in the runtime. +- Secrets should not be stored as plaintext CDK config values when avoidable. +- We should minimize blast radius for the public STAC deployment. + +## Architecture Overview +The change has two layers. + +1. Runtime layer + - Add a custom Python runtime package at `cdk/runtimes/eoapi/stac/`. + - Rebuild the STAC API app locally instead of relying on the upstream all-in-one transaction extension. + - Register normal read-only STAC behavior exactly as today. + - Conditionally register a MAAP-specific collection-transactions extension. + - this will just require a subclass of `stac_fastapi.extensions.transaction.TransactionExtension` with a `register()` method that omits the item routes + - Apply auth only to the collection write routes. + - for initial Basic auth, use the upstream `TransactionExtension(..., route_dependencies=...)` support added in `stac-fastapi` PR #885 once that release is available + - Keep an auth-provider seam so future OIDC/JWT mode can install `stac-auth-proxy` middleware in-process on the same FastAPI app instead of introducing a separate proxy tier. + +2. Infrastructure layer + - Add a `transactions` config block under `stacApiConfig` in `PgStacInfra` props. + - All deployments will use the same custom runtime. + - The collection-transactions extension will only be activated (via Lambda env var) for instances where the `transactions` config is enabled. + - Enable this only for `userInfrastructure` initially. + +This keeps route behavior read-only unless transactions are explicitly enabled, while standardizing the runtime shape across deployments. + +## Runtime Design + +### File layout +Proposed layout: + +```text +cdk/ + dockerfiles/ + Dockerfile.stac + runtimes/ + eoapi/ + stac/ + README.md + pyproject.toml + uv.lock + .python-version + eoapi/ + stac/ + __init__.py + main.py + auth.py + transactions.py + handler.py +docker-compose.yml +``` + +### App construction +`eoapi/stac/main.py` will build the FastAPI app. + +Implementation approach: +- make `eoapi/stac/main.py` a near 1:1 copy of `/home/henry/workspace/stac-utils/stac-fastapi-pgstac/stac_fastapi/pgstac/app.py` +- keep upstream app construction, middleware, lifespan wiring, request-model setup, and extension composition aligned as closely as possible +- continue to use upstream pgSTAC clients for core read behavior +- continue to use upstream request/response models where possible +- do not enable the default upstream `TransactionExtension` +- instead, register a local `CollectionTransactionExtension` in the same spot, with the rest of the app structure staying unchanged unless MAAP has a specific reason to diverge + +Why copy the app closely instead of mutating the upstream app after import? +- removing routes after registration is brittle +- conformance classes and docs become easy to miss +- auth attachment is cleaner when routes are created locally +- it avoids depending on upstream internal route order or router structure +- keeping the file near-identical to upstream makes future drift easier to review and reduce + +### CollectionTransactionExtension +Add a local extension in `eoapi/stac/transactions.py`. + +It should: +- subclass `TransactionExtension` +- set `conformance_classes = [TransactionConformanceClasses.COLLECTIONS]` +- implement the same collection transaction route contracts as upstream +- reuse upstream `TransactionsClient` for collection CRUD methods +- use the upstream `route_dependencies` constructor support from `stac-fastapi` PR #885 so auth can be attached at extension construction time rather than by hand on each route +- override `register(app: FastAPI)` to set `self.router.prefix = app.state.router_prefix`, call the four collection registration helpers, and then `include_router(...)` +- register only these routes: + +```text +POST /collections +PUT /collections/{collection_id} +PATCH /collections/{collection_id} +DELETE /collections/{collection_id} +``` + +It should not register any `/collections/{collection_id}/items...` write routes. + +It should advertise only this conformance class: + +```text +https://api.stacspec.org/v1.0.0/collections/extensions/transaction +``` + +It must not advertise: + +```text +https://api.stacspec.org/v1.0.0/ogcapi-features/extensions/transaction +``` + +This should stay intentionally small: reuse upstream route helper methods such as `register_create_collection()` and only narrow the registered surface, rather than forking transaction route implementations. + +### Auth model +Add a small auth abstraction in `eoapi/stac/auth.py`. + +Initial and planned modes: +- `basic` for the first implementation +- `oidc` as the future mode backed by `stac-auth-proxy` middleware + +The route-level dependency contract for the initial Basic path is: + +```python +async def require_transaction_auth(request: Request) -> None: + ... +``` + +Behavior in `basic` mode: +- passed through `TransactionExtension(..., route_dependencies=[...])` when collection transactions are enabled +- applied only to collection transaction routes +- no effect on read-only routes +- returns `401` with `WWW-Authenticate: Basic` when credentials are missing or invalid +- should not be wired per-route manually unless the upstream release plan changes + +Basic auth credential source: +- Lambda env contains `MAAP_TRANSACTION_AUTH_MODE=basic` +- Lambda env contains `MAAP_TRANSACTION_AUTH_SECRET_ARN=` +- referenced secret payload format: + +```json +{ + "username": "...", + "password": "..." +} +``` + +Planned `oidc` mode using `stac-auth-proxy` middleware: +- do not run the full reverse proxy app in front of the Lambda +- instead, apply the middleware stack to the in-process FastAPI app, using `stac_auth_proxy.configure_app(app, settings=...)` or selected middleware classes directly if we need tighter control +- configure path/method protection so only collection transaction routes are private: + +```json +{ + "^/collections$": ["POST"], + "^/collections/([^/]+)$": ["PUT", "PATCH", "DELETE"] +} +``` + +- do not mark item transaction routes as private because they should not exist in this runtime variant +- optionally use its OpenAPI and Authentication Extension middleware so docs and STAC responses advertise OIDC requirements consistently +- leave filtering middleware disabled unless and until we explicitly adopt record-level authorization + +Why this is a future path rather than the first implementation: +- `stac-auth-proxy` currently assumes OIDC/JWT, not Basic auth +- our immediate need is a minimal collection-write guard +- the in-process middleware route is still valuable because it gives us a ready-made JWT/OIDC layer later without adding a separate network hop + +Future JWT compatibility: +- auth dispatch should be mode-based, not hardcoded inside route handlers +- adding `oidc` later should require selecting a different auth provider, not rewriting collection transaction routes + +### Runtime environment variables +The custom runtime should support these env vars in addition to existing STAC API env vars: + +```text +ENABLED_EXTENSIONS=collection_transaction,collection_search,... +MAAP_TRANSACTION_AUTH_MODE=basic|oidc +MAAP_TRANSACTION_AUTH_SECRET_ARN=arn:aws:secretsmanager:... +MAAP_OIDC_DISCOVERY_URL=https://issuer/.well-known/openid-configuration +MAAP_ALLOWED_JWT_AUDIENCES=aud1,aud2 +``` + +Rules: +- if `collection_transaction` is not in the `ENABLED_EXTENSIONS` env var, do not register the transaction endpoints +- if enabled and auth mode is `basic`, secret ARN is required +- if enabled and auth mode is `oidc`, OIDC discovery URL is required +- item transaction routes remain absent in all modes for this runtime version +- the runtime may internally map MAAP env vars into `stac-auth-proxy` settings rather than exposing the proxy's full env surface directly + +### DB connection behavior +The runtime must create a write pool only when collection transactions are enabled. + +Equivalent intent to upstream: +- read-only deployments use read pool only +- transaction-enabled deployments initialize write pool too + +`handler.py` should keep the same Lambda/Mangum and SnapStart lifecycle pattern already used by the upstream `eoapi-cdk` runtime so connection handling stays consistent. + +## API or Interface Design + +### TypeScript props +Add a new optional transactions block under `stacApiConfig`. + +```ts +stacApiConfig: { + customDomainName?: string; + integrationApiArn?: string; + transactions?: { + enabled: boolean; + authMode: "basic" | "oidc"; + authSecretArn?: string; + oidcDiscoveryUrl?: string; + allowedJwtAudiences?: string[]; + }; +}; +``` + +Validation rules: +- `transactions` omitted => current behavior +- `transactions.enabled === false` => current behavior +- `transactions.enabled === true` and `authMode === "basic"` => `authSecretArn` required +- `transactions.enabled === true` and `authMode === "oidc"` => `oidcDiscoveryUrl` required +- `transactions.enabled === true` and `authMode === "oidc"` with audience enforcement => `allowedJwtAudiences` optional but recommended + +### CDK usage +Initial intended usage in `cdk/app.ts`: + +- `coreInfrastructure`: no transactions block +- `userInfrastructure`: transactions enabled + +Example: + +```ts +stacApiConfig: { + customDomainName: userStacStacApiCustomDomainName, + transactions: { + enabled: true, + authMode: "basic", + authSecretArn: userStacCollectionTransactionsAuthSecretArn, + }, +} +``` + +### Runtime override in PgStacInfra +For all deployments: +- keep using `new PgStacApiLambda(...)` +- pass `lambdaFunctionOptions.code = lambda.Code.fromDockerBuild(...)` +- pass `lambdaFunctionOptions.handler = "handler.handler"` +- pass extension-selection env vars through `apiEnv` +- do not rely on upstream `enabledExtensions` transaction flag + +When transactions are enabled, also pass auth env vars and, for `basic` mode, secret access. + +This preserves existing API Gateway, custom domain, VPC, DB, and SnapStart behavior managed by `eoapi-cdk` while standardizing on one MAAP-owned runtime. + +## Data Model +No database schema change is required. + +New configuration data introduced: + +### CDK deployment config +```ts +interface StacTransactionsConfig { + enabled: boolean; + authMode: "basic" | "oidc"; + authSecretArn?: string; + oidcDiscoveryUrl?: string; + allowedJwtAudiences?: string[]; +} +``` + +### Secrets Manager payload for basic auth +```json +{ + "username": "stac-writer", + "password": "" +} +``` + +## Integration Points + +### `cdk/PgStacInfra.ts` +Changes: +- extend `Props.stacApiConfig` +- always use the custom STAC runtime override for the STAC API Lambda +- grant transaction auth secret read access to the STAC API Lambda when needed +- pass auth and transactions env vars into the Lambda +- keep disabled deployments on the read-only path inside the shared custom runtime + +### `cdk/app.ts` +Changes: +- wire transactions config only for `userInfrastructure` +- leave `coreInfrastructure` unchanged + +### `cdk/config.ts` +Add optional config values for the user stack, for example: + +```text +USER_STAC_COLLECTION_TRANSACTIONS_ENABLED +USER_STAC_COLLECTION_TRANSACTIONS_AUTH_MODE +USER_STAC_COLLECTION_TRANSACTIONS_AUTH_SECRET_ARN +USER_STAC_COLLECTION_TRANSACTIONS_OIDC_DISCOVERY_URL +USER_STAC_COLLECTION_TRANSACTIONS_ALLOWED_JWT_AUDIENCES +``` + +These should default to disabled when unset. + +### Tests +Likely touch points: +- `test/config.test.ts` for config parsing +- new unit or synth-level tests for `PgStacInfra` transaction config behavior +- runtime tests for auth and route exposure +- if we add `oidc` mode later, runtime tests that `stac-auth-proxy` middleware protects only collection transaction routes and leaves read routes untouched + +### Docs +Update at least: +- repo `README.md` if deployment configuration is user-facing +- runtime `README.md` under `cdk/runtimes/eoapi/stac/` +- `docker-compose.yml` usage notes for local runtime development + +## Migration Path +1. Add custom runtime package and Dockerfile. +2. Add a local `docker-compose.yml` for the custom STAC and raster runtimes, following the shape of `/home/henry/workspace/devseed/eoapi-devseed/docker-compose.yml` where it still fits this repo. +3. Copy `stac_fastapi.pgstac.app` into the MAAP runtime as a near 1:1 local app, swapping in `CollectionTransactionExtension` for the default transaction extension. +4. Add transactions config to `PgStacInfra` and config loading in `cdk/config.ts`. +5. Wire `userInfrastructure` to use the new config. +6. When the `stac-fastapi` release that includes PR #885 is available, update any MAAP dependency pins needed to consume it and finish the extension-level auth attachment through `route_dependencies`. +7. Keep the runtime auth abstraction narrow so a later `oidc` mode can install `stac-auth-proxy` middleware without changing the collection transaction extension. +8. Deploy to a non-prod internal environment. +9. Verify: + - collection transaction routes work with auth + - item transaction routes return `404` + - OpenAPI docs show only collection transaction routes + - conformance output includes only the collection transaction class +10. Promote to other user STAC deployments as needed. + +No backfill or data migration is required. + +## Testing Strategy + +### Runtime tests +Add Python tests around the custom app builder: +- transactions disabled => no write routes present +- transactions enabled => collection write routes present +- item write routes absent +- OpenAPI schema excludes item transaction routes +- conformance classes exclude item transaction URI +- basic auth rejects unauthenticated requests with `401` +- basic auth accepts valid credentials +- future `oidc` mode can be enabled without reintroducing item transaction docs or routes +- future `oidc` mode protects only the collection transaction endpoints when configured with collection-only private endpoint regexes + +### Infrastructure tests +Add TypeScript tests for: +- config parsing defaults to disabled +- enabling basic auth without secret ARN throws +- all deployments use the custom Lambda handler/code override +- enabling transactions adds expected Lambda env vars +- disabled mode omits transaction auth env vars and keeps read-only behavior + +### Local development checks +Add local verification for `docker-compose.yml`: +- custom STAC runtime starts against local pgSTAC +- custom raster runtime starts alongside it +- disabled-mode STAC startup works before the auth-hook release lands +- compose configuration remains usable for the final auth-enabled verification pass once PR #885 is available + +### Smoke tests +Post-deploy manual/API checks: +- `GET /` and `GET /conformance` +- `POST /collections` with and without auth +- `PUT/PATCH/DELETE /collections/{collection_id}` with auth +- `POST /collections/{collection_id}/items` should be `404` +- Swagger/OpenAPI should not document item transaction routes + +## Decision Log +| Decision | Options Considered | Rationale | +|----------|--------------------|-----------| +| Use one custom runtime for all STAC deployments | Only use the custom runtime when transactions are enabled; patch routes in place; use stock runtime unchanged | Keeps route behavior switchable per deployment while avoiding two runtime code paths for the same API surface | +| Keep a near 1:1 local copy of `stac_fastapi.pgstac.app` | Mutate the upstream app after import; reassemble a more custom MAAP app from smaller pieces | A near-identical copy keeps behavior aligned with upstream while making the transaction-extension substitution explicit and reviewable | +| Implement a collection-only extension | Monkeypatch the upstream router | A local extension is explicit, testable, and resilient to upstream internal changes | +| Use `TransactionExtension.route_dependencies` for initial Basic auth attachment | API Gateway auth only; middleware on all routes; manually attaching dependencies per route | We only need protection on collection write routes right now, and the upstream `route_dependencies` hook added in PR #885 gives us a clean extension-level attachment point for the minimal Basic-auth first step | +| Preserve `stac-auth-proxy` as an in-process middleware option for future OIDC/JWT | Run a standalone reverse proxy in front of the Lambda; build our own JWT middleware from scratch; ignore the project for now | `stac-auth-proxy` already solves OIDC enforcement, OpenAPI security augmentation, and STAC Authentication Extension responses. Using it in-process keeps that path open without committing this first iteration to a separate proxy hop or to OIDC immediately | +| Store basic auth credentials in Secrets Manager | Plain env vars; SSM parameters | Secrets Manager is the least bad option for credentials and matches existing Lambda secret-read patterns | +| Omit item transaction routes entirely | Expose them but block with auth/authorization | Not registering them is safer and keeps docs/conformance honest | + +## Open Questions +- Should failed item transaction requests return `404` or an explicit `403` from a defensive blocker route? The cleaner default is `404` by not registering them. +- Do we want to add explicit public-stack transaction config now for symmetry, or keep the config surface user-stack-only until there is a real second use case? +- Should basic auth credentials be a single shared writer credential, or do we expect multiple clients soon enough to justify a richer secret format? +- Do we want CloudWatch metrics or structured logs specifically for collection transaction attempts and auth failures? +- Which released `stac-fastapi` version first includes PR #885, and do any downstream MAAP dependencies need version bumps before we can rely on it for the final auth attachment? +- If we adopt `stac-auth-proxy` in-process later, should we call `configure_app(...)` wholesale or add only `EnforceAuthMiddleware`, `OpenApiMiddleware`, and `AuthenticationExtensionMiddleware` directly for tighter control? +- Do we want the future `oidc` mode to expose the STAC Authentication Extension immediately, or should that remain separately configurable? +- How do we want to keep the local `app.py` copy aligned with future upstream `stac-fastapi-pgstac` changes after this fork lands? +- Is there any existing upstream work toward collection-only transaction registration that we may want to track before maintaining this long term? + +## References +- `cdk/PgStacInfra.ts` +- `cdk/app.ts` +- `cdk/config.ts` +- `cdk/runtimes/eoapi/raster/` +- `node_modules/eoapi-cdk/lib/stac-api/index.d.ts` +- `node_modules/eoapi-cdk/lib/stac-api/runtime/src/stac_api/handler.py` +- `/home/henry/workspace/stac-utils/stac-fastapi-pgstac/stac_fastapi/pgstac/app.py` +- `https://github.com/stac-utils/stac-fastapi-pgstac/blob/main/stac_fastapi/pgstac/app.py` +- `https://github.com/stac-utils/stac-fastapi-pgstac/blob/main/stac_fastapi/pgstac/transactions.py` +- `https://github.com/stac-utils/stac-fastapi/blob/main/stac_fastapi/extensions/stac_fastapi/extensions/transaction/transaction.py` +- `https://github.com/stac-utils/stac-fastapi/issues/884` +- `https://github.com/stac-utils/stac-fastapi/pull/885` +- `https://github.com/developmentseed/stac-auth-proxy` +- `https://developmentseed.org/stac-auth-proxy/user-guide/getting-started/` +- `https://developmentseed.org/stac-auth-proxy/user-guide/configuration/` +- `https://developmentseed.org/stac-auth-proxy/user-guide/route-level-auth/` +- `https://developmentseed.org/stac-auth-proxy/architecture/middleware-stack/` +- `/home/henry/workspace/devseed/eoapi-devseed/docker-compose.yml` diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0c49ac1 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,116 @@ +services: + stac: + platform: linux/amd64 + build: + context: ./cdk + dockerfile: dockerfiles/Dockerfile.stac + target: local + entrypoint: + - /bin/bash + - -lc + ports: + - "${MY_DOCKER_IP:-127.0.0.1}:8081:8081" + volumes: + - ./cdk/runtimes/eoapi/stac:/workspace + environment: + PYTHONUNBUFFERED: "1" + PYTHONPATH: /workspace:/asset + POSTGRES_USER: username + POSTGRES_PASS: password + POSTGRES_DBNAME: postgis + POSTGRES_HOST_READER: database + POSTGRES_HOST_WRITER: database + POSTGRES_PORT: "5432" + DB_MIN_CONN_SIZE: "1" + DB_MAX_CONN_SIZE: "1" + ENABLED_EXTENSIONS: query,sort,fields,filter,free_text,pagination,collection_search,collection_transaction + TITILER_ENDPOINT: http://raster:8082 + STAC_FASTAPI_TITLE: MAAP Local STAC API + STAC_FASTAPI_LANDING_ID: maap-stac-api-local + STAC_FASTAPI_DESCRIPTION: Local deployment of the MAAP STAC API + STAC_FASTAPI_VERSION: 0.1.0 + MAAP_TRANSACTION_AUTH_MODE: ${MAAP_TRANSACTION_AUTH_MODE:-basic} + MAAP_TRANSACTION_AUTH_USERNAME: ${MAAP_TRANSACTION_AUTH_USERNAME:-username} + MAAP_TRANSACTION_AUTH_PASSWORD: ${MAAP_TRANSACTION_AUTH_PASSWORD:-password} + MAAP_TRANSACTION_AUTH_SECRET_ARN: ${MAAP_TRANSACTION_AUTH_SECRET_ARN:-} + env_file: + - path: .env + required: false + - path: .stac.env + required: false + depends_on: + - database + - raster + command: + - >- + until (echo > /dev/tcp/database/5432) >/dev/null 2>&1; do sleep 1; done + && python -m uvicorn eoapi.stac.main:app --host 0.0.0.0 --port 8081 + --workers 1 --reload --reload-dir /workspace/eoapi/stac + + raster: + platform: linux/amd64 + build: + context: ./cdk + dockerfile: dockerfiles/Dockerfile.raster + target: local + entrypoint: + - /bin/bash + - -lc + ports: + - "${MY_DOCKER_IP:-127.0.0.1}:8082:8082" + environment: + PYTHONUNBUFFERED: "1" + PYTHONPATH: /asset + POSTGRES_USER: username + POSTGRES_PASS: password + POSTGRES_DBNAME: postgis + POSTGRES_HOST: database + POSTGRES_PORT: "5432" + DB_MIN_CONN_SIZE: "1" + DB_MAX_CONN_SIZE: "10" + CPL_TMPDIR: /tmp + GDAL_CACHEMAX: 75% + GDAL_INGESTED_BYTES_AT_OPEN: "32768" + GDAL_DISABLE_READDIR_ON_OPEN: EMPTY_DIR + GDAL_HTTP_MERGE_CONSECUTIVE_RANGES: "YES" + GDAL_HTTP_MULTIPLEX: "YES" + GDAL_HTTP_VERSION: "2" + VSI_CACHE: "TRUE" + VSI_CACHE_SIZE: "536870912" + MOSAIC_BACKEND: dynamodb:// + MOSAIC_HOST: ${MOSAIC_HOST:-localhost} + MOSAIC_CONCURRENCY: "1" + AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-} + AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-} + AWS_SESSION_TOKEN: ${AWS_SESSION_TOKEN:-} + env_file: + - path: .env + required: false + - path: .raster.env + required: false + depends_on: + - database + command: + - >- + until (echo > /dev/tcp/database/5432) >/dev/null 2>&1; do sleep 1; done + && python -m uvicorn eoapi.raster.main:app --host 0.0.0.0 --port 8082 + --workers 1 + + database: + image: ghcr.io/stac-utils/pgstac:v0.9.9 + ports: + - "${MY_DOCKER_IP:-127.0.0.1}:5439:5432" + environment: + POSTGRES_USER: username + POSTGRES_PASSWORD: password + POSTGRES_DB: postgis + PGUSER: username + PGPASSWORD: password + PGDATABASE: postgis + command: postgres -N 500 + volumes: + - ./.pgdata:/var/lib/postgresql/data + +networks: + default: + name: maap-eoapi diff --git a/pyproject.toml b/pyproject.toml index e346b6f..fe5bbd8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,7 @@ requires-python = ">=3.12" [tool.uv.sources] dps-stac-item-generator = { path = "cdk/constructs/DpsStacItemGenerator/runtime" } eoapi-raster = { path = "cdk/runtimes/eoapi/raster" } +eoapi-stac = { path = "cdk/runtimes/eoapi/stac" } [dependency-groups] diff --git a/test/config.test.ts b/test/config.test.ts index 0919547..171ac2a 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -69,6 +69,7 @@ describe("Config", () => { expect(config.webAclArn).toBe( "arn:aws:wafv2:us-east-1:123456789012:global/webacl/test-acl", ); + expect(config.userStacCollectionTransactions).toBeUndefined(); // Test number properties expect(config.dbAllocatedStorage).toBe(20); @@ -91,7 +92,6 @@ describe("Config", () => { }); test("handles optional environment variables correctly", () => { - // Set optional environment variables process.env.CERTIFICATE_ARN = "arn:aws:acm:us-east-1:123456789012:certificate/optional-cert"; process.env.INGESTOR_DOMAIN_NAME = "ingestor.example.com"; @@ -99,7 +99,6 @@ describe("Config", () => { const config = new Config(); - // Optional values should be set expect(config.certificateArn).toBe( "arn:aws:acm:us-east-1:123456789012:certificate/optional-cert", ); @@ -107,6 +106,49 @@ describe("Config", () => { expect(config.titilerPgStacApiCustomDomainName).toBe("titiler.example.com"); }); + test("enables user STAC collection transactions with stack-managed auth by default", () => { + process.env.USER_STAC_COLLECTION_TRANSACTIONS_ENABLED = "true"; + process.env.USER_STAC_COLLECTION_TRANSACTIONS_AUTH_MODE = "basic"; + + const config = new Config(); + + expect(config.userStacCollectionTransactions).toEqual({ + authMode: "basic", + }); + }); + + test("accepts an explicit user STAC collection transaction secret ARN override", () => { + process.env.USER_STAC_COLLECTION_TRANSACTIONS_ENABLED = "true"; + process.env.USER_STAC_COLLECTION_TRANSACTIONS_AUTH_MODE = "basic"; + process.env.USER_STAC_COLLECTION_TRANSACTIONS_AUTH_SECRET_ARN = + "arn:aws:secretsmanager:us-west-2:123456789012:secret:user-stac-auth"; + + const config = new Config(); + + expect(config.userStacCollectionTransactions).toEqual({ + authMode: "basic", + authSecretArn: + "arn:aws:secretsmanager:us-west-2:123456789012:secret:user-stac-auth", + }); + }); + + test("rejects unsupported user STAC collection transaction auth modes", () => { + process.env.USER_STAC_COLLECTION_TRANSACTIONS_ENABLED = "true"; + process.env.USER_STAC_COLLECTION_TRANSACTIONS_AUTH_MODE = "jwt"; + process.env.USER_STAC_COLLECTION_TRANSACTIONS_AUTH_SECRET_ARN = + "arn:aws:secretsmanager:us-west-2:123456789012:secret:user-stac-auth"; + + expect(() => new Config()).toThrow(/Expected \"basic\"/); + }); + + test("rejects invalid user STAC collection transaction boolean values", () => { + process.env.USER_STAC_COLLECTION_TRANSACTIONS_ENABLED = "yes"; + + expect(() => new Config()).toThrow( + /USER_STAC_COLLECTION_TRANSACTIONS_ENABLED/, + ); + }); + test("buildStackName formats properly", () => { const config = new Config(); diff --git a/test/pgstac-infra.test.ts b/test/pgstac-infra.test.ts new file mode 100644 index 0000000..fd3a751 --- /dev/null +++ b/test/pgstac-infra.test.ts @@ -0,0 +1,178 @@ +import * as cdk from "aws-cdk-lib"; +import * as ec2 from "aws-cdk-lib/aws-ec2"; +import * as lambda from "aws-cdk-lib/aws-lambda"; +import { Match, Template } from "aws-cdk-lib/assertions"; +import { PgStacInfra, Props } from "../cdk/PgStacInfra"; + +function buildTemplate(overrides: Partial = {}): Template { + const app = new cdk.App(); + const networkStack = new cdk.Stack(app, "NetworkStack"); + const vpc = new ec2.Vpc(networkStack, "Vpc", { + maxAzs: 2, + natGateways: 1, + subnetConfiguration: [ + { + name: "public", + subnetType: ec2.SubnetType.PUBLIC, + }, + { + name: "private", + subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS, + }, + { + name: "isolated", + subnetType: ec2.SubnetType.PRIVATE_ISOLATED, + }, + ], + }); + + const stack = new PgStacInfra(app, "TestPgStacInfra", { + vpc, + stage: "test", + type: "internal", + version: "1.0.0", + webAclArn: + "arn:aws:wafv2:us-east-1:123456789012:global/webacl/test-acl", + loggingBucketArn: "arn:aws:s3:::test-logging-bucket", + pgstacDbConfig: { + instanceType: new ec2.InstanceType("t3.micro"), + subnetPublic: false, + allocatedStorage: 20, + pgstacVersion: "0.9.5", + }, + stacApiConfig: { + customDomainName: "stac-api.example.com", + }, + titilerPgstacConfig: { + mosaicHost: "example.com/table-name", + bucketsPath: "./titiler_buckets.yaml", + dataAccessRoleArn: "arn:aws:iam::123456789012:role/test-role", + customDomainName: "titiler.example.com", + }, + ...overrides, + }); + + return Template.fromStack(stack); +} + +describe("PgStacInfra STAC runtime wiring", () => { + beforeAll(() => { + jest + .spyOn(lambda.Code, "fromDockerBuild") + .mockImplementation(() => lambda.Code.fromAsset("test")); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + test("uses the custom STAC handler and keeps transactions disabled by default", () => { + const template = buildTemplate({ + type: "public", + stacApiConfig: { + customDomainName: "public-stac.example.com", + integrationApiArn: + "arn:aws:execute-api:us-west-2:123456789012:api-id/stage/GET/", + }, + }); + + template.hasResourceProperties("AWS::Lambda::Function", { + Handler: "eoapi.stac.handler.handler", + Environment: { + Variables: Match.objectLike({ + STAC_FASTAPI_TITLE: "MAAP public STAC API (test)", + STAC_FASTAPI_LANDING_ID: "maap-public-stac-api-test", + ENABLED_EXTENSIONS: + "query,sort,fields,filter,free_text,pagination,collection_search", + }), + }, + }); + + expect( + Object.keys( + template.findResources("AWS::SecretsManager::Secret", { + Properties: { + Name: + "/maap-eoapi/test/public/stac-collection-transaction-basic-auth", + }, + }), + ), + ).toHaveLength(0); + template.resourceCountIs("AWS::SSM::Parameter", 1); + }); + + test("enables collection transactions with a stack-managed secret by default", () => { + const template = buildTemplate({ + stacApiConfig: { + customDomainName: "internal-stac.example.com", + transactions: { + authMode: "basic", + }, + }, + }); + + template.hasResourceProperties("AWS::SecretsManager::Secret", { + Description: + "Basic auth secret for MAAP internal STAC collection transactions (test)", + Name: "/maap-eoapi/test/internal/stac-collection-transaction-basic-auth", + GenerateSecretString: Match.objectLike({ + GenerateStringKey: "password", + SecretStringTemplate: '{"username":"maap-internal-stac-writer"}', + }), + }); + + template.hasResourceProperties("AWS::Lambda::Function", { + Handler: "eoapi.stac.handler.handler", + Environment: { + Variables: Match.objectLike({ + ENABLED_EXTENSIONS: + "query,sort,fields,filter,free_text,pagination,collection_search,collection_transaction", + MAAP_TRANSACTION_AUTH_MODE: "basic", + MAAP_TRANSACTION_AUTH_SECRET_ARN: { + Ref: Match.stringLikeRegexp( + "staccollectiontransactionauthsecret", + ), + }, + }), + }, + }); + + template.hasResourceProperties("AWS::SSM::Parameter", { + Name: + "/maap-eoapi/test/internal/stac-collection-transaction-auth-secret-arn", + }); + }); + + test("uses an explicit transaction auth secret ARN override when provided", () => { + const template = buildTemplate({ + stacApiConfig: { + customDomainName: "internal-stac.example.com", + transactions: { + authMode: "basic", + authSecretArn: + "arn:aws:secretsmanager:us-west-2:123456789012:secret:existing-auth-abcdef", + }, + }, + }); + + expect( + Object.keys( + template.findResources("AWS::SecretsManager::Secret", { + Properties: { + Name: + "/maap-eoapi/test/internal/stac-collection-transaction-basic-auth", + }, + }), + ), + ).toHaveLength(0); + template.hasResourceProperties("AWS::Lambda::Function", { + Handler: "eoapi.stac.handler.handler", + Environment: { + Variables: Match.objectLike({ + MAAP_TRANSACTION_AUTH_SECRET_ARN: + "arn:aws:secretsmanager:us-west-2:123456789012:secret:existing-auth-abcdef", + }), + }, + }); + }); +});