diff --git a/.changeset/README.md b/.changeset/README.md new file mode 100644 index 0000000..e21dc61 --- /dev/null +++ b/.changeset/README.md @@ -0,0 +1,9 @@ +# Changesets + +Use Changesets to describe user-facing changes that should be included in the next release. + +Create a changeset with: + +```sh +pnpm changeset +``` diff --git a/.changeset/config.json b/.changeset/config.json new file mode 100644 index 0000000..30895ed --- /dev/null +++ b/.changeset/config.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json", + "changelog": "@changesets/cli/changelog", + "commit": false, + "fixed": [], + "linked": [], + "access": "restricted", + "baseBranch": "main", + "updateInternalDependencies": "patch", + "privatePackages": { + "version": true, + "tag": true + } +} diff --git a/.changeset/quiet-cycles-roll.md b/.changeset/quiet-cycles-roll.md new file mode 100644 index 0000000..318145a --- /dev/null +++ b/.changeset/quiet-cycles-roll.md @@ -0,0 +1,5 @@ +--- +"impose-cli": major +--- + +impose-cli support interactive and non-interactive modes for CLI mode, as well as an optional API deployment. diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..a82c969 --- /dev/null +++ b/.envrc @@ -0,0 +1,11 @@ +use flake + +export UV_PROJECT_ENVIRONMENT="$PWD/.venv" +export VIRTUAL_ENV="$PWD/.venv" + +if [ ! -d "$VIRTUAL_ENV" ]; then + uv venv --python "$UV_PYTHON" "$VIRTUAL_ENV" +fi + +export UV_PYTHON="$VIRTUAL_ENV/bin/python" +PATH_add "$VIRTUAL_ENV/bin" diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..93b5a03 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,93 @@ +name: ci + +on: + pull_request: + types: + - opened + - synchronize + - reopened + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + quality: + name: quality + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@v20 + + - name: Fetch main branch + run: git fetch origin main:main + + - name: Check source changes + id: source-changes + uses: tj-actions/changed-files@v46 + with: + files: | + .github/** + examples/** + intregation/** + src/** + unit/** + .envrc + flake.lock + flake.nix + package.json + pnpm-lock.yaml + pyproject.toml + uv.lock + + - name: Check changeset changes + id: changeset-changes + uses: tj-actions/changed-files@v46 + with: + files: | + .changeset/** + + - name: Require changeset + if: github.event.pull_request.title != 'Version Apps' + run: | + if ${{ steps.source-changes.outputs.any_modified == 'true' && steps.changeset-changes.outputs.any_modified == 'false' }}; then + echo "Source changes require a changeset. Run 'pnpm changeset' and commit the generated .changeset file." + exit 1 + fi + + - name: Install PNPM dependencies + run: nix develop -c pnpm install --frozen-lockfile + + - name: Check changeset status + if: github.event.pull_request.title != 'Version Apps' + run: nix develop -c pnpm changeset status --since=origin/main + + - name: Check formatting + run: nix develop -c uv run ruff format --check + + - name: Lint + run: nix develop -c uv run ruff check + + - name: Type check + run: nix develop -c uv run pyright + + tests: + name: tests + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v5 + + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@v20 + + - name: Run unit tests + run: nix develop -c uv run pytest unit + + - name: Run integration tests + run: nix develop -c uv run pytest integration diff --git a/.github/workflows/version-and-release.yaml b/.github/workflows/version-and-release.yaml new file mode 100644 index 0000000..8ead596 --- /dev/null +++ b/.github/workflows/version-and-release.yaml @@ -0,0 +1,187 @@ +name: version and release + +on: + pull_request: + types: + - closed + branches: + - main + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + rc-package: + name: rc package + runs-on: ubuntu-latest + environment: pypi + if: >- + github.event_name == 'workflow_dispatch' || + ( + github.event_name == 'pull_request' && + github.event.pull_request.merged == true && + github.event.pull_request.title != 'Version Apps' + ) + permissions: + id-token: write + steps: + - name: Checkout repo + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@v20 + + - name: Install PNPM dependencies + run: nix develop -c pnpm install --frozen-lockfile + + - name: Update to the correct RC version + id: version + shell: bash + run: | + set -euo pipefail + + nix develop -c npx changeset status --output=/tmp/release-plan.json + VERSION=$(nix develop -c jq -r '.releases[].newVersion' /tmp/release-plan.json) + + PACKAGE_NAME="impose-cli" + ESCAPED_VERSION="${VERSION//./\\.}" + + LAST_RC=$( + curl --fail --silent --show-error \ + "https://pypi.org/pypi/${PACKAGE_NAME}/json" | + nix develop -c jq -r '.releases | keys[]' | + sed -nE "s/^${ESCAPED_VERSION}rc([0-9]+)$/\1/p" | + sort -n | + tail -n 1 + ) + + RC_NUMBER=$((${LAST_RC:--1} + 1)) + RC_VERSION="${VERSION}rc${RC_NUMBER}" + + nix develop -c uv version "$RC_VERSION" + + echo "version=${RC_VERSION}" >> "$GITHUB_OUTPUT" + + - name: Build RC package + run: nix develop -c uv build + + - name: Upload RC package artifact + uses: actions/upload-artifact@v4 + with: + name: impose-cli-${{ steps.version.outputs.version }} + path: dist/* + if-no-files-found: error + + - name: Publish RC package to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + + version-pr: + name: version apps pr + runs-on: ubuntu-latest + if: >- + github.event_name == 'workflow_dispatch' || + ( + github.event_name == 'pull_request' && + github.event.pull_request.merged == true && + github.event.pull_request.title != 'Version Apps' + ) + permissions: + contents: write + pull-requests: write + steps: + - name: Checkout repo + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@v20 + + - name: Create GitHub App token + id: app-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.KASSETT_PR_BOT_APP_ID }} + private-key: ${{ secrets.KASSETT_PR_BOT_PRIVATE_KEY }} + + - name: Install PNPM dependencies + run: nix develop -c pnpm install --frozen-lockfile + + - name: Version packages + shell: bash + run: | + set -euo pipefail + + # Applies Changesets, updates changelogs, and removes consumed + # Changeset files. + nix develop -c pnpm changeset version + + VERSION="$( + nix develop -c node \ + -p "require('./package.json').version" + )" + + # Keep the Python package version synchronized using uv rather + # than editing pyproject.toml directly. + nix develop -c uv version "$VERSION" + + - name: Create or update Version Apps PR + uses: peter-evans/create-pull-request@v7 + with: + token: ${{ steps.app-token.outputs.token }} + branch: changeset-release-main + sign-commits: true + commit-message: Version apps + title: Version Apps + body: Automated PR to version apps from Changesets. + + release: + name: release + runs-on: ubuntu-latest + environment: pypi + if: >- + github.event_name == 'pull_request' && + github.event.pull_request.merged == true && + github.event.pull_request.title == 'Version Apps' + permissions: + contents: write + id-token: write + steps: + - name: Checkout repo + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@v20 + + - name: Create GitHub App token + id: app-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.KASSETT_PR_BOT_APP_ID }} + private-key: ${{ secrets.KASSETT_PR_BOT_PRIVATE_KEY }} + + - name: Read version + id: version + run: | + VERSION=$(node -p "require('./package.json').version") + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + + - name: Create release + uses: softprops/action-gh-release@v2 + with: + token: ${{ steps.app-token.outputs.token }} + tag_name: ${{ steps.version.outputs.version }} + name: Release ${{ steps.version.outputs.version }} + generate_release_notes: true + + - name: Build package + run: nix develop -c uv build + + - name: Publish package to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/workflow_dev.yml b/.github/workflows/workflow_dev.yml deleted file mode 100644 index 7328f9b..0000000 --- a/.github/workflows/workflow_dev.yml +++ /dev/null @@ -1,45 +0,0 @@ -name: Publish to PyPI - -on: - push: - branches: - - dev - -jobs: - build: - runs-on: ubuntu-latest - env: - TWINE_USERNAME: __token__ - TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} - steps: - - name: Checkout code - uses: actions/checkout@v3 - - - name: Set up Python 3.8 - uses: actions/setup-python@v3 - with: - python-version: 3.8 - - - name: Install dependencies - run: | - python -m venv env - source env/bin/activate - python -m pip install --upgrade pip - pip install -r requirements-dev.txt - pip install -r requirements.txt - pip install -r requirements-api.txt - - # - name: Run pytest - # run: | - # source env/bin/activate - # pytest tests/test.py - - - name: Build distribution - run: | - source env/bin/activate - python setup.py sdist bdist_wheel - - - name: Upload to PyPI - run: | - source env/bin/activate - twine upload dist/* \ No newline at end of file diff --git a/.github/workflows/workflow_master.yml b/.github/workflows/workflow_master.yml deleted file mode 100644 index 106b850..0000000 --- a/.github/workflows/workflow_master.yml +++ /dev/null @@ -1,45 +0,0 @@ -name: Publish to PyPI - -on: - push: - branches: - - master - -jobs: - build: - runs-on: ubuntu-latest - env: - TWINE_USERNAME: __token__ - TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} - steps: - - name: Checkout code - uses: actions/checkout@v3 - - - name: Set up Python 3.8 - uses: actions/setup-python@v3 - with: - python-version: 3.8 - - - name: Install dependencies - run: | - python -m venv env - source env/bin/activate - python -m pip install --upgrade pip - pip install -r requirements-dev.txt - pip install -r requirements.txt - pip install -r requirements-api.txt - -# - name: Run pytest -# run: | -# source env/bin/activate -# pytest tests/test.py - - - name: Build distribution - run: | - source env/bin/activate - python setup.py sdist bdist_wheel - - - name: Upload to PyPI - run: | - source env/bin/activate - twine upload dist/* \ No newline at end of file diff --git a/.gitignore b/.gitignore index e07fbc9..b4f743d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,18 @@ impose-cli.iml venv/ .idea/ .env +.direnv/ +.pytest_cache/ +.coverage +.coverage.* +coverage.xml +coverage.json +htmlcov/ +.venv/ +node_modules/ +dist/ +*.egg-info/ +__pycache__/ +**/__pycache__/ +*.py[cod] +*.iml diff --git a/README.md b/README.md new file mode 100644 index 0000000..d1b4e51 --- /dev/null +++ b/README.md @@ -0,0 +1,540 @@ +# impose-cli + +impose-cli is the fastest and least disruptive way to turn your project, or a subset of your project, into a CLI or API. + +Create an `ImposeApplication()` and point it at a module. Impose will iterate through that module and its submodules, then create a CLI and optionally a FastAPI application that can be mounted into another FastAPI application. + +Every function decorated with `@impose` becomes a command while remaining a normal Python function. Add `impose_api_method` when the function should also become an API endpoint. + +```python +from impose import ImposeApplication, impose + + +@impose(impose_cs="elbv2") +def list_load_balancers(region: str) -> list[str]: + ... + + +app = ImposeApplication() +``` + +## Disruption Free + +The primary goal of Impose is to be disruption free. Adding `@impose` to a function does not change how the function is called from Python code, does not require the function to inherit from a framework type, and does not prevent the function from being imported and reused normally. + +The decorator is a passthrough: + +```python +@impose(impose_cs="elbv2") +def list_load_balancers(region: str) -> list[str]: + ... + + +load_balancers = list_load_balancers("us-east-1") +``` + +Impose-specific decorator options are namespaced with the `impose_` prefix, such as `impose_cs`. This keeps Impose configuration separate from the function's own parameters and limits the chance of keyword argument interference as the function evolves. + +## Command Sets + +`@impose` accepts an `impose_cs` keyword argument, short for `impose_command_set`: + +```python +@impose(impose_cs="elbv2") +def describe_target_groups() -> list[str]: + ... +``` + +All functions under the same command set automatically become subcommands under the same CLI group. Functions that also set `impose_api_method` become endpoints under the same API router path. + +Command descriptions and per-argument help come from the function docstring, keeping the decorator focused on command registration. + +### Example: Explicit Command Sets + +```python +# cloud/elbv2.py +from impose import impose + + +@impose(impose_cs="elbv2") +def list_load_balancers(region: str) -> list[str]: + ... + + +@impose(impose_cs="elbv2") +def describe_target_groups(load_balancer_arn: str) -> list[str]: + ... +``` + +```python +# cli.py +import cloud.elbv2 +from impose import ImposeApplication + + +application = ImposeApplication(modules=[cloud.elbv2]) +cli = application.cli() +``` + +The functions above become subcommands under the same `elbv2` group: + +```sh +impose elbv2 list-load-balancers us-east-1 +impose elbv2 describe-target-groups arn:aws:... +``` + +## Project Structure + +Impose can optionally use the actual project structure to dynamically create commands and subcommands. For example, functions under an `elbv2` folder can become subcommands of the `elbv2` command. + +### Example: Dynamic Project Structure + +Given a project like this: + +```text +cloud_tools/ + __init__.py + aws/ + __init__.py + elbv2.py + rds.py + s3.py + github/ + __init__.py + repos.py + cli.py +``` + +```python +# cloud_tools/aws/elbv2.py +from impose import impose + + +@impose +def list_load_balancers(region: str) -> list[str]: + ... + + +@impose +def describe_target_group(target_group_arn: str) -> dict[str, str]: + ... +``` + +```python +# cloud_tools/aws/s3.py +from impose import impose + + +@impose +def list_buckets(profile: str | None = None) -> list[str]: + ... +``` + +```python +# cloud_tools/aws/rds.py +from impose import impose + + +@impose +def reboot_instance(identifier: str, force_failover: bool = False) -> None: + ... +``` + +```python +# cloud_tools/github/repos.py +from impose import impose + + +@impose +def archive_repo(owner: str, repo: str) -> None: + ... +``` + +Configure Impose to use the Python package structure as the command structure: + +```python +# cloud_tools/cli.py +import cloud_tools +from impose import ImposeApplication + + +application = ImposeApplication( + root_module=cloud_tools, + use_project_structure=True, + modules_as_subcommands=True, +) +cli = application.cli() +``` + +`modules_as_subcommands` is a global setting that defaults to `True`. With the default behavior, Impose reads the modules below `cloud_tools`, finds functions decorated with `@impose`, and creates commands shaped like the package tree, including module filenames: + +```sh +impose aws elbv2 list-load-balancers us-east-1 +impose aws elbv2 describe-target-group arn:aws:... +impose aws rds reboot-instance prod-db-1 --force-failover +impose aws s3 list-buckets --profile prod +impose github repos archive-repo example old-service +``` + +The generated command hierarchy mirrors the source layout: + +```text +cloud_tools/aws/elbv2.py:list_load_balancers + -> impose aws elbv2 list-load-balancers + +cloud_tools/aws/elbv2.py:describe_target_group + -> impose aws elbv2 describe-target-group + +cloud_tools/aws/rds.py:reboot_instance + -> impose aws rds reboot-instance + +cloud_tools/aws/s3.py:list_buckets + -> impose aws s3 list-buckets + +cloud_tools/github/repos.py:archive_repo + -> impose github repos archive-repo +``` + +If you only want folders/packages to create command groups, disable module-generated subcommands: + +```python +# cloud_tools/cli.py +import cloud_tools +from impose import ImposeApplication + + +application = ImposeApplication( + root_module=cloud_tools, + use_project_structure=True, + modules_as_subcommands=False, +) +cli = application.cli() +``` + +With `modules_as_subcommands=False`, the folder still creates the `aws` group, but `rds.py`, `s3.py`, and `elbv2.py` do not add another command level: + +```sh +impose aws list-load-balancers us-east-1 +impose aws describe-target-group arn:aws:... +impose aws reboot-instance prod-db-1 --force-failover +impose aws list-buckets --profile prod +impose github archive-repo example old-service +``` + +## FastAPI + +Impose can create a FastAPI application or router from decorated functions that explicitly opt into API exposure. The generated API can be mounted into another FastAPI application, which lets you expose project functionality without building a separate API layer by hand. + +### Example: Mounting into FastAPI + +```python +# api.py +import cloud_tools +from fastapi import FastAPI +from impose import ImposeApplication + + +service = FastAPI() + +impose_application = ImposeApplication( + root_module=cloud_tools, + use_project_structure=True, +) + +service.include_router( + impose_application.api_router(), + prefix="/internal/tools", +) +``` + +Or create a standalone FastAPI app directly: + +```python +app = impose_application.api_app(title="Internal Tools") +``` + +A decorated function such as: + +```python +@impose(impose_cs="elbv2", impose_api_method="GET") +def list_load_balancers(region: str) -> list[str]: + ... +``` + +can be exposed as an endpoint under the generated router: + +```text +GET /internal/tools/elbv2/list-load-balancers?region=us-east-1 +``` + +If a function is decorated with `@impose` but does not set `impose_api_method`, it remains available to the CLI but is skipped when `api_router()` or `api_app()` is built. Impose emits a warning for each skipped function so accidental API omissions are visible during app startup. + +Supported API methods are `GET`, `PUT`, `POST`, `PATCH`, and `DELETE`. Any other `impose_api_method` value fails loudly when the API app or router is created. + +### API Parameter Rules + +`GET` endpoints read function parameters from query parameters: + +```python +@impose(impose_api_method="GET") +def list_users(tier: str, limit: int = 100) -> list[str]: + ... +``` + +```text +GET /list-users?tier=paid&limit=25 +``` + +`DELETE` endpoints read function parameters from path parameters. Impose appends the function parameters to the route path: + +```python +@impose(impose_api_method="DELETE") +def delete_user(user_id: str, hard: bool) -> str: + ... +``` + +```text +DELETE /delete-user/{user_id}/{hard} +``` + +`PUT`, `POST`, and `PATCH` endpoints read every function parameter from the JSON request body: + +```python +from pydantic import BaseModel + + +class Region(BaseModel): + partition: str + name: str + + +@impose(impose_api_method="POST") +def list_instances(region: Region, owner: str) -> list[str]: + ... +``` + +The body nests Pydantic models under their parameter names: + +```json +{ + "region": { + "partition": "aws", + "name": "us-east-1" + }, + "owner": "platform" +} +``` + +`GET` and `DELETE` only support primitive-like arguments: `str`, `int`, `float`, `bool`, enums, literals of primitive values, and optional unions of those types. Pydantic models and other structured types are rejected for `GET` and `DELETE`; Impose raises an error during API app/router creation instead of creating an invalid route. + +## Middleware + +Impose supports FastAPI-style HTTP middleware on the whole generated API, on command sets, and on individual endpoints. Middleware runs for generated API routes only; CLI calls execute the Python function directly. + +Middleware functions receive a request and `call_next`, matching FastAPI's `@app.middleware("http")` style: + +```python +from fastapi import HTTPException, Request, Response + +from impose import impose + + +async def require_admin_scope(request: Request, call_next) -> Response: + scopes = set(request.headers.get("x-auth-user-scopes", "").split()) + if "admin" not in scopes: + raise HTTPException(status_code=403, detail="Missing admin scope.") + return await call_next(request) + + +@impose( + impose_cs="deploy", + impose_api_method="POST", + impose_middleware=[require_admin_scope], +) +def restart_service(name: str, environment: str) -> str: + ... +``` + +Add middleware to every generated endpoint: + +```python +from impose import ImposeApplication + + +application = ImposeApplication() +application.add_middleware(require_admin_scope) +``` + +This works whether you call `application.api_app()` or include `application.api_router()` in another FastAPI app. In router mode, app-wide middleware means every generated Impose endpoint in that router; it does not become global middleware for unrelated routes in the parent app. + +Add middleware to one command set: + +```python +application.add_command_set_middleware( + "admin", + require_admin_scope, +) +``` + +Every generated API endpoint in the `admin` command set will require the admin scope. + +## Confirmation Prompts + +Commands that need an explicit safety check can opt into confirmation: + +```python +from impose import impose + + +@impose(impose_require_confirmation=True) +def delete_user(username: str, reason: str) -> None: + ... +``` + +After all CLI arguments have been parsed, including any values collected through `-i` interactive mode, Impose prints an alarming confirmation message with a table of the exact function arguments. Long values are truncated instead of wrapped. The command only runs when the user types `yes`; typing `no` aborts it. + +## Tests + +Run unit tests with 100% coverage enforcement: + +```sh +uv run pytest unit +``` + +Run admin-tool integration tests without coverage enforcement: + +```sh +uv run pytest integration +``` + +## Serialization + +Custom serializers can be attached to types expected by imposed functions using Pydantic. This lets your CLI and API share the same typed interface while still accepting and returning rich project-specific objects. + +### Example: Custom Serialization with Pydantic + +```python +from pydantic import BaseModel, field_serializer + +from impose import impose + + +class Region(BaseModel): + partition: str + name: str + + @field_serializer("name") + def serialize_name(self, value: str) -> str: + return value.lower() + + +@impose(impose_cs="ec2") +def list_instances(region: Region) -> list[str]: + ... +``` + +Invoke it from the CLI by passing the Pydantic model as structured input: + +```sh +impose ec2 list-instances '{"partition":"aws","name":"US-EAST-1"}' +``` + +The `field_serializer` normalizes the region name when the value is serialized, so downstream CLI output or API responses can return `us-east-1`. + +If the same function is exposed through FastAPI, the generated endpoint can accept the same shape in the request body: + +```sh +curl -X POST http://localhost:8000/ec2/list-instances \ + -H 'content-type: application/json' \ + -d '{"region":{"partition":"aws","name":"US-EAST-1"}}' +``` + +The same type can be used by the CLI parser and the generated API schema, so project-specific values do not need separate CLI and HTTP representations. + +### Example: Rich Return Types + +```python +from pydantic import BaseModel + +from impose import impose + + +class LoadBalancer(BaseModel): + name: str + arn: str + scheme: str + + +@impose(impose_cs="elbv2") +def get_load_balancer(name: str) -> LoadBalancer: + ... +``` + +When exposed through the API, the Pydantic model becomes the response schema. When used from the CLI, Impose can serialize the result into a stable command output format. + +## Interactive Mode + +Impose supports a global `-i` mode that turns your impose command into an interactive command. Enums, Literals, and Booleans become interactive options that can be selected instead of typed manually. + +### Example: Interactive Options + +```python +from enum import Enum +from typing import Literal + +from impose import impose + + +class Environment(str, Enum): + dev = "dev" + staging = "staging" + prod = "prod" + + +@impose(impose_cs="deploy") +def deploy_service( + service: str, + environment: Environment, + strategy: Literal["rolling", "blue-green"], + dry_run: bool = True, +) -> None: + ... +``` + +Run the command normally: + +```sh +impose deploy deploy-service \ + billing \ + prod \ + rolling \ + --dry-run +``` + +Or use interactive mode: + +```sh +impose -i deploy deploy-service +``` + +In interactive mode, Impose can prompt for `environment`, `strategy`, and `dry_run` using selectable choices. + +## Development + +Enter the development environment: + +```sh +direnv allow +``` + +Install the project environment: + +```sh +uv sync +``` + +Run the CLI: + +```sh +uv run impose --help +``` diff --git a/examples/__init__.py b/examples/__init__.py new file mode 100644 index 0000000..e6638d8 --- /dev/null +++ b/examples/__init__.py @@ -0,0 +1 @@ +"""Runnable Impose examples.""" diff --git a/examples/admin_tool/README.md b/examples/admin_tool/README.md new file mode 100644 index 0000000..8f0eb85 --- /dev/null +++ b/examples/admin_tool/README.md @@ -0,0 +1,99 @@ +# Admin Tool Example + +This example shows how to use Impose to expose the same project functions as both an admin CLI and a FastAPI router. + +The command tree is generated from the Python package structure: + +```text +admin_tool/ + application.py + data.py + middleware.py + storage.py + users.py +``` + +The example uses project structure to create command groups: + +```text +admin-tool data seed-data +admin-tool storage move-s3-files +admin-tool users delete-user +``` + +It also demonstrates all middleware attachment points for generated API endpoints: + +- App-wide middleware: every API endpoint requires `x-auth-user-scopes: admin`. +- Command-set middleware: every `users` endpoint writes an audit message. +- Single-endpoint middleware: `users delete-user` requires `x-admin-delete-approved: true`. + +Run the CLI: + +```sh +uv run python examples/admin_tool/cli.py data seed-data dev --rows 10 +uv run python examples/admin_tool/cli.py users delete-user user-123 "duplicate account" +uv run python examples/admin_tool/cli.py storage move-s3-files s3://inbox/raw s3://archive/raw --dry-run +``` + +Use the same application as an API router: + +```python +# examples/admin_tool/api.py +from admin_tool.application import application + + +app = application.api_app(title="Admin Tool") +``` + +The example only creates a FastAPI application. Run it however your project normally serves FastAPI apps. If you already have an app, include `application.api_router()` into it instead. The app-wide Impose middleware still applies to generated Impose endpoints when used as a router; it does not apply to unrelated routes in the parent app. + +To run this example directly with uvicorn, make both the local `src` package and the example package importable: + +```sh +export PYTHONPATH="$PWD/src:$PWD/examples/admin_tool${PYTHONPATH:+:$PYTHONPATH}" +``` + +From the repo root: + +```sh +uvicorn api:app --app-dir examples/admin_tool +``` + +Or from the example directory: + +```sh +cd examples/admin_tool +uvicorn api:app +``` + +Example request shape once mounted: + +```sh +curl -X POST http://localhost:8000/admin/data/seed-data \ + -H 'content-type: application/json' \ + -H 'x-auth-user-scopes: admin' \ + -d '{"environment":"dev","rows":10}' +``` + +API requests without the `admin` scope fail before the function body runs. + +The `users` command set has additional audit middleware: + +```sh +curl -X PATCH http://localhost:8000/admin/users/disable-user \ + -H 'content-type: application/json' \ + -H 'x-auth-user: sam@example.com' \ + -H 'x-auth-user-scopes: admin' \ + -d '{"user_id":"user-123","ticket":"INC-42"}' +``` + +The `delete-user` endpoint has single-endpoint middleware that requires an explicit approval header: + +```sh +curl -X DELETE http://localhost:8000/admin/users/delete-user/user-123/duplicate-account \ + -H 'x-auth-user: sam@example.com' \ + -H 'x-auth-user-scopes: admin' \ + -H 'x-admin-delete-approved: true' +``` + +The same call without `x-admin-delete-approved: true` fails before `delete_user()` runs. diff --git a/examples/admin_tool/__init__.py b/examples/admin_tool/__init__.py new file mode 100644 index 0000000..0374846 --- /dev/null +++ b/examples/admin_tool/__init__.py @@ -0,0 +1 @@ +"""Admin CLI and API example.""" diff --git a/examples/admin_tool/admin_tool/__init__.py b/examples/admin_tool/admin_tool/__init__.py new file mode 100644 index 0000000..10e9422 --- /dev/null +++ b/examples/admin_tool/admin_tool/__init__.py @@ -0,0 +1 @@ +"""Example admin tool package.""" diff --git a/examples/admin_tool/admin_tool/application.py b/examples/admin_tool/admin_tool/application.py new file mode 100644 index 0000000..3b88a2b --- /dev/null +++ b/examples/admin_tool/admin_tool/application.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from importlib import import_module + +from impose import ImposeApplication + +if __package__: + from .middleware import audit_user_command, require_admin_scope +else: + import sys + from pathlib import Path + + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + from admin_tool.middleware import audit_user_command, require_admin_scope + + +root_module = import_module(__package__ or "admin_tool") + +application = ImposeApplication( + root_module=root_module, + use_project_structure=True, + modules_as_subcommands=True, +) +application.add_middleware(require_admin_scope) +application.add_command_set_middleware("users", audit_user_command) + + +def main() -> None: + application.run(prog="admin-tool") + + +if __name__ == "__main__": + main() diff --git a/examples/admin_tool/admin_tool/data.py b/examples/admin_tool/admin_tool/data.py new file mode 100644 index 0000000..57649c1 --- /dev/null +++ b/examples/admin_tool/admin_tool/data.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from impose import impose + + +@impose(impose_api_method="POST") +def seed_data(environment: str, rows: int = 100) -> str: + """Seed sample data into an environment. + + Use this command after creating a new development or staging environment. + The example only prints what would happen, but a real admin tool could + connect to a database, create reference records, and prepare test users. + + Args: + environment: Environment to seed, such as dev, staging, or prod. + rows: Number of sample records to create. + """ + message = f"Seeded {rows} rows into the {environment} environment." + print(message) + return message + + +@impose(impose_api_method="GET") +def download_data(dataset: str, destination: str) -> str: + """Download an admin dataset to a local destination. + + This represents an operational export command. A production version could + validate access, stream an object from storage, and write it to the given + destination path. + + Args: + dataset: Dataset identifier to download. + destination: Local path where the dataset should be written. + """ + message = f"Downloaded dataset {dataset} to {destination}." + print(message) + return message diff --git a/examples/admin_tool/admin_tool/middleware.py b/examples/admin_tool/admin_tool/middleware.py new file mode 100644 index 0000000..f285eac --- /dev/null +++ b/examples/admin_tool/admin_tool/middleware.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from typing import Any + +from fastapi import HTTPException, Request, Response + + +def _request_scopes(request: Request) -> set[str]: + raw_scopes = request.headers.get("x-auth-user-scopes", "") + return {scope.strip() for scope in raw_scopes.replace(",", " ").split() if scope.strip()} + + +async def require_admin_scope(request: Request, call_next: Any) -> Response: + """Require the admin scope for every generated API endpoint.""" + if "admin" not in _request_scopes(request): + raise HTTPException( + status_code=403, + detail="Missing required 'admin' scope in x-auth-user-scopes.", + ) + return await call_next(request) + + +async def audit_user_command(request: Request, call_next: Any) -> Response: + """Record an audit message for user-management API endpoints.""" + actor = request.headers.get("x-auth-user", "unknown") + print(f"Audit user command actor={actor} path={request.url.path}") + return await call_next(request) + + +async def require_delete_user_approval(request: Request, call_next: Any) -> Response: + """Require an explicit approval header for the delete-user endpoint.""" + if request.headers.get("x-admin-delete-approved") != "true": + raise HTTPException( + status_code=409, + detail="Set x-admin-delete-approved: true to delete a user.", + ) + return await call_next(request) diff --git a/examples/admin_tool/admin_tool/storage.py b/examples/admin_tool/admin_tool/storage.py new file mode 100644 index 0000000..0ecccc5 --- /dev/null +++ b/examples/admin_tool/admin_tool/storage.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from impose import impose + + +@impose(impose_api_method="PATCH") +def move_s3_files(source: str, destination: str, dry_run: bool = True) -> str: + """Move objects from one S3 prefix to another. + + The dry-run default makes the command safer for admin workflows. In a real + tool this could list matching objects, copy them to the destination prefix, + and remove the source objects only when dry-run is disabled. + + Args: + source: Source S3 URI or prefix to move objects from. + destination: Destination S3 URI or prefix to move objects to. + dry_run: Preview the move without changing storage. + """ + mode = "Would move" if dry_run else "Moved" + message = f"{mode} files from {source} to {destination}." + print(message) + return message diff --git a/examples/admin_tool/admin_tool/users.py b/examples/admin_tool/admin_tool/users.py new file mode 100644 index 0000000..c0f26a9 --- /dev/null +++ b/examples/admin_tool/admin_tool/users.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from impose import impose + +from .middleware import require_delete_user_approval + + +@impose(impose_api_method="DELETE", impose_middleware=[require_delete_user_approval]) +def delete_user(user_id: str, reason: str) -> str: + """Delete a user account. + + This example stands in for a privileged destructive operation. A real + implementation could verify the ticket, revoke sessions, delete user-owned + records, and write an audit event with the supplied reason. + + Args: + user_id: Unique user identifier to delete. + reason: Human-readable audit reason for deleting the user. + """ + message = f"Deleted user {user_id} because: {reason}." + print(message) + return message + + +@impose(impose_api_method="PATCH") +def disable_user(user_id: str, ticket: str) -> str: + """Disable a user account for an incident ticket. + + Use this for a reversible account action during support or security + workflows. A real implementation could set a disabled flag, invalidate + tokens, and record the incident ticket for audit review. + + Args: + user_id: Unique user identifier to disable. + ticket: Support or incident ticket authorizing the action. + """ + message = f"Disabled user {user_id} for ticket {ticket}." + print(message) + return message diff --git a/examples/admin_tool/api.py b/examples/admin_tool/api.py new file mode 100644 index 0000000..da486ee --- /dev/null +++ b/examples/admin_tool/api.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +try: + from examples.admin_tool.admin_tool.application import application +except ModuleNotFoundError: + from admin_tool.application import application + +app = application.api_app(title="Admin Tool") diff --git a/examples/admin_tool/cli.py b/examples/admin_tool/cli.py new file mode 100644 index 0000000..26127e9 --- /dev/null +++ b/examples/admin_tool/cli.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from admin_tool.application import application + + +def main() -> None: + application.run(prog="admin-tool") + + +if __name__ == "__main__": + main() diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..13ef078 --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1783224372, + "narHash": "sha256-8i/87eeoqiGE4yOTjwSA3Eh/ziJRQEmd/unYU+K27sk=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "d407951447dcd00442e97087bf374aad70c04cea", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..3ff6ba2 --- /dev/null +++ b/flake.nix @@ -0,0 +1,49 @@ +{ + description = "Python 3.12 development environment for impose-cli"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + }; + + outputs = + { nixpkgs, ... }: + let + supportedSystems = [ + "aarch64-darwin" + "aarch64-linux" + "x86_64-darwin" + "x86_64-linux" + ]; + + forAllSystems = + function: + nixpkgs.lib.genAttrs supportedSystems ( + system: + function { + pkgs = import nixpkgs { + inherit system; + }; + } + ); + in + { + devShells = forAllSystems ( + { pkgs }: + { + default = pkgs.mkShell { + packages = [ + pkgs.nodejs_22 + pkgs.pnpm + pkgs.python312 + pkgs.uv + ]; + + env = { + UV_PYTHON = "${pkgs.python312}/bin/python"; + UV_PYTHON_DOWNLOADS = "never"; + }; + }; + } + ); + }; +} diff --git a/integration/pytest.ini b/integration/pytest.ini new file mode 100644 index 0000000..1a5e889 --- /dev/null +++ b/integration/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +pythonpath = + ../src + ../examples/admin_tool +testpaths = . diff --git a/integration/test_admin_tool.py b/integration/test_admin_tool.py new file mode 100644 index 0000000..4083ab9 --- /dev/null +++ b/integration/test_admin_tool.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +import asyncio + +import pytest +from admin_tool.application import application +from fastapi import HTTPException + + +class Request: + def __init__( + self, + payload=None, + *, + headers=None, + path_params=None, + query_params=None, + path: str = "/", + ): + self.payload = payload + self.headers = headers or {} + self.path_params = path_params or {} + self.query_params = query_params or {} + self.url = type("URL", (), {"path": path})() + + async def json(self): + return self.payload + + +def api_routes(): + return {route.path: route for route in application.api_router().routes} + + +def admin_headers(**extra): + return { + "x-auth-user": "sam@example.com", + "x-auth-user-scopes": "admin", + **extra, + } + + +def test_admin_tool_cli_uses_project_structure(capsys) -> None: + result = application.run(["data", "seed-data", "dev", "--rows", "10"], prog="admin-tool") + + assert result == "Seeded 10 rows into the dev environment." + assert "Seeded 10 rows into the dev environment." in capsys.readouterr().out + + +def test_admin_tool_api_requires_admin_scope() -> None: + endpoint = api_routes()["/data/seed-data"].endpoint + + with pytest.raises(HTTPException) as exc: + asyncio.run( + endpoint( + Request( + {"environment": "dev", "rows": 10}, + headers={"x-auth-user-scopes": "reader"}, + ) + ) + ) + + assert exc.value.status_code == 403 + assert "admin" in exc.value.detail + + +def test_admin_tool_api_allows_admin_scope() -> None: + endpoint = api_routes()["/data/seed-data"].endpoint + + assert ( + asyncio.run( + endpoint( + Request( + {"environment": "dev", "rows": 10}, + headers=admin_headers(), + ) + ) + ) + == "Seeded 10 rows into the dev environment." + ) + + +def test_admin_tool_user_command_set_middleware_audits(capsys) -> None: + endpoint = api_routes()["/users/disable-user"].endpoint + + assert ( + asyncio.run( + endpoint( + Request( + {"user_id": "user-123", "ticket": "INC-42"}, + headers=admin_headers(), + path="/users/disable-user", + ) + ) + ) + == "Disabled user user-123 for ticket INC-42." + ) + + assert ( + "Audit user command actor=sam@example.com path=/users/disable-user" + in capsys.readouterr().out + ) + + +def test_admin_tool_delete_user_requires_endpoint_approval() -> None: + endpoint = api_routes()["/users/delete-user/{user_id}/{reason}"].endpoint + + with pytest.raises(HTTPException) as exc: + asyncio.run( + endpoint( + Request( + headers=admin_headers(), + path_params={"user_id": "user-123", "reason": "duplicate-account"}, + path="/users/delete-user/user-123/duplicate-account", + ) + ) + ) + + assert exc.value.status_code == 409 + assert "x-admin-delete-approved" in exc.value.detail + + +def test_admin_tool_delete_user_with_endpoint_approval() -> None: + endpoint = api_routes()["/users/delete-user/{user_id}/{reason}"].endpoint + + assert ( + asyncio.run( + endpoint( + Request( + headers=admin_headers(**{"x-admin-delete-approved": "true"}), + path_params={"user_id": "user-123", "reason": "duplicate-account"}, + path="/users/delete-user/user-123/duplicate-account", + ) + ) + ) + == "Deleted user user-123 because: duplicate-account." + ) diff --git a/package.json b/package.json new file mode 100644 index 0000000..a7da6b7 --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "impose-cli", + "version": "0.1.0", + "private": true, + "packageManager": "pnpm@10.7.1", + "scripts": { + "changeset": "changeset", + "changeset:status": "changeset status", + "changeset:version": "changeset version" + }, + "devDependencies": { + "@changesets/cli": "^2.29.7" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..f97828b --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,805 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@changesets/cli': + specifier: ^2.29.7 + version: 2.31.0 + +packages: + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@changesets/apply-release-plan@7.1.1': + resolution: {integrity: sha512-9qPCm/rLx/xoOFXIHGB229+4GOL76S4MC+7tyOuTsR6+1jYlfFDQORdvwR5hDA6y4FL2BPt3qpbcQIS+dW85LA==} + + '@changesets/assemble-release-plan@6.0.10': + resolution: {integrity: sha512-rSDcqdJ9KbVyjpBIuCidhvZNIiVt1XaIYp73ycVQRIA5n/j6wQaEk0ChRLMUQ1vkxZe51PTQ9OIhbg6HQMW45A==} + + '@changesets/changelog-git@0.2.1': + resolution: {integrity: sha512-x/xEleCFLH28c3bQeQIyeZf8lFXyDFVn1SgcBiR2Tw/r4IAWlk1fzxCEZ6NxQAjF2Nwtczoen3OA2qR+UawQ8Q==} + + '@changesets/cli@2.31.0': + resolution: {integrity: sha512-AhI4enNTgHu2IZr6K4WZyf0EPch4XVMn1yOMFmCD9gsfBGqMYaHXls5HyDv6/CL5axVQABz68eG30eCtbr2wFg==} + hasBin: true + + '@changesets/config@3.1.4': + resolution: {integrity: sha512-pf0bvD/v6WI2cRlZ6hzpjtZdSlXDXMAJ+Iz7xfFzV4ZxJ8OGGAON+1qYc99ZPrijnt4xp3VGG7eNvAOGS24V1Q==} + + '@changesets/errors@0.2.0': + resolution: {integrity: sha512-6BLOQUscTpZeGljvyQXlWOItQyU71kCdGz7Pi8H8zdw6BI0g3m43iL4xKUVPWtG+qrrL9DTjpdn8eYuCQSRpow==} + + '@changesets/get-dependents-graph@2.1.4': + resolution: {integrity: sha512-ZsS00x6WvmHq3sQv8oCMwL0f/z3wbXCVuSVTJwCnnmbC/iBdNJGFx1EcbMG4PC6sXRyH69liM4A2WKXzn/kRPg==} + + '@changesets/get-release-plan@4.0.16': + resolution: {integrity: sha512-2K5Om6CrMPm45rtvckfzWo7e9jOVCKLCnXia5eUPaURH7/LWzri7pK1TycdzAuAtehLkW7VPbWLCSExTHmiI6g==} + + '@changesets/get-version-range-type@0.4.0': + resolution: {integrity: sha512-hwawtob9DryoGTpixy1D3ZXbGgJu1Rhr+ySH2PvTLHvkZuQ7sRT4oQwMh0hbqZH1weAooedEjRsbrWcGLCeyVQ==} + + '@changesets/git@3.0.4': + resolution: {integrity: sha512-BXANzRFkX+XcC1q/d27NKvlJ1yf7PSAgi8JG6dt8EfbHFHi4neau7mufcSca5zRhwOL8j9s6EqsxmT+s+/E6Sw==} + + '@changesets/logger@0.1.1': + resolution: {integrity: sha512-OQtR36ZlnuTxKqoW4Sv6x5YIhOmClRd5pWsjZsddYxpWs517R0HkyiefQPIytCVh4ZcC5x9XaG8KTdd5iRQUfg==} + + '@changesets/parse@0.4.3': + resolution: {integrity: sha512-ZDmNc53+dXdWEv7fqIUSgRQOLYoUom5Z40gmLgmATmYR9NbL6FJJHwakcCpzaeCy+1D0m0n7mT4jj2B/MQPl7A==} + + '@changesets/pre@2.0.2': + resolution: {integrity: sha512-HaL/gEyFVvkf9KFg6484wR9s0qjAXlZ8qWPDkTyKF6+zqjBe/I2mygg3MbpZ++hdi0ToqNUF8cjj7fBy0dg8Ug==} + + '@changesets/read@0.6.7': + resolution: {integrity: sha512-D1G4AUYGrBEk8vj8MGwf75k9GpN6XL3wg8i42P2jZZwFLXnlr2Pn7r9yuQNbaMCarP7ZQWNJbV6XLeysAIMhTA==} + + '@changesets/should-skip-package@0.1.2': + resolution: {integrity: sha512-qAK/WrqWLNCP22UDdBTMPH5f41elVDlsNyat180A33dWxuUDyNpg6fPi/FyTZwRriVjg0L8gnjJn2F9XAoF0qw==} + + '@changesets/types@4.1.0': + resolution: {integrity: sha512-LDQvVDv5Kb50ny2s25Fhm3d9QSZimsoUGBsUioj6MC3qbMUCuC8GPIvk/M6IvXx3lYhAs0lwWUQLb+VIEUCECw==} + + '@changesets/types@6.1.0': + resolution: {integrity: sha512-rKQcJ+o1nKNgeoYRHKOS07tAMNd3YSN0uHaJOZYjBAgxfV7TUE7JE+z4BzZdQwb5hKaYbayKN5KrYV7ODb2rAA==} + + '@changesets/write@0.4.0': + resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} + + '@inquirer/external-editor@1.0.3': + resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@manypkg/find-root@1.1.0': + resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==} + + '@manypkg/get-packages@1.1.3': + resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@types/node@12.20.55': + resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} + + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + + better-path-resolve@1.0.0: + resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} + engines: {node: '>=4'} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + chardet@2.2.0: + resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + detect-indent@6.1.0: + resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} + engines: {node: '>=8'} + + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + + enquirer@2.4.1: + resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} + engines: {node: '>=8.6'} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + extendable-error@0.1.7: + resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + + fs-extra@7.0.1: + resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} + engines: {node: '>=6 <7 || >=8'} + + fs-extra@8.1.0: + resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} + engines: {node: '>=6 <7 || >=8'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + human-id@4.2.0: + resolution: {integrity: sha512-K3GbkIWqyvvlpfhBPlbEvD97TtqBpAYA4kt+cn2lD2x2HuohzZCibcA2nOlnJT6exqvJLggoB5nv2dNf192nEA==} + hasBin: true + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-subdir@1.2.0: + resolution: {integrity: sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==} + engines: {node: '>=4'} + + is-windows@1.0.2: + resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} + engines: {node: '>=0.10.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + js-yaml@3.15.0: + resolution: {integrity: sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==} + hasBin: true + + js-yaml@4.3.0: + resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} + hasBin: true + + jsonfile@4.0.0: + resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + + lodash.startcase@4.4.0: + resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + + outdent@0.5.0: + resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} + + p-filter@2.1.0: + resolution: {integrity: sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==} + engines: {node: '>=8'} + + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-map@2.1.0: + resolution: {integrity: sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==} + engines: {node: '>=6'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + package-manager-detector@0.2.11: + resolution: {integrity: sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + pify@4.0.1: + resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} + engines: {node: '>=6'} + + prettier@2.8.8: + resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==} + engines: {node: '>=10.13.0'} + hasBin: true + + quansync@0.2.11: + resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + read-yaml-file@1.1.0: + resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==} + engines: {node: '>=6'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + spawndamnit@3.0.1: + resolution: {integrity: sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg==} + + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + term-size@2.2.1: + resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} + engines: {node: '>=8'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + universalify@0.1.2: + resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} + engines: {node: '>= 4.0.0'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + +snapshots: + + '@babel/runtime@7.29.7': {} + + '@changesets/apply-release-plan@7.1.1': + dependencies: + '@changesets/config': 3.1.4 + '@changesets/get-version-range-type': 0.4.0 + '@changesets/git': 3.0.4 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + detect-indent: 6.1.0 + fs-extra: 7.0.1 + lodash.startcase: 4.4.0 + outdent: 0.5.0 + prettier: 2.8.8 + resolve-from: 5.0.0 + semver: 7.8.5 + + '@changesets/assemble-release-plan@6.0.10': + dependencies: + '@changesets/errors': 0.2.0 + '@changesets/get-dependents-graph': 2.1.4 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + semver: 7.8.5 + + '@changesets/changelog-git@0.2.1': + dependencies: + '@changesets/types': 6.1.0 + + '@changesets/cli@2.31.0': + dependencies: + '@changesets/apply-release-plan': 7.1.1 + '@changesets/assemble-release-plan': 6.0.10 + '@changesets/changelog-git': 0.2.1 + '@changesets/config': 3.1.4 + '@changesets/errors': 0.2.0 + '@changesets/get-dependents-graph': 2.1.4 + '@changesets/get-release-plan': 4.0.16 + '@changesets/git': 3.0.4 + '@changesets/logger': 0.1.1 + '@changesets/pre': 2.0.2 + '@changesets/read': 0.6.7 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 + '@changesets/write': 0.4.0 + '@inquirer/external-editor': 1.0.3 + '@manypkg/get-packages': 1.1.3 + ansi-colors: 4.1.3 + enquirer: 2.4.1 + fs-extra: 7.0.1 + mri: 1.2.0 + package-manager-detector: 0.2.11 + picocolors: 1.1.1 + resolve-from: 5.0.0 + semver: 7.8.5 + spawndamnit: 3.0.1 + term-size: 2.2.1 + transitivePeerDependencies: + - '@types/node' + + '@changesets/config@3.1.4': + dependencies: + '@changesets/errors': 0.2.0 + '@changesets/get-dependents-graph': 2.1.4 + '@changesets/logger': 0.1.1 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + fs-extra: 7.0.1 + micromatch: 4.0.8 + + '@changesets/errors@0.2.0': + dependencies: + extendable-error: 0.1.7 + + '@changesets/get-dependents-graph@2.1.4': + dependencies: + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + picocolors: 1.1.1 + semver: 7.8.5 + + '@changesets/get-release-plan@4.0.16': + dependencies: + '@changesets/assemble-release-plan': 6.0.10 + '@changesets/config': 3.1.4 + '@changesets/pre': 2.0.2 + '@changesets/read': 0.6.7 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + + '@changesets/get-version-range-type@0.4.0': {} + + '@changesets/git@3.0.4': + dependencies: + '@changesets/errors': 0.2.0 + '@manypkg/get-packages': 1.1.3 + is-subdir: 1.2.0 + micromatch: 4.0.8 + spawndamnit: 3.0.1 + + '@changesets/logger@0.1.1': + dependencies: + picocolors: 1.1.1 + + '@changesets/parse@0.4.3': + dependencies: + '@changesets/types': 6.1.0 + js-yaml: 4.3.0 + + '@changesets/pre@2.0.2': + dependencies: + '@changesets/errors': 0.2.0 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + fs-extra: 7.0.1 + + '@changesets/read@0.6.7': + dependencies: + '@changesets/git': 3.0.4 + '@changesets/logger': 0.1.1 + '@changesets/parse': 0.4.3 + '@changesets/types': 6.1.0 + fs-extra: 7.0.1 + p-filter: 2.1.0 + picocolors: 1.1.1 + + '@changesets/should-skip-package@0.1.2': + dependencies: + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + + '@changesets/types@4.1.0': {} + + '@changesets/types@6.1.0': {} + + '@changesets/write@0.4.0': + dependencies: + '@changesets/types': 6.1.0 + fs-extra: 7.0.1 + human-id: 4.2.0 + prettier: 2.8.8 + + '@inquirer/external-editor@1.0.3': + dependencies: + chardet: 2.2.0 + iconv-lite: 0.7.3 + + '@manypkg/find-root@1.1.0': + dependencies: + '@babel/runtime': 7.29.7 + '@types/node': 12.20.55 + find-up: 4.1.0 + fs-extra: 8.1.0 + + '@manypkg/get-packages@1.1.3': + dependencies: + '@babel/runtime': 7.29.7 + '@changesets/types': 4.1.0 + '@manypkg/find-root': 1.1.0 + fs-extra: 8.1.0 + globby: 11.1.0 + read-yaml-file: 1.1.0 + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@types/node@12.20.55': {} + + ansi-colors@4.1.3: {} + + ansi-regex@5.0.1: {} + + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + + argparse@2.0.1: {} + + array-union@2.1.0: {} + + better-path-resolve@1.0.0: + dependencies: + is-windows: 1.0.2 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + chardet@2.2.0: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + detect-indent@6.1.0: {} + + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + + enquirer@2.4.1: + dependencies: + ansi-colors: 4.1.3 + strip-ansi: 6.0.1 + + esprima@4.0.1: {} + + extendable-error@0.1.7: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + + fs-extra@7.0.1: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + + fs-extra@8.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.3 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 3.0.0 + + graceful-fs@4.2.11: {} + + human-id@4.2.0: {} + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + ignore@5.3.2: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + is-subdir@1.2.0: + dependencies: + better-path-resolve: 1.0.0 + + is-windows@1.0.2: {} + + isexe@2.0.0: {} + + js-yaml@3.15.0: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + + js-yaml@4.3.0: + dependencies: + argparse: 2.0.1 + + jsonfile@4.0.0: + optionalDependencies: + graceful-fs: 4.2.11 + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + + lodash.startcase@4.4.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mri@1.2.0: {} + + outdent@0.5.0: {} + + p-filter@2.1.0: + dependencies: + p-map: 2.1.0 + + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-map@2.1.0: {} + + p-try@2.2.0: {} + + package-manager-detector@0.2.11: + dependencies: + quansync: 0.2.11 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-type@4.0.0: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + pify@4.0.1: {} + + prettier@2.8.8: {} + + quansync@0.2.11: {} + + queue-microtask@1.2.3: {} + + read-yaml-file@1.1.0: + dependencies: + graceful-fs: 4.2.11 + js-yaml: 3.15.0 + pify: 4.0.1 + strip-bom: 3.0.0 + + resolve-from@5.0.0: {} + + reusify@1.1.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + safer-buffer@2.1.2: {} + + semver@7.8.5: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + signal-exit@4.1.0: {} + + slash@3.0.0: {} + + spawndamnit@3.0.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + sprintf-js@1.0.3: {} + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-bom@3.0.0: {} + + term-size@2.2.1: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + universalify@0.1.2: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c1c59fb --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,89 @@ +[project] +name = "impose-cli" +version = "0.1.0" +description = "Command line tools for impose." +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "docstring-parser>=0.18", + "pydantic>=2.0", +] + +[project.optional-dependencies] +api = [ + "fastapi>=0.100", +] + +[dependency-groups] +dev = [ + "fastapi>=0.100", + "pyright>=1.1", + "pytest>=8.0", + "pytest-cov>=7.0", + "ruff>=0.14", +] + +[project.scripts] +impose = "impose.__main__:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/impose"] + +[tool.hatch.build.targets.sdist] +include = [ + "examples", + "integration", + "src/impose", + "src/tests", + "unit", + "README.md", + "pyproject.toml", +] + +[tool.coverage.run] +branch = true +source = ["impose"] + +[tool.coverage.report] +fail_under = 100 +show_missing = true + +[tool.pyright] +include = ["src/impose"] +pythonVersion = "3.12" +typeCheckingMode = "strict" +venvPath = "." +venv = ".venv" + +[tool.ruff] +line-length = 100 +target-version = "py312" + +[tool.ruff.lint] +select = [ + "E", + "F", + "I", + "UP", + "B", + "D", + "SIM", +] +ignore = [ + "D203", + "D213", + "UP047", +] + +[tool.ruff.lint.per-file-ignores] +"src/tests/**/*.py" = ["B011", "D"] +"unit/**/*.py" = ["D"] +"integration/**/*.py" = ["D"] +"examples/**/*.py" = ["D"] + +[tool.uv] +package = true diff --git a/src/impose/__init__.py b/src/impose/__init__.py new file mode 100644 index 0000000..4367066 --- /dev/null +++ b/src/impose/__init__.py @@ -0,0 +1,10 @@ +"""Public API for impose-cli.""" + +from __future__ import annotations + +from impose.application import ImposeApplication +from impose.decorator import ImposeMetadata, impose + +__all__ = ["ImposeApplication", "ImposeMetadata", "__version__", "impose"] + +__version__ = "0.1.0" diff --git a/src/impose/__main__.py b/src/impose/__main__.py new file mode 100644 index 0000000..f94e30d --- /dev/null +++ b/src/impose/__main__.py @@ -0,0 +1,31 @@ +"""Console entry point for the installed impose command.""" + +from __future__ import annotations + +import argparse + +from impose import __version__ + + +def build_parser() -> argparse.ArgumentParser: + """Build the parser for the installed impose console command.""" + parser = argparse.ArgumentParser( + prog="impose", + description="Command line tools for impose.", + ) + parser.add_argument( + "--version", + action="version", + version=f"%(prog)s {__version__}", + ) + return parser + + +def main() -> None: + """Run the installed impose console command.""" + parser = build_parser() + parser.parse_args() + + +if __name__ == "__main__": + main() diff --git a/src/impose/api.py b/src/impose/api.py new file mode 100644 index 0000000..9ac9eb8 --- /dev/null +++ b/src/impose/api.py @@ -0,0 +1,199 @@ +"""FastAPI integration helpers for Impose applications.""" + +from __future__ import annotations + +import inspect +import warnings +from collections.abc import Callable +from enum import Enum +from types import UnionType +from typing import Any, Literal, Union, cast, get_args, get_origin, get_type_hints + +from impose.commands import CommandSpec +from impose.middleware import HttpMiddleware, apply_http_middleware, middleware_for_endpoint +from impose.values import coerce_kwargs, parse_value + +SUPPORTED_API_METHODS = {"GET", "PUT", "POST", "PATCH", "DELETE"} +BODY_API_METHODS = {"PUT", "POST", "PATCH"} + + +def build_api_router( + commands: list[CommandSpec], + call_command: Callable[[CommandSpec, dict[str, Any]], Any], + *, + application_middleware: list[HttpMiddleware] | None = None, + command_set_middleware: dict[str, list[HttpMiddleware]] | None = None, +) -> Any: + """Build a FastAPI router from discovered commands.""" + try: + from fastapi import APIRouter, Request + except ImportError as exc: + raise RuntimeError( + "Install impose-cli with the 'api' extra to use FastAPI support." + ) from exc + + router = APIRouter() + app_middleware = application_middleware or [] + cs_middleware = command_set_middleware or {} + for command in commands: + method = api_method(command) + if method is None: + warnings.warn( + f"Skipping {command.function.__module__}.{command.function.__name__}: " + "set impose_api_method to expose it as an API endpoint.", + stacklevel=2, + ) + continue + validate_api_command(command, method) + route_path = api_route_path(command, method) + router.add_api_route( + route_path, + api_endpoint( + command, + call_command, + method=method, + middleware=middleware_for_endpoint( + command, + application_middleware=app_middleware, + command_set_middleware=cs_middleware, + ), + request_type=Request, + ), + methods=[method], + ) + return router + + +def api_method(command: CommandSpec) -> str | None: + """Return the normalized API method for a command, if configured.""" + method = command.metadata.api_method + if method is None: + return None + normalized = method.upper() + if normalized not in SUPPORTED_API_METHODS: + raise ValueError( + f"{command.function.__name__} uses unsupported impose_api_method {method!r}. " + "Supported methods are DELETE, GET, PATCH, POST, and PUT." + ) + return normalized + + +def api_route_path(command: CommandSpec, method: str) -> str: + """Return the generated route path for a command and HTTP method.""" + parts = [*command.path, command.name] + if method == "DELETE": + parts.extend(f"{{{parameter.name}}}" for parameter in api_parameters(command)) + return "/" + "/".join(parts) + + +def validate_api_command(command: CommandSpec, method: str) -> None: + """Validate that a command can be exposed for the configured HTTP method.""" + if method in {"GET", "DELETE"}: + for parameter in api_parameters(command): + annotation = get_type_hints(command.function).get(parameter.name, parameter.annotation) + if not is_primitive_api_annotation(annotation): + raise TypeError( + f"{command.function.__name__}.{parameter.name} cannot be used with " + f"{method}; GET and DELETE API parameters must be primitive-like." + ) + + +def api_parameters(command: CommandSpec) -> tuple[inspect.Parameter, ...]: + """Return supported function parameters for an API command.""" + return tuple(inspect.signature(command.function).parameters.values()) + + +def is_primitive_api_annotation(annotation: Any) -> bool: + """Return whether an annotation can be represented as a primitive API value.""" + if annotation is inspect.Signature.empty: + return True + if annotation in (str, int, float, bool): + return True + if inspect.isclass(annotation) and issubclass(annotation, Enum): + return True + origin = get_origin(annotation) + if origin is Literal: + return all(isinstance(value, str | int | float | bool) for value in get_args(annotation)) + if origin in (Union, UnionType): + return all( + arg is type(None) or is_primitive_api_annotation(arg) for arg in get_args(annotation) + ) + return False + + +def api_endpoint( + command: CommandSpec, + call_command: Callable[[CommandSpec, dict[str, Any]], Any], + *, + method: str, + middleware: list[HttpMiddleware], + request_type: type[Any], +) -> Callable[[Any], Any]: + """Build an async FastAPI endpoint for one command.""" + + async def run_endpoint(request: Any) -> Any: + """Coerce a request and invoke the command.""" + kwargs = await request_kwargs(command, request, method) + return call_command(command, kwargs) + + async def endpoint(request: Any) -> Any: + """Run middleware and the endpoint for a request.""" + return await apply_http_middleware(request, run_endpoint, middleware) + + endpoint.__annotations__["request"] = request_type + return endpoint + + +async def request_kwargs(command: CommandSpec, request: Any, method: str) -> dict[str, Any]: + """Return keyword arguments parsed from a request for an API method.""" + if method == "GET": + return primitive_kwargs(command, request.query_params) + if method == "DELETE": + return primitive_kwargs(command, request.path_params) + payload = await request.json() + if not isinstance(payload, dict): + raise ValueError("Request body must be a JSON object.") + return coerce_kwargs(command.function, cast(dict[str, Any], payload)) + + +def primitive_kwargs(command: CommandSpec, values: Any) -> dict[str, Any]: + """Parse primitive query or path values into command keyword arguments.""" + kwargs: dict[str, Any] = {} + type_hints = get_type_hints(command.function) + for parameter in api_parameters(command): + annotation = type_hints.get(parameter.name, parameter.annotation) + if parameter.name not in values: + if parameter.default is inspect.Parameter.empty: + raise ValueError(f"Missing required argument {parameter.name!r}.") + kwargs[parameter.name] = parameter.default + continue + kwargs[parameter.name] = parse_value(str(values[parameter.name]), annotation) + return kwargs + + +def build_api_app( + commands: list[CommandSpec], + call_command: Callable[[CommandSpec, dict[str, Any]], Any], + *, + application_middleware: list[HttpMiddleware] | None = None, + command_set_middleware: dict[str, list[HttpMiddleware]] | None = None, + title: str = "Impose API", +) -> Any: + """Build a FastAPI application from discovered commands.""" + try: + from fastapi import FastAPI + except ImportError as exc: + raise RuntimeError( + "Install impose-cli with the 'api' extra to use FastAPI support." + ) from exc + + app = FastAPI(title=title) + app.include_router( + build_api_router( + commands, + call_command, + application_middleware=application_middleware, + command_set_middleware=command_set_middleware, + ) + ) + return app diff --git a/src/impose/application.py b/src/impose/application.py new file mode 100644 index 0000000..c0fdeae --- /dev/null +++ b/src/impose/application.py @@ -0,0 +1,110 @@ +"""Application facade for discovering and exposing imposed commands.""" + +from __future__ import annotations + +import argparse +import sys +from collections.abc import Sequence +from types import ModuleType +from typing import Any + +from impose.api import build_api_app, build_api_router +from impose.cli import build_parser +from impose.commands import CommandSpec +from impose.discovery import discover_commands +from impose.middleware import HttpMiddleware + + +class ImposeApplication: + """Discover decorated functions and expose them as CLI commands or API routes.""" + + def __init__( + self, + *, + root_module: ModuleType | None = None, + modules: Sequence[ModuleType] | None = None, + use_project_structure: bool = False, + modules_as_subcommands: bool = True, + ) -> None: + """Initialize an Impose application configuration.""" + self.root_module = root_module + self.modules = tuple(modules or ()) + self.use_project_structure = use_project_structure + self.modules_as_subcommands = modules_as_subcommands + self._middleware: list[HttpMiddleware] = [] + self._command_set_middleware: dict[str, list[HttpMiddleware]] = {} + + def add_middleware( + self, + middleware: HttpMiddleware, + ) -> None: + """Add HTTP middleware that applies to every generated endpoint.""" + self._middleware.append(middleware) + + def add_command_set_middleware( + self, + impose_cs: str, + middleware: HttpMiddleware, + ) -> None: + """Add HTTP middleware that applies to endpoints in one command set.""" + self._command_set_middleware.setdefault(impose_cs, []).append(middleware) + + def commands(self) -> list[CommandSpec]: + """Return all commands discovered from configured modules.""" + return discover_commands( + root_module=self.root_module, + modules=self.modules, + use_project_structure=self.use_project_structure, + modules_as_subcommands=self.modules_as_subcommands, + ) + + def cli( + self, + *, + prog: str = "impose", + interactive_mode: bool = False, + ) -> argparse.ArgumentParser: + """Build an argparse parser for the discovered commands.""" + return build_parser( + self.commands(), + self._call, + prog=prog, + interactive_mode=interactive_mode, + ) + + def run(self, argv: Sequence[str] | None = None, *, prog: str = "impose") -> Any: + """Run the generated CLI and return the invoked command result.""" + arguments = list(sys.argv[1:] if argv is None else argv) + parser = self.cli( + prog=prog, + interactive_mode="-i" in arguments or "--interactive" in arguments, + ) + namespace = parser.parse_args(arguments) + handler = getattr(namespace, "__impose_handler__", None) + if handler is None: + parser.print_help() + return None + return handler(namespace) + + def api_router(self) -> Any: + """Build a FastAPI router for the discovered commands.""" + return build_api_router( + self.commands(), + self._call, + application_middleware=self._middleware, + command_set_middleware=self._command_set_middleware, + ) + + def api_app(self, *, title: str = "Impose API") -> Any: + """Build a FastAPI application for the discovered commands.""" + return build_api_app( + self.commands(), + self._call, + application_middleware=self._middleware, + command_set_middleware=self._command_set_middleware, + title=title, + ) + + def _call(self, command: CommandSpec, kwargs: dict[str, Any]) -> Any: + """Invoke a command function.""" + return command.function(**kwargs) diff --git a/src/impose/cli/__init__.py b/src/impose/cli/__init__.py new file mode 100644 index 0000000..fda051c --- /dev/null +++ b/src/impose/cli/__init__.py @@ -0,0 +1,7 @@ +"""CLI construction helpers for Impose.""" + +from __future__ import annotations + +from impose.cli.builder import build_parser + +__all__ = ["build_parser"] diff --git a/src/impose/cli/arguments.py b/src/impose/cli/arguments.py new file mode 100644 index 0000000..fa34d91 --- /dev/null +++ b/src/impose/cli/arguments.py @@ -0,0 +1,180 @@ +"""Argument generation and prompting for generated CLIs.""" + +from __future__ import annotations + +import argparse +import inspect +from collections.abc import Callable +from typing import Any, Protocol, get_type_hints + +from impose.commands import CommandSpec, command_name +from impose.docstrings import parameter_descriptions +from impose.values import ( + annotation_name, + choices_for_annotation, + parse_value, +) + +ParseCallback = Callable[[str], Any] + + +class ArgumentTarget(Protocol): + """Protocol for parser-like objects that accept argparse arguments.""" + + def add_argument(self, *args: Any, **kwargs: Any) -> argparse.Action: + """Add an argument to the target parser or argument group.""" + ... + + +def add_function_arguments( + parser: argparse.ArgumentParser, + command: CommandSpec, + *, + allow_missing_required: bool = False, +) -> None: + """Add argparse arguments for a command function signature.""" + signature = inspect.signature(command.function) + type_hints = get_type_hints(command.function) + descriptions = command_parameter_descriptions(command) + parser._positionals.title = "required arguments" + optional_group = parser.add_argument_group("optional arguments") + for parameter in signature.parameters.values(): + if parameter.kind not in ( + inspect.Parameter.POSITIONAL_OR_KEYWORD, + inspect.Parameter.KEYWORD_ONLY, + ): + raise TypeError( + f"{command.function.__name__} has unsupported parameter {parameter.name!r}." + ) + if parameter.default is inspect.Parameter.empty: + add_positional_argument( + parser, + parameter, + type_hints, + descriptions, + allow_missing=allow_missing_required, + ) + else: + add_optional_argument(optional_group, parameter, type_hints, descriptions) + + +def add_positional_argument( + parser: ArgumentTarget, + parameter: inspect.Parameter, + type_hints: dict[str, Any], + descriptions: dict[str, str], + *, + allow_missing: bool, +) -> None: + """Add one required function parameter as a positional argument.""" + annotation = type_hints.get(parameter.name, parameter.annotation) + kwargs: dict[str, Any] = { + "help": parameter_help(parameter, annotation, descriptions), + } + kwargs["type"] = parse_callback(annotation) + choices = choices_for_annotation(annotation) + if choices is not None: + kwargs["choices"] = choices + if allow_missing: + kwargs["nargs"] = "?" + kwargs["default"] = None + parser.add_argument(parameter.name, metavar=command_name(parameter.name), **kwargs) + + +def add_optional_argument( + parser: ArgumentTarget, + parameter: inspect.Parameter, + type_hints: dict[str, Any], + descriptions: dict[str, str], +) -> None: + """Add one defaulted function parameter as an optional argument.""" + annotation = type_hints.get(parameter.name, parameter.annotation) + kwargs: dict[str, Any] = { + "dest": parameter.name, + "help": parameter_help(parameter, annotation, descriptions), + } + if annotation is bool or isinstance(parameter.default, bool): + kwargs["action"] = argparse.BooleanOptionalAction + kwargs["default"] = None + else: + kwargs["type"] = parse_callback(annotation) + choices = choices_for_annotation(annotation) + if choices is not None: + kwargs["choices"] = choices + kwargs["default"] = parameter.default + parser.add_argument(f"--{command_name(parameter.name)}", **kwargs) + + +def parameter_help( + parameter: inspect.Parameter, + annotation: Any, + descriptions: dict[str, str], +) -> str: + """Build the help text for one function parameter.""" + details: list[str] = [] + if annotation is not inspect.Signature.empty: + details.append(annotation_name(annotation)) + if parameter.default is not inspect.Parameter.empty: + details.append(f"default: {parameter.default!r}") + description = descriptions.get(parameter.name) + if description is None: + return ", ".join(details) + if not details: + return description + return f"{description} ({', '.join(details)})" + + +def command_parameter_descriptions(command: CommandSpec) -> dict[str, str]: + """Return parameter help parsed from the command docstring.""" + return parameter_descriptions(command.function) + + +def prompt_for_parameter(parameter: inspect.Parameter, annotation: Any) -> Any: + """Prompt interactively for one parameter value.""" + choices = choices_for_annotation(annotation) + prompt_name = command_name(parameter.name) + if choices: + print(f"{prompt_name}:") + for index, choice in enumerate(choices, start=1): + print(f" {index}. {choice}") + selected = input("> ").strip() + if selected.isdigit() and 1 <= int(selected) <= len(choices): + selected = choices[int(selected) - 1] + return parse_value(selected, annotation) + if annotation is bool or isinstance(parameter.default, bool): + selected = input(f"{prompt_name} [y/N]: ").strip().lower() + return selected in {"1", "true", "t", "yes", "y", "on"} + selected = input(f"{prompt_name}: ") + return parse_value(selected, annotation) + + +def namespace_kwargs( + command: CommandSpec, + namespace: argparse.Namespace, + parser: argparse.ArgumentParser, +) -> dict[str, Any]: + """Convert an argparse namespace into keyword arguments for a command.""" + kwargs: dict[str, Any] = {} + type_hints = get_type_hints(command.function) + for parameter in inspect.signature(command.function).parameters.values(): + annotation = type_hints.get(parameter.name, parameter.annotation) + value = getattr(namespace, parameter.name) + if value is None: + if namespace.interactive: + value = prompt_for_parameter(parameter, annotation) + elif parameter.default is inspect.Parameter.empty: + parser.error(f"{command_name(parameter.name)} is required") + else: + value = parameter.default + kwargs[parameter.name] = value + return kwargs + + +def parse_callback(annotation: Any) -> ParseCallback: + """Return an argparse type callback for an annotation.""" + + def callback(value: str) -> Any: + """Parse one command-line value using the captured annotation.""" + return parse_value(value, annotation) + + return callback diff --git a/src/impose/cli/builder.py b/src/impose/cli/builder.py new file mode 100644 index 0000000..85748ab --- /dev/null +++ b/src/impose/cli/builder.py @@ -0,0 +1,132 @@ +"""Build argparse parsers from discovered Impose commands.""" + +from __future__ import annotations + +import argparse +from collections.abc import Callable +from typing import Any + +from impose.cli.arguments import add_function_arguments, namespace_kwargs +from impose.cli.confirmation import confirm_command +from impose.cli.formatting import ImposeHelpFormatter, supports_color +from impose.commands import CommandSpec, command_count, command_summary, command_tree +from impose.docstrings import command_description +from impose.values import serialize_result + + +def build_parser( + commands: list[CommandSpec], + call_command: Callable[[CommandSpec, dict[str, Any]], Any], + *, + prog: str, + interactive_mode: bool = False, +) -> argparse.ArgumentParser: + """Build a root argparse parser for discovered commands.""" + color = supports_color() + parser = argparse.ArgumentParser( + prog=prog, + description="Commands generated from decorated Python functions.", + formatter_class=lambda prog: ImposeHelpFormatter( + prog, + color=color, + ), + ) + parser.add_argument( + "-i", + "--interactive", + action="store_true", + help="Prompt for supported argument values interactively.", + ) + add_tree( + parser, + command_tree(commands), + call_command, + interactive_mode=interactive_mode, + ) + return parser + + +def add_tree( + parser: argparse.ArgumentParser, + tree: dict[str, Any], + call_command: Callable[[CommandSpec, dict[str, Any]], Any], + *, + interactive_mode: bool, +) -> None: + """Add nested subparsers for a command tree.""" + subparsers = parser.add_subparsers( + dest=f"command_{id(parser)}", + metavar="", + title="commands", + ) + for name, value in sorted(tree.items()): + if isinstance(value, CommandSpec): + command_parser = subparsers.add_parser( + name, + description=command_description(value.function), + help=command_summary(value.function), + formatter_class=parser.formatter_class, + ) + add_function_arguments( + command_parser, + value, + allow_missing_required=interactive_mode, + ) + command_parser.set_defaults( + __impose_handler__=command_handler( + value, + command_parser, + call_command, + ) + ) + continue + count = command_count(value) + child = subparsers.add_parser( + name, + help=f"{count} command{'s' if count != 1 else ''}", + formatter_class=parser.formatter_class, + ) + add_tree( + child, + value, + call_command, + interactive_mode=interactive_mode, + ) + + +def handle_cli_command( + command: CommandSpec, + namespace: argparse.Namespace, + parser: argparse.ArgumentParser, + call_command: Callable[[CommandSpec, dict[str, Any]], Any], +) -> Any: + """Invoke a command from a parsed namespace and print its result.""" + kwargs = namespace_kwargs(command, namespace, parser) + if command.metadata.require_confirmation and not confirm_command( + command, + kwargs, + color=supports_color(), + ): + return None + try: + result = call_command(command, kwargs) + except PermissionError as exc: + parser.error(str(exc)) + output = serialize_result(result) + if output: + print(output) + return result + + +def command_handler( + command: CommandSpec, + parser: argparse.ArgumentParser, + call_command: Callable[[CommandSpec, dict[str, Any]], Any], +) -> Callable[[argparse.Namespace], Any]: + """Return an argparse handler bound to one command parser.""" + + def handler(namespace: argparse.Namespace) -> Any: + """Handle one parsed CLI invocation.""" + return handle_cli_command(command, namespace, parser, call_command) + + return handler diff --git a/src/impose/cli/confirmation.py b/src/impose/cli/confirmation.py new file mode 100644 index 0000000..cf0a6da --- /dev/null +++ b/src/impose/cli/confirmation.py @@ -0,0 +1,86 @@ +"""Confirmation prompts for commands that require explicit approval.""" + +from __future__ import annotations + +import json +from typing import Any + +from impose.cli.formatting import style +from impose.commands import CommandSpec + +MAX_TABLE_VALUE_WIDTH = 48 + + +def confirm_command( + command: CommandSpec, + kwargs: dict[str, Any], + *, + color: bool, +) -> bool: + """Print a confirmation prompt and return whether the command should run.""" + print(format_confirmation_message(command, kwargs, color=color)) + while True: + response = input("Run this command? Type yes or no: ").strip() + if response == "yes": + return True + if response == "no": + print(style("Aborted.", "1;31", enabled=color)) + return False + print(style("Type yes or no.", "1;31", enabled=color)) + + +def format_confirmation_message( + command: CommandSpec, + kwargs: dict[str, Any], + *, + color: bool, +) -> str: + """Format an alarming confirmation message with an argument table.""" + title = style("!!! CONFIRM DESTRUCTIVE COMMAND !!!", "1;37;41", enabled=color) + command_name = " ".join((*command.path, command.name)) + lines = [ + title, + style(f"Command: {command_name}", "1;31", enabled=color), + style( + "Review every argument before continuing. This command will not run without yes.", + "1;33", + enabled=color, + ), + "", + format_arguments_table(kwargs), + ] + return "\n".join(lines) + + +def format_arguments_table(kwargs: dict[str, Any]) -> str: + """Format keyword arguments as a compact fixed-width table.""" + rows = [(name, truncate_value(argument_value(value))) for name, value in kwargs.items()] + name_width = max([len("Argument"), *(len(name) for name, _ in rows)]) + value_width = max([len("Value"), *(len(value) for _, value in rows)]) + border = f"+-{'-' * name_width}-+-{'-' * value_width}-+" + output = [ + border, + f"| {'Argument'.ljust(name_width)} | {'Value'.ljust(value_width)} |", + border, + ] + output.extend( + f"| {name.ljust(name_width)} | {value.ljust(value_width)} |" for name, value in rows + ) + output.append(border) + return "\n".join(output) + + +def argument_value(value: Any) -> str: + """Return a stable single-line representation for an argument value.""" + try: + return json.dumps(value, default=str, sort_keys=True) + except TypeError: + return str(value) + + +def truncate_value(value: str, *, max_width: int = MAX_TABLE_VALUE_WIDTH) -> str: + """Truncate a table value with an ellipsis when it exceeds the maximum width.""" + single_line = value.replace("\n", "\\n") + if len(single_line) <= max_width: + return single_line + return f"{single_line[: max_width - 3]}..." diff --git a/src/impose/cli/formatting.py b/src/impose/cli/formatting.py new file mode 100644 index 0000000..382003d --- /dev/null +++ b/src/impose/cli/formatting.py @@ -0,0 +1,75 @@ +"""Help formatting utilities for generated CLIs.""" + +from __future__ import annotations + +import argparse +import os +import sys +from collections.abc import Iterable +from typing import Any + + +def supports_color() -> bool: + """Return whether help output should use ANSI colors.""" + color_setting = os.environ.get("IMPOSE_COLOR", "auto").lower() + if color_setting in {"always", "1", "true", "yes"}: + return True + if color_setting in {"never", "0", "false", "no"}: + return False + if os.environ.get("NO_COLOR") is not None: + return False + return sys.stdout.isatty() + + +def style(value: str, code: str, *, enabled: bool) -> str: + """Wrap a string in an ANSI style code when colors are enabled.""" + if not enabled: + return value + return f"\033[{code}m{value}\033[0m" + + +class ImposeHelpFormatter(argparse.HelpFormatter): + """argparse help formatter with compact spacing and optional color.""" + + def __init__(self, prog: str, *, color: bool = False) -> None: + """Initialize the formatter for one parser.""" + super().__init__(prog, max_help_position=34, width=100) + self._color = color + + def start_section(self, heading: str | None) -> None: + """Start a help section with optional colored headings.""" + if heading is not None: + heading = style(heading, "1;36", enabled=self._color) + super().start_section(heading) + + def _format_usage( + self, + usage: str | None, + actions: Iterable[argparse.Action], + groups: Iterable[Any], + prefix: str | None, + ) -> str: + """Format the usage line with an Impose-styled prefix.""" + if prefix is None: + prefix = style("Usage: ", "1;36", enabled=self._color) + return super()._format_usage(usage, actions, groups, prefix) + + def _format_action_invocation(self, action: argparse.Action) -> str: + """Format one action invocation with optional color.""" + if not self._color: + return super()._format_action_invocation(action) + if action.option_strings: + options = ", ".join( + style(option, "33", enabled=True) for option in action.option_strings + ) + if action.nargs == 0: + return options + metavar = self._format_args( + action, + self._get_default_metavar_for_optional(action), + ) + return f"{options} {metavar}" + invocation = super()._format_action_invocation(action) + if action.__class__.__name__ == "_SubParsersAction": + return style(invocation, "32", enabled=True) + return invocation diff --git a/src/impose/commands.py b/src/impose/commands.py new file mode 100644 index 0000000..49cedff --- /dev/null +++ b/src/impose/commands.py @@ -0,0 +1,55 @@ +"""Command model and command-tree helpers.""" + +from __future__ import annotations + +import inspect +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from impose.decorator import ImposeMetadata + + +@dataclass(frozen=True) +class CommandSpec: + """Discovered command metadata used by CLI and API builders.""" + + name: str + path: tuple[str, ...] + function: Callable[..., Any] + metadata: ImposeMetadata + + +def command_name(name: str) -> str: + """Convert a Python identifier into a CLI-friendly command name.""" + return name.replace("_", "-") + + +def command_summary(function: Callable[..., Any]) -> str | None: + """Return the short help summary for a command function.""" + doc = inspect.getdoc(function) + if doc is None: + return None + return doc.splitlines()[0] + + +def command_tree(commands: list[CommandSpec]) -> dict[str, Any]: + """Return a nested command tree keyed by command path segments.""" + tree: dict[str, Any] = {} + for command in commands: + cursor = tree + for part in command.path: + cursor = cursor.setdefault(part, {}) + cursor[command.name] = command + return tree + + +def command_count(tree: dict[str, Any]) -> int: + """Count leaf commands in a nested command tree.""" + count = 0 + for value in tree.values(): + if isinstance(value, CommandSpec): + count += 1 + else: + count += command_count(value) + return count diff --git a/src/impose/decorator.py b/src/impose/decorator.py new file mode 100644 index 0000000..cf56400 --- /dev/null +++ b/src/impose/decorator.py @@ -0,0 +1,77 @@ +"""Decorator and metadata primitives for marking imposed functions.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import Any, Literal, TypeVar, cast, overload + +FunctionT = TypeVar("FunctionT", bound=Callable[..., Any]) +HttpCallNext = Callable[[Any], Any] +HttpMiddleware = Callable[[Any, HttpCallNext], Any] +ApiMethod = Literal["GET", "PUT", "POST", "PATCH", "DELETE"] + + +@dataclass(frozen=True) +class ImposeMetadata: + """Metadata attached to a function decorated with @impose.""" + + command_set: str | None = None + api_method: str | None = None + require_confirmation: bool = False + middleware: tuple[HttpMiddleware, ...] = field(default_factory=tuple) + + +@overload +def impose(function: FunctionT, /) -> FunctionT: ... + + +@overload +def impose( + *, + impose_cs: str | None = None, + impose_command_set: str | None = None, + impose_api_method: ApiMethod | str | None = None, + impose_require_confirmation: bool = False, + impose_middleware: tuple[HttpMiddleware, ...] | list[HttpMiddleware] = (), +) -> Callable[[FunctionT], FunctionT]: ... + + +def impose( + function: FunctionT | None = None, + /, + *, + impose_cs: str | None = None, + impose_command_set: str | None = None, + impose_api_method: ApiMethod | str | None = None, + impose_require_confirmation: bool = False, + impose_middleware: tuple[HttpMiddleware, ...] | list[HttpMiddleware] = (), +) -> FunctionT | Callable[[FunctionT], FunctionT]: + """Mark a function for Impose discovery without wrapping or replacing it.""" + if impose_cs is not None and impose_command_set is not None: + raise ValueError("Use either impose_cs or impose_command_set, not both.") + + command_set = impose_cs if impose_cs is not None else impose_command_set + metadata = ImposeMetadata( + command_set=command_set, + api_method=impose_api_method, + require_confirmation=impose_require_confirmation, + middleware=tuple(impose_middleware), + ) + + def decorate(inner: FunctionT) -> FunctionT: + """Attach Impose metadata to a function and return it unchanged.""" + cast(Any, inner).__impose__ = metadata + return inner + + if function is not None: + return decorate(function) + return decorate + + +def get_impose_metadata(function: Callable[..., Any]) -> ImposeMetadata | None: + """Return Impose metadata attached to a function, if present.""" + metadata = getattr(function, "__impose__", None) + if isinstance(metadata, ImposeMetadata): + return metadata + return None diff --git a/src/impose/discovery.py b/src/impose/discovery.py new file mode 100644 index 0000000..bafe66d --- /dev/null +++ b/src/impose/discovery.py @@ -0,0 +1,105 @@ +"""Module discovery for decorated Impose functions.""" + +from __future__ import annotations + +import inspect +import pkgutil +from collections.abc import Iterable, Sequence +from importlib import import_module +from types import ModuleType + +from impose.commands import CommandSpec, command_name +from impose.decorator import ImposeMetadata, get_impose_metadata + + +def discover_commands( + *, + root_module: ModuleType | None, + modules: Sequence[ModuleType], + use_project_structure: bool, + modules_as_subcommands: bool, +) -> list[CommandSpec]: + """Discover decorated functions from configured modules.""" + commands: list[CommandSpec] = [] + for module, is_package in iter_modules(root_module=root_module, modules=modules): + path = module_path( + module, + is_package=is_package, + root_module=root_module, + use_project_structure=use_project_structure, + modules_as_subcommands=modules_as_subcommands, + ) + for _, function in inspect.getmembers(module, inspect.isfunction): + if function.__module__ != module.__name__: + continue + metadata = get_impose_metadata(function) + if metadata is None: + continue + commands.append( + CommandSpec( + name=command_name(function.__name__), + path=function_path(path, metadata), + function=function, + metadata=metadata, + ) + ) + return sorted(commands, key=lambda command: (*command.path, command.name)) + + +def iter_modules( + *, + root_module: ModuleType | None, + modules: Sequence[ModuleType], +) -> Iterable[tuple[ModuleType, bool]]: + """Yield configured modules and submodules without duplicates.""" + seen: set[str] = set() + for module in modules: + if module.__name__ not in seen: + seen.add(module.__name__) + yield module, hasattr(module, "__path__") + if root_module is None: + return + if root_module.__name__ not in seen: + seen.add(root_module.__name__) + yield root_module, hasattr(root_module, "__path__") + if not hasattr(root_module, "__path__"): + return + prefix = f"{root_module.__name__}." + for module_info in pkgutil.walk_packages(root_module.__path__, prefix): + module = import_module(module_info.name) + if module.__name__ in seen: + continue + seen.add(module.__name__) + yield module, module_info.ispkg + + +def module_path( + module: ModuleType, + *, + is_package: bool, + root_module: ModuleType | None, + use_project_structure: bool, + modules_as_subcommands: bool, +) -> tuple[str, ...]: + """Return command path segments derived from a module name.""" + if not use_project_structure or root_module is None: + return () + root_name = root_module.__name__ + if module.__name__ == root_name: + return () + if not module.__name__.startswith(f"{root_name}."): + return () + relative_parts = module.__name__[len(root_name) + 1 :].split(".") + if modules_as_subcommands or is_package: + return tuple(command_name(part) for part in relative_parts) + return tuple(command_name(part) for part in relative_parts[:-1]) + + +def function_path( + module_path: tuple[str, ...], + metadata: ImposeMetadata, +) -> tuple[str, ...]: + """Return command path segments after applying decorator metadata.""" + if metadata.command_set is not None: + return (command_name(metadata.command_set),) + return module_path diff --git a/src/impose/docstrings.py b/src/impose/docstrings.py new file mode 100644 index 0000000..691890c --- /dev/null +++ b/src/impose/docstrings.py @@ -0,0 +1,34 @@ +"""Docstring parsing helpers for command and parameter usage.""" + +from __future__ import annotations + +import inspect +from collections.abc import Callable +from typing import Any + +from docstring_parser import parse +from docstring_parser.common import Docstring + + +def parameter_descriptions(function: Callable[..., Any]) -> dict[str, str]: + """Return parameter descriptions parsed from a function docstring.""" + docstring = parsed_docstring(function) + return { + parameter.arg_name: parameter.description + for parameter in docstring.params + if parameter.arg_name and parameter.description + } + + +def command_description(function: Callable[..., Any]) -> str | None: + """Return the long command description parsed from a function docstring.""" + docstring = parsed_docstring(function) + description = "\n\n".join( + part for part in (docstring.short_description, docstring.long_description) if part + ) + return description or None + + +def parsed_docstring(function: Callable[..., Any]) -> Docstring: + """Parse a function docstring into a docstring-parser object.""" + return parse(inspect.getdoc(function) or "") diff --git a/src/impose/middleware.py b/src/impose/middleware.py new file mode 100644 index 0000000..1dee72b --- /dev/null +++ b/src/impose/middleware.py @@ -0,0 +1,68 @@ +"""Middleware helpers for generated FastAPI endpoints.""" + +from __future__ import annotations + +import inspect +from collections.abc import Awaitable, Callable, Iterable +from typing import Any + +from impose.commands import CommandSpec + +HttpCallNext = Callable[[Any], Awaitable[Any]] +HttpMiddleware = Callable[[Any, HttpCallNext], Any] + + +def middleware_for_endpoint( + command: CommandSpec, + *, + application_middleware: Iterable[HttpMiddleware], + command_set_middleware: dict[str, list[HttpMiddleware]], +) -> list[HttpMiddleware]: + """Return all HTTP middleware configured for an endpoint.""" + middleware = list(application_middleware) + command_set = command_set_name(command) + if command_set is not None: + middleware.extend(command_set_middleware.get(command_set, [])) + middleware.extend(command.metadata.middleware) + return middleware + + +def command_set_name(command: CommandSpec) -> str | None: + """Return the command-set name for explicit or project-structure commands.""" + if command.metadata.command_set is not None: + return command.metadata.command_set + if command.path: + return command.path[0] + return None + + +async def apply_http_middleware( + request: Any, + endpoint: Callable[[Any], Awaitable[Any]], + middleware: Iterable[HttpMiddleware], +) -> Any: + """Apply HTTP middleware around an endpoint request handler.""" + chain = tuple(middleware) + + async def call_next(index: int, next_request: Any) -> Any: + """Call the next middleware item or the endpoint.""" + if index == len(chain): + return await endpoint(next_request) + middleware_item = dispatch_callable(chain[index]) + result = middleware_item( + next_request, + lambda forwarded_request: call_next(index + 1, forwarded_request), + ) + if inspect.isawaitable(result): + return await result + return result + + return await call_next(0, request) + + +def dispatch_callable(middleware: HttpMiddleware) -> HttpMiddleware: + """Return a FastAPI-style dispatch callable for middleware.""" + dispatch = getattr(middleware, "dispatch", None) + if callable(dispatch): + return dispatch + return middleware diff --git a/src/impose/values.py b/src/impose/values.py new file mode 100644 index 0000000..13f81a8 --- /dev/null +++ b/src/impose/values.py @@ -0,0 +1,115 @@ +"""Value parsing and serialization helpers for CLI and API inputs.""" + +from __future__ import annotations + +import inspect +import json +from collections.abc import Callable +from enum import Enum +from types import UnionType +from typing import Any, Literal, Union, cast, get_args, get_origin, get_type_hints + +from pydantic import BaseModel + + +def annotation_accepts_none(annotation: Any) -> bool: + """Return whether an annotation accepts None.""" + if annotation is inspect.Signature.empty: + return False + origin = get_origin(annotation) + if origin in (Union, UnionType): + return type(None) in get_args(annotation) + return False + + +def is_pydantic_model(annotation: Any) -> bool: + """Return whether an annotation is a Pydantic model class.""" + return inspect.isclass(annotation) and issubclass(annotation, BaseModel) + + +def pydantic_model(annotation: Any) -> type[BaseModel]: + """Cast an annotation to a Pydantic model class.""" + return cast(type[BaseModel], annotation) + + +def parse_value(value: str, annotation: Any) -> Any: + """Parse a string value according to a function annotation.""" + if annotation is inspect.Signature.empty or annotation is str: + return value + if annotation is int: + return int(value) + if annotation is float: + return float(value) + if annotation is bool: + return value.lower() in {"1", "true", "t", "yes", "y", "on"} + if inspect.isclass(annotation) and issubclass(annotation, Enum): + return annotation(value) + if is_pydantic_model(annotation): + return pydantic_model(annotation).model_validate_json(value) + + origin = get_origin(annotation) + args = get_args(annotation) + if origin is Literal: + return value + if origin in (Union, UnionType): + non_none_args = [arg for arg in args if arg is not type(None)] + if value == "" and annotation_accepts_none(annotation): + return None + if len(non_none_args) == 1: + return parse_value(value, non_none_args[0]) + + return value + + +def serialize_result(result: Any) -> str: + """Serialize a command result for CLI output.""" + if result is None: + return "" + if isinstance(result, BaseModel): + return result.model_dump_json() + return json.dumps(result, default=str) + + +def choices_for_annotation(annotation: Any) -> list[str] | None: + """Return CLI choices for enum and literal annotations.""" + if inspect.isclass(annotation) and issubclass(annotation, Enum): + return [str(item.value) for item in annotation] + if get_origin(annotation) is Literal: + return [str(item) for item in get_args(annotation)] + return None + + +def annotation_name(annotation: Any) -> str: + """Return a short display name for an annotation.""" + if annotation is bool: + return "boolean" + if annotation in (str, int, float): + return annotation.__name__ + if inspect.isclass(annotation) and issubclass(annotation, Enum): + return "choice" + if is_pydantic_model(annotation): + return "json" + if get_origin(annotation) is Literal: + return "choice" + return "value" + + +def coerce_kwargs(function: Callable[..., Any], values: dict[str, Any]) -> dict[str, Any]: + """Coerce API payload values into keyword arguments for a function.""" + kwargs: dict[str, Any] = {} + type_hints = get_type_hints(function) + for parameter in inspect.signature(function).parameters.values(): + if parameter.name not in values: + if parameter.default is inspect.Parameter.empty: + raise ValueError(f"Missing required argument {parameter.name!r}.") + kwargs[parameter.name] = parameter.default + continue + value = values[parameter.name] + annotation = type_hints.get(parameter.name, parameter.annotation) + if is_pydantic_model(annotation): + kwargs[parameter.name] = pydantic_model(annotation).model_validate(value) + elif inspect.isclass(annotation) and issubclass(annotation, Enum): + kwargs[parameter.name] = annotation(value) + else: + kwargs[parameter.name] = value + return kwargs diff --git a/src/tests/helpers.py b/src/tests/helpers.py new file mode 100644 index 0000000..241aee7 --- /dev/null +++ b/src/tests/helpers.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from enum import StrEnum +from types import ModuleType + +from pydantic import BaseModel + + +class Color(StrEnum): + red = "red" + blue = "blue" + + +class Payload(BaseModel): + name: str + + +def module_for(function, name: str = "coverage_commands") -> ModuleType: + module = ModuleType(name) + function.__module__ = module.__name__ + setattr(module, function.__name__, function) + return module diff --git a/src/tests/test_api.py b/src/tests/test_api.py new file mode 100644 index 0000000..cf10272 --- /dev/null +++ b/src/tests/test_api.py @@ -0,0 +1,382 @@ +from __future__ import annotations + +import asyncio +from enum import StrEnum +from typing import Literal + +import pytest +from helpers import module_for +from pydantic import BaseModel + +from impose import ImposeApplication, impose +from impose.commands import CommandSpec +from impose.decorator import get_impose_metadata +from impose.middleware import middleware_for_endpoint + + +class Request: + def __init__( + self, + payload=None, + *, + path_params=None, + query_params=None, + scopes: str = "", + ): + self.payload = payload + self.path_params = path_params or {} + self.query_params = query_params or {} + self.headers = {"x-auth-user-scopes": scopes} + + async def json(self): + return self.payload + + +async def require_admin_scope(request, call_next): + scopes = set(request.headers.get("x-auth-user-scopes", "").replace(",", " ").split()) + if "admin" not in scopes: + raise PermissionError("missing admin scope") + return await call_next(request) + + +class Region(BaseModel): + name: str + + +class Tier(StrEnum): + free = "free" + paid = "paid" + + +def test_api_router_success_and_invalid_payload() -> None: + @impose(impose_api_method="POST") + def hello(name: str) -> dict[str, str]: + return {"hello": name} + + app = ImposeApplication(modules=[module_for(hello, "api_commands")]) + router = app.api_router() + endpoint = router.routes[0].endpoint + + assert asyncio.run(endpoint(Request({"name": "sam"}))) == {"hello": "sam"} + with pytest.raises(ValueError, match="Request body must be a JSON object"): + asyncio.run(endpoint(Request(["not", "object"]))) + + +def test_api_app_builds_fastapi_app() -> None: + @impose(impose_api_method="POST") + def hello(name: str) -> dict[str, str]: + return {"hello": name} + + app = ImposeApplication(modules=[module_for(hello, "api_app_commands")]) + fastapi_app = app.api_app(title="Example API") + + assert fastapi_app.title == "Example API" + assert str(fastapi_app.url_path_for("endpoint")) == "/hello" + + +def test_api_router_missing_fastapi(monkeypatch) -> None: + @impose(impose_api_method="POST") + def hello() -> str: + return "hello" + + real_import = __import__ + + def fake_import(name, *args, **kwargs): + if name == "fastapi": + raise ImportError("missing") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr("builtins.__import__", fake_import) + app = ImposeApplication(modules=[module_for(hello, "missing_fastapi_commands")]) + + with pytest.raises(RuntimeError, match="api"): + app.api_router() + + with pytest.raises(RuntimeError, match="api"): + app.api_app() + + +def test_api_app_middleware_checks_request_headers() -> None: + @impose(impose_api_method="POST") + def delete_user(username: str) -> dict[str, str]: + return {"deleted": username} + + app = ImposeApplication(modules=[module_for(delete_user, "delete_commands")]) + app.add_middleware(require_admin_scope) + endpoint = app.api_router().routes[0].endpoint + + with pytest.raises(PermissionError, match="missing admin scope"): + asyncio.run(endpoint(Request({"username": "sam"}, scopes="reader"))) + + assert asyncio.run(endpoint(Request({"username": "sam"}, scopes="reader admin"))) == { + "deleted": "sam" + } + + +def test_app_middleware_survives_including_api_router_in_parent_app() -> None: + from fastapi import FastAPI + + @impose(impose_api_method="POST") + def delete_user(username: str) -> dict[str, str]: + return {"deleted": username} + + impose_app = ImposeApplication(modules=[module_for(delete_user, "mounted_commands")]) + impose_app.add_middleware(require_admin_scope) + + parent_app = FastAPI() + parent_app.include_router(impose_app.api_router(), prefix="/tools") + included_router = next( + route for route in parent_app.routes if hasattr(route, "original_router") + ) + routes = {route.path: route.endpoint for route in included_router.original_router.routes} + endpoint = routes["/delete-user"] + + with pytest.raises(PermissionError, match="missing admin scope"): + asyncio.run(endpoint(Request({"username": "sam"}, scopes="reader"))) + + assert asyncio.run(endpoint(Request({"username": "sam"}, scopes="admin"))) == {"deleted": "sam"} + + +def test_command_set_and_endpoint_middleware_apply_to_matching_routes() -> None: + calls: list[str] = [] + + async def audit_command_set(request, call_next): + calls.append("command-set") + return await call_next(request) + + class EndpointAudit: + async def dispatch(self, request, call_next): + calls.append("endpoint") + return await call_next(request) + + @impose(impose_cs="admin", impose_api_method="POST", impose_middleware=[EndpointAudit()]) + def delete_user(username: str) -> dict[str, str]: + return {"deleted": username} + + @impose(impose_cs="public", impose_api_method="POST") + def ping() -> dict[str, str]: + return {"ok": "true"} + + module = module_for(delete_user, "middleware_commands") + ping.__module__ = module.__name__ + module.ping = ping + + app = ImposeApplication(modules=[module]) + app.add_command_set_middleware("admin", audit_command_set) + routes = {route.path: route.endpoint for route in app.api_router().routes} + + assert asyncio.run(routes["/admin/delete-user"](Request({"username": "sam"}))) == { + "deleted": "sam" + } + assert calls == ["command-set", "endpoint"] + + calls.clear() + assert asyncio.run(routes["/public/ping"](Request({}))) == {"ok": "true"} + assert calls == [] + + +def test_command_set_middleware_can_match_project_structure_path() -> None: + async def audit_users(request, call_next): + return await call_next(request) + + @impose + def disable_user(username: str) -> dict[str, str]: + return {"disabled": username} + + command = CommandSpec( + "disable-user", + ("users",), + disable_user, + get_impose_metadata(disable_user), + ) + + assert middleware_for_endpoint( + command, + application_middleware=[], + command_set_middleware={"users": [audit_users]}, + ) == [audit_users] + + +def test_endpoint_middleware_only_runs_for_that_endpoint() -> None: + calls: list[str] = [] + + async def endpoint_only(request, call_next): + calls.append("endpoint-only") + return await call_next(request) + + @impose(impose_cs="admin", impose_api_method="POST", impose_middleware=[endpoint_only]) + def delete_user(username: str) -> dict[str, str]: + return {"deleted": username} + + @impose(impose_cs="admin", impose_api_method="POST") + def disable_user(username: str) -> dict[str, str]: + return {"disabled": username} + + @impose(impose_cs="admin", impose_api_method="GET") + def list_users(limit: int = 10) -> dict[str, int]: + return {"limit": limit} + + module = module_for(delete_user, "endpoint_isolation_commands") + for function in (disable_user, list_users): + function.__module__ = module.__name__ + setattr(module, function.__name__, function) + + routes = { + route.path: route.endpoint + for route in ImposeApplication(modules=[module]).api_router().routes + } + + assert asyncio.run(routes["/admin/disable-user"](Request({"username": "sam"}))) == { + "disabled": "sam" + } + assert asyncio.run(routes["/admin/list-users"](Request(query_params={"limit": "25"}))) == { + "limit": 25 + } + assert calls == [] + + assert asyncio.run(routes["/admin/delete-user"](Request({"username": "sam"}))) == { + "deleted": "sam" + } + assert calls == ["endpoint-only"] + + calls.clear() + assert asyncio.run(routes["/admin/disable-user"](Request({"username": "sam"}))) == { + "disabled": "sam" + } + assert calls == [] + + +def test_sync_endpoint_middleware_can_return_directly() -> None: + def stop_request(_request, _call_next): + return {"stopped": "true"} + + @impose(impose_api_method="POST", impose_middleware=[stop_request]) + def ping() -> dict[str, str]: + return {"ok": "true"} + + app = ImposeApplication(modules=[module_for(ping, "sync_middleware_commands")]) + endpoint = app.api_router().routes[0].endpoint + + assert asyncio.run(endpoint(Request({}))) == {"stopped": "true"} + + +def test_api_router_skips_commands_without_api_method() -> None: + @impose + def cli_only() -> str: + return "cli" + + app = ImposeApplication(modules=[module_for(cli_only, "cli_only_commands")]) + + with pytest.warns(UserWarning, match="set impose_api_method"): + router = app.api_router() + + assert router.routes == [] + + +def test_api_methods_parse_query_path_and_nested_body_values() -> None: + @impose(impose_api_method="GET") + def list_users(tier: Tier, limit: int = 10, active: bool = True) -> dict[str, object]: + return {"tier": tier.value, "limit": limit, "active": active} + + @impose(impose_api_method="DELETE") + def delete_user(user_id: str, hard: bool) -> dict[str, object]: + return {"user_id": user_id, "hard": hard} + + @impose(impose_api_method="PUT") + def update_region(region: Region, owner: str) -> dict[str, str]: + return {"region": region.name, "owner": owner} + + module = module_for(list_users, "api_method_commands") + for function in (delete_user, update_region): + function.__module__ = module.__name__ + setattr(module, function.__name__, function) + + routes = { + route.path: route for route in ImposeApplication(modules=[module]).api_router().routes + } + + assert routes["/list-users"].methods == {"GET"} + assert asyncio.run( + routes["/list-users"].endpoint( + Request(query_params={"tier": "paid", "limit": "25", "active": "false"}) + ) + ) == {"tier": "paid", "limit": 25, "active": False} + + assert routes["/delete-user/{user_id}/{hard}"].methods == {"DELETE"} + assert asyncio.run( + routes["/delete-user/{user_id}/{hard}"].endpoint( + Request(path_params={"user_id": "user-123", "hard": "true"}) + ) + ) == {"user_id": "user-123", "hard": True} + + assert routes["/update-region"].methods == {"PUT"} + assert asyncio.run( + routes["/update-region"].endpoint( + Request({"region": {"name": "us-east-1"}, "owner": "platform"}) + ) + ) == {"region": "us-east-1", "owner": "platform"} + + +def test_api_method_validation_fails_loudly() -> None: + @impose(impose_api_method="OPTIONS") + def unsupported() -> str: + return "bad" + + with pytest.raises(ValueError, match="unsupported impose_api_method"): + ImposeApplication( + modules=[module_for(unsupported, "unsupported_method_commands")] + ).api_router() + + @impose(impose_api_method="GET") + def invalid_get(region: Region) -> str: + return region.name + + with pytest.raises(TypeError, match="primitive-like"): + ImposeApplication(modules=[module_for(invalid_get, "invalid_get_commands")]).api_router() + + @impose(impose_api_method="DELETE") + def invalid_delete(region: Region | None) -> str: + return region.name if region else "none" + + with pytest.raises(TypeError, match="primitive-like"): + ImposeApplication( + modules=[module_for(invalid_delete, "invalid_delete_commands")] + ).api_router() + + +def test_get_method_accepts_literal_and_optional_primitives() -> None: + @impose(impose_api_method="GET") + def choose(mode: Literal["fast", "safe"], count: int | None = None) -> dict[str, object]: + return {"mode": mode, "count": count} + + endpoint = ( + ImposeApplication(modules=[module_for(choose, "literal_get_commands")]) + .api_router() + .routes[0] + .endpoint + ) + + assert asyncio.run(endpoint(Request(query_params={"mode": "fast", "count": ""}))) == { + "mode": "fast", + "count": None, + } + + +def test_get_method_handles_unannotated_required_and_default_query_parameters() -> None: + @impose(impose_api_method="GET") + def search(term, page: int = 1) -> dict[str, object]: + return {"term": term, "page": page} + + endpoint = ( + ImposeApplication(modules=[module_for(search, "query_default_commands")]) + .api_router() + .routes[0] + .endpoint + ) + + assert asyncio.run(endpoint(Request(query_params={"term": "impose"}))) == { + "term": "impose", + "page": 1, + } + with pytest.raises(ValueError, match="Missing required argument"): + asyncio.run(endpoint(Request(query_params={}))) diff --git a/src/tests/test_api_fuzz.py b/src/tests/test_api_fuzz.py new file mode 100644 index 0000000..d94ee0c --- /dev/null +++ b/src/tests/test_api_fuzz.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +from types import ModuleType +from typing import Any, Literal + +import pytest +from pydantic import BaseModel + +from impose import ImposeApplication, impose + + +class FuzzModel(BaseModel): + value: str + + +def module_for_function(function, name: str) -> ModuleType: + module = ModuleType(name) + function.__module__ = module.__name__ + setattr(module, function.__name__, function) + return module + + +def test_supported_api_methods_are_case_insensitive() -> None: + for index, method in enumerate(("get", "PUT", "post", "Patch", "DELETE")): + parameter = "item" if method.upper() == "DELETE" else None + + @impose(impose_api_method=method) + def command(item: str = "value") -> str: + return item + + app = ImposeApplication(modules=[module_for_function(command, f"method_{index}")]) + route = app.api_router().routes[0] + + assert route.methods == {method.upper()} + if parameter is None: + assert route.path == "/command" + else: + assert route.path == f"/command/{{{parameter}}}" + + +def test_unsupported_api_methods_fail_loudly() -> None: + for index, method in enumerate(("HEAD", "OPTIONS", "TRACE", "CONNECT", "")): + + @impose(impose_api_method=method) + def command() -> str: + return "bad" + + app = ImposeApplication(modules=[module_for_function(command, f"bad_method_{index}")]) + + with pytest.raises(ValueError, match="unsupported impose_api_method"): + app.api_router() + + +def test_get_and_delete_accept_only_primitive_like_annotations() -> None: + accepted: tuple[Any, ...] = ( + str, + int, + float, + bool, + Literal["one", "two"], + int | None, + ) + rejected: tuple[Any, ...] = ( + FuzzModel, + list[str], + dict[str, str], + FuzzModel | None, + Literal[object()], + ) + + for method in ("GET", "DELETE"): + for index, annotation in enumerate(accepted): + namespace: dict[str, Any] = {} + exec( + "def command(value: annotation) -> str:\n return str(value)\n", + {"annotation": annotation}, + namespace, + ) + command = impose(impose_api_method=method)(namespace["command"]) + app = ImposeApplication( + modules=[module_for_function(command, f"accepted_{method.lower()}_{index}")] + ) + + assert len(app.api_router().routes) == 1 + + for index, annotation in enumerate(rejected): + namespace = {} + exec( + "def command(value: annotation) -> str:\n return str(value)\n", + {"annotation": annotation}, + namespace, + ) + command = impose(impose_api_method=method)(namespace["command"]) + app = ImposeApplication( + modules=[module_for_function(command, f"rejected_{method.lower()}_{index}")] + ) + + with pytest.raises(TypeError, match="primitive-like"): + app.api_router() diff --git a/src/tests/test_cli_arguments.py b/src/tests/test_cli_arguments.py new file mode 100644 index 0000000..960d4dd --- /dev/null +++ b/src/tests/test_cli_arguments.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import argparse +from typing import Literal + +import pytest +from helpers import Color, module_for + +from impose import ImposeApplication, impose +from impose.cli.arguments import namespace_kwargs, parameter_help, prompt_for_parameter +from impose.commands import CommandSpec +from impose.decorator import get_impose_metadata + + +def test_prompt_for_parameter_choices_bool_and_plain(monkeypatch) -> None: + parameter = next(iter(__import__("inspect").signature(lambda color: None).parameters.values())) + monkeypatch.setattr("builtins.input", lambda _: "2") + assert prompt_for_parameter(parameter, Color) is Color.blue + + bool_parameter = next( + iter(__import__("inspect").signature(lambda enabled=True: None).parameters.values()) + ) + monkeypatch.setattr("builtins.input", lambda _: "y") + assert prompt_for_parameter(bool_parameter, bool) is True + + plain_parameter = next( + iter(__import__("inspect").signature(lambda name: None).parameters.values()) + ) + monkeypatch.setattr("builtins.input", lambda _: "sam") + assert prompt_for_parameter(plain_parameter, str) == "sam" + + monkeypatch.setattr("builtins.input", lambda _: "red") + assert prompt_for_parameter(parameter, Color) is Color.red + + +def test_parameter_help_without_details() -> None: + parameter = next(iter(__import__("inspect").signature(lambda value: None).parameters.values())) + assert ( + parameter_help(parameter, __import__("inspect").Signature.empty, {"value": "Value help."}) + == "Value help." + ) + + +def test_optional_bool_choice_and_positional_choice_arguments() -> None: + @impose + def configure( + color: Color, mode: Literal["fast", "safe"] = "fast", enabled: bool = False + ) -> tuple[Color, str, bool]: + return color, mode, enabled + + app = ImposeApplication(modules=[module_for(configure, "configure_commands")]) + + assert app.run(["configure", "blue", "--mode", "safe", "--enabled"]) == ( + Color.blue, + "safe", + True, + ) + + +def test_positional_argument_with_underscore_uses_python_parameter_name() -> None: + @impose + def delete_user(user_id: str) -> str: + return user_id + + app = ImposeApplication(modules=[module_for(delete_user, "underscore_commands")]) + + assert app.run(["delete-user", "user-123"]) == "user-123" + + +def test_namespace_kwargs_missing_required_and_default_branch() -> None: + @impose + def command(name: str, count: int = 3) -> str: + return name + + command_spec = CommandSpec("command", (), command, get_impose_metadata(command)) + parser = argparse.ArgumentParser() + + with pytest.raises(SystemExit) as exc: + namespace_kwargs( + command_spec, + argparse.Namespace(name=None, count=None, interactive=False), + parser, + ) + assert exc.value.code == 2 + + assert namespace_kwargs( + command_spec, + argparse.Namespace(name="sam", count=None, interactive=False), + parser, + ) == {"name": "sam", "count": 3} diff --git a/src/tests/test_cli_confirmation.py b/src/tests/test_cli_confirmation.py new file mode 100644 index 0000000..321794b --- /dev/null +++ b/src/tests/test_cli_confirmation.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +from types import ModuleType + +from helpers import module_for + +from impose import ImposeApplication, impose +from impose.cli.confirmation import ( + argument_value, + confirm_command, + format_arguments_table, + format_confirmation_message, + truncate_value, +) +from impose.commands import CommandSpec +from impose.decorator import get_impose_metadata + + +def test_confirmation_message_formats_alarming_table_with_truncated_values() -> None: + @impose(impose_require_confirmation=True) + def delete_user(username: str, reason: str) -> None: + pass + + command = CommandSpec("delete-user", ("admin",), delete_user, get_impose_metadata(delete_user)) + message = format_confirmation_message( + command, + { + "username": "sam", + "reason": "x" * 80, + }, + color=True, + ) + + assert "\033[1;37;41m!!! CONFIRM DESTRUCTIVE COMMAND !!!\033[0m" in message + assert "\033[1;31mCommand: admin delete-user\033[0m" in message + assert "+----------+--------------------------------------------------+" in message + assert '| username | "sam" |' in message + assert '| reason | "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx...' in message + assert "x" * 80 not in message + + +def test_argument_table_handles_empty_and_multiline_values() -> None: + assert format_arguments_table({}) == "\n".join( + [ + "+----------+-------+", + "| Argument | Value |", + "+----------+-------+", + "+----------+-------+", + ] + ) + assert truncate_value("line-one\nline-two", max_width=20) == "line-one\\nline-two" + + +def test_argument_value_falls_back_to_string_for_unjsonable_values() -> None: + assert argument_value({1: "one", "two": 2}).startswith("{") + + +def test_confirm_command_accepts_only_exact_yes_and_no(monkeypatch, capsys) -> None: + @impose(impose_require_confirmation=True) + def deploy(environment: str) -> str: + return environment + + command = CommandSpec("deploy", (), deploy, get_impose_metadata(deploy)) + answers = iter(["Y", "YES", "yes"]) + monkeypatch.setattr("builtins.input", lambda _: next(answers)) + + assert confirm_command( + command, + {"environment": "prod"}, + color=False, + ) + assert capsys.readouterr().out.count("Type yes or no.") == 2 + + +def test_confirmation_abort_skips_command_invocation(monkeypatch, capsys) -> None: + calls: list[str] = [] + + @impose(impose_require_confirmation=True) + def delete_user(username: str) -> str: + calls.append(username) + return username + + app = ImposeApplication(modules=[module_for(delete_user, "delete_user_commands")]) + monkeypatch.setattr("builtins.input", lambda _: "no") + + assert app.run(["delete-user", "sam"]) is None + assert calls == [] + assert "Aborted." in capsys.readouterr().out + + +def test_confirmation_runs_after_interactive_prompts(monkeypatch, capsys) -> None: + answers = iter(["customers", "/tmp/customers.json", "yes"]) + + @impose(impose_require_confirmation=True) + def download_data(dataset: str, destination: str) -> str: + return f"{dataset}:{destination}" + + module = ModuleType("confirmed_interactive_commands") + download_data.__module__ = module.__name__ + module.download_data = download_data + app = ImposeApplication(modules=[module]) + monkeypatch.setattr("builtins.input", lambda _: next(answers)) + + assert app.run(["-i", "download-data"]) == "customers:/tmp/customers.json" + output = capsys.readouterr().out + assert '| dataset | "customers" |' in output + assert '| destination | "/tmp/customers.json" |' in output + assert '"customers:/tmp/customers.json"' in output diff --git a/src/tests/test_cli_runtime.py b/src/tests/test_cli_runtime.py new file mode 100644 index 0000000..fe83b70 --- /dev/null +++ b/src/tests/test_cli_runtime.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import sys + +import pytest +from helpers import module_for + +from impose import ImposeApplication, impose + + +def test_cli_errors_for_missing_required_and_unsupported_parameter(capsys) -> None: + @impose + def required(name: str) -> str: + return name + + app = ImposeApplication(modules=[module_for(required, "required_commands")]) + with pytest.raises(SystemExit) as exc: + app.run(["required"]) + assert exc.value.code == 2 + assert "the following arguments are required: name" in capsys.readouterr().err + + @impose + def unsupported(*values: str) -> None: + pass + + bad_app = ImposeApplication(modules=[module_for(unsupported, "unsupported_commands")]) + with pytest.raises(TypeError, match="unsupported parameter"): + bad_app.cli() + + +def test_cli_permission_errors_and_empty_output(capsys) -> None: + @impose + def quiet() -> None: + return None + + quiet_app = ImposeApplication(modules=[module_for(quiet, "quiet_commands")]) + assert quiet_app.run(["quiet"]) is None + assert capsys.readouterr().out == "" + + @impose + def restricted() -> str: + raise PermissionError("denied") + + restricted_app = ImposeApplication(modules=[module_for(restricted, "restricted_commands")]) + with pytest.raises(SystemExit) as exc: + restricted_app.run(["restricted"]) + assert exc.value.code == 2 + assert "denied" in capsys.readouterr().err + + +def test_application_prints_help_when_no_handler(capsys) -> None: + app = ImposeApplication() + assert app.run([]) is None + assert "Usage:" in capsys.readouterr().out + + +def test_application_uses_sys_argv_when_argv_is_none(monkeypatch) -> None: + @impose + def hello(name: str) -> str: + return name + + app = ImposeApplication(modules=[module_for(hello, "argv_commands")]) + monkeypatch.setattr(sys, "argv", ["prog", "hello", "sam"]) + + assert app.run() == "sam" diff --git a/src/tests/test_commands.py b/src/tests/test_commands.py new file mode 100644 index 0000000..738fc74 --- /dev/null +++ b/src/tests/test_commands.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from impose import impose +from impose.commands import CommandSpec, command_count, command_summary, command_tree +from impose.decorator import get_impose_metadata + + +def test_command_summary_uses_docstring_summary() -> None: + @impose + def command() -> None: + """Original summary.""" + + def undocumented() -> None: + pass + + assert command_summary(command) == "Original summary." + assert command_summary(undocumented) is None + + +def test_command_tree_and_count() -> None: + @impose + def first() -> None: + pass + + @impose + def second() -> None: + pass + + commands = [ + CommandSpec("first", ("admin",), first, get_impose_metadata(first)), + CommandSpec("second", ("admin", "users"), second, get_impose_metadata(second)), + ] + tree = command_tree(commands) + + assert command_count(tree) == 2 + assert tree["admin"]["first"].name == "first" + assert tree["admin"]["users"]["second"].name == "second" diff --git a/src/tests/test_decorator.py b/src/tests/test_decorator.py new file mode 100644 index 0000000..f7d700b --- /dev/null +++ b/src/tests/test_decorator.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import pytest + +from impose import impose +from impose.decorator import get_impose_metadata + + +def test_impose_decorator_is_passthrough() -> None: + @impose(impose_cs="math") + def add(left: int, right: int) -> int: + return left + right + + assert add(1, 2) == 3 + + +def test_impose_without_parentheses_preserves_function_keyword_arguments() -> None: + @impose + def configure(*, impose_cs: str, enabled: bool = True) -> tuple[str, bool]: + return impose_cs, enabled + + assert configure(impose_cs="local-value", enabled=False) == ("local-value", False) + + +def test_impose_with_parentheses_preserves_function_keyword_arguments() -> None: + async def middleware(request, call_next): + return await call_next(request) + + @impose( + impose_cs="admin", + impose_require_confirmation=True, + impose_middleware=[middleware], + ) + def delete_user(*, impose_middleware: str, reason: str = "manual") -> tuple[str, str]: + return impose_middleware, reason + + assert delete_user(impose_middleware="function-value", reason="duplicate") == ( + "function-value", + "duplicate", + ) + + +def test_impose_rejects_conflicting_command_set_names() -> None: + with pytest.raises(ValueError, match="Use either impose_cs"): + impose(impose_cs="one", impose_command_set="two") + + +def test_get_impose_metadata_ignores_unmarked_functions() -> None: + def plain() -> None: + pass + + assert get_impose_metadata(plain) is None diff --git a/src/tests/test_discovery.py b/src/tests/test_discovery.py new file mode 100644 index 0000000..1c052be --- /dev/null +++ b/src/tests/test_discovery.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +from types import ModuleType + +from impose import ImposeApplication, impose +from impose.discovery import discover_commands + + +def test_discovery_explicit_module_package_and_external_module_paths(tmp_path, monkeypatch) -> None: + package = tmp_path / "pkg" + package.mkdir() + (package / "__init__.py").write_text( + "from impose import impose\n\n@impose\ndef root() -> str:\n return 'root'\n", + encoding="utf-8", + ) + monkeypatch.syspath_prepend(str(tmp_path)) + import pkg + + commands = discover_commands( + root_module=pkg, + modules=[pkg], + use_project_structure=False, + modules_as_subcommands=True, + ) + + assert [(command.path, command.name) for command in commands] == [((), "root")] + + +def test_discovery_skips_duplicate_explicit_and_walked_modules(tmp_path, monkeypatch) -> None: + package = tmp_path / "dup_pkg" + package.mkdir() + (package / "__init__.py").write_text("", encoding="utf-8") + (package / "commands.py").write_text( + "from impose import impose\n\n@impose\ndef run() -> str:\n return 'run'\n", + encoding="utf-8", + ) + monkeypatch.syspath_prepend(str(tmp_path)) + import dup_pkg + import dup_pkg.commands + + commands = discover_commands( + root_module=dup_pkg, + modules=[dup_pkg.commands, dup_pkg.commands], + use_project_structure=True, + modules_as_subcommands=True, + ) + + assert [(command.path, command.name) for command in commands] == [(("commands",), "run")] + + +def test_discovery_ignores_imported_and_unmarked_functions_and_external_paths() -> None: + def imported() -> str: + return "imported" + + @impose + def marked() -> str: + return "marked" + + def unmarked() -> str: + return "unmarked" + + module = ModuleType("scan_commands") + marked.__module__ = module.__name__ + unmarked.__module__ = module.__name__ + imported.__module__ = "elsewhere" + module.marked = marked + module.unmarked = unmarked + module.imported = imported + + commands = discover_commands( + root_module=ModuleType("other_root"), + modules=[module], + use_project_structure=True, + modules_as_subcommands=True, + ) + + assert [(command.path, command.name) for command in commands] == [((), "marked")] + + +def test_project_structure_can_include_modules_as_subcommands(tmp_path, monkeypatch) -> None: + package = tmp_path / "cloud_tools" + aws = package / "aws" + aws.mkdir(parents=True) + (package / "__init__.py").write_text("", encoding="utf-8") + (aws / "__init__.py").write_text("", encoding="utf-8") + (aws / "rds.py").write_text( + "from impose import impose\n\n" + "@impose\n" + "def reboot_instance(identifier: str) -> str:\n" + " return identifier\n", + encoding="utf-8", + ) + monkeypatch.syspath_prepend(str(tmp_path)) + import cloud_tools + + application = ImposeApplication( + root_module=cloud_tools, + use_project_structure=True, + ) + + commands = application.commands() + + assert [(command.path, command.name) for command in commands] == [ + (("aws", "rds"), "reboot-instance") + ] + + +def test_project_structure_can_ignore_modules_as_subcommands(tmp_path, monkeypatch) -> None: + package = tmp_path / "admin_tools" + aws = package / "aws" + aws.mkdir(parents=True) + (package / "__init__.py").write_text("", encoding="utf-8") + (aws / "__init__.py").write_text("", encoding="utf-8") + (aws / "rds.py").write_text( + "from impose import impose\n\n" + "@impose\n" + "def reboot_instance(identifier: str) -> str:\n" + " return identifier\n", + encoding="utf-8", + ) + monkeypatch.syspath_prepend(str(tmp_path)) + import admin_tools + + application = ImposeApplication( + root_module=admin_tools, + use_project_structure=True, + modules_as_subcommands=False, + ) + + commands = application.commands() + + assert [(command.path, command.name) for command in commands] == [(("aws",), "reboot-instance")] diff --git a/src/tests/test_formatting.py b/src/tests/test_formatting.py new file mode 100644 index 0000000..1e5b783 --- /dev/null +++ b/src/tests/test_formatting.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import argparse +import sys + +from impose.cli.formatting import ImposeHelpFormatter, style, supports_color + + +def test_formatting_color_modes(monkeypatch) -> None: + monkeypatch.setenv("IMPOSE_COLOR", "always") + assert supports_color() is True + monkeypatch.setenv("IMPOSE_COLOR", "never") + assert supports_color() is False + monkeypatch.delenv("IMPOSE_COLOR") + monkeypatch.setenv("NO_COLOR", "1") + assert supports_color() is False + monkeypatch.delenv("NO_COLOR") + monkeypatch.setattr(sys.stdout, "isatty", lambda: True) + assert supports_color() is True + + assert style("x", "31", enabled=False) == "x" + assert style("x", "31", enabled=True) == "\033[31mx\033[0m" + + parser = argparse.ArgumentParser( + prog="x", + formatter_class=lambda prog: ImposeHelpFormatter(prog, color=True), + ) + parser.add_argument("--name") + parser.add_argument("--enabled", action="store_true") + parser.add_argument("path") + parser.add_subparsers(title="commands", metavar="") + help_text = parser.format_help() + + assert "\033[33m--name\033[0m NAME" in help_text + assert "\033[33m--enabled\033[0m" in help_text + assert "\033[32m\033[0m" in help_text + assert "path" in help_text + + formatter = ImposeHelpFormatter("x", color=True) + formatter.start_section(None) diff --git a/src/tests/test_integration.py b/src/tests/test_integration.py new file mode 100644 index 0000000..6b2bdb2 --- /dev/null +++ b/src/tests/test_integration.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from contextlib import suppress +from types import ModuleType + +from pydantic import BaseModel, field_serializer + +from impose import ImposeApplication, impose + + +class Region(BaseModel): + partition: str + name: str + + @field_serializer("name") + def serialize_name(self, value: str) -> str: + return value.lower() + + +def test_command_set_generates_cli_group(capsys) -> None: + @impose(impose_cs="math") + def add(left: int, right: int) -> int: + return left + right + + module = ModuleType("math_commands") + add.__module__ = module.__name__ + module.add = add + application = ImposeApplication(modules=[module]) + result = application.run(["math", "add", "1", "2"]) + + assert result == 3 + assert capsys.readouterr().out == "3\n" + + +def test_pydantic_argument_can_be_supplied_as_json(capsys) -> None: + @impose(impose_cs="ec2") + def list_instances(region: Region) -> dict[str, str]: + return region.model_dump() + + module = ModuleType("ec2_commands") + list_instances.__module__ = module.__name__ + module.list_instances = list_instances + application = ImposeApplication(modules=[module]) + + result = application.run( + [ + "ec2", + "list-instances", + '{"partition":"aws","name":"US-EAST-1"}', + ] + ) + + assert result == {"partition": "aws", "name": "us-east-1"} + assert capsys.readouterr().out == '{"partition": "aws", "name": "us-east-1"}\n' + + +def test_docstring_argument_usage_is_used_in_help(capsys) -> None: + @impose + def seed_data(environment: str, rows: int = 100) -> str: + """Seed sample data. + + Args: + environment: Environment to seed. + rows: Number of rows to create. + + """ + return environment + + module = ModuleType("usage_commands") + seed_data.__module__ = module.__name__ + module.seed_data = seed_data + application = ImposeApplication(modules=[module]) + + with suppress(SystemExit): + application.run(["seed-data", "--help"]) + + output = capsys.readouterr().out + assert "Environment to seed. (str)" in output + assert "Number of rows to create. (int, default: 100)" in output + + +def test_interactive_mode_prompts_for_missing_required_positionals(monkeypatch) -> None: + answers = iter(["customers", "/tmp/customers.json"]) + + @impose + def download_data(dataset: str, destination: str) -> str: + return f"{dataset}:{destination}" + + monkeypatch.setattr("builtins.input", lambda _: next(answers)) + + module = ModuleType("interactive_commands") + download_data.__module__ = module.__name__ + module.download_data = download_data + application = ImposeApplication(modules=[module]) + + assert application.run(["-i", "download-data"]) == "customers:/tmp/customers.json" diff --git a/src/tests/test_main.py b/src/tests/test_main.py new file mode 100644 index 0000000..7352993 --- /dev/null +++ b/src/tests/test_main.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +import runpy +import sys +from pathlib import Path + +import pytest + +import impose.__main__ as impose_main + + +def test_main_parser_version_and_main(capsys) -> None: + parser = impose_main.build_parser() + with pytest.raises(SystemExit) as exc: + parser.parse_args(["--version"]) + assert exc.value.code == 0 + assert "impose 0.1.0" in capsys.readouterr().out + + with pytest.raises(SystemExit) as exc: + sys.argv = ["impose", "--version"] + impose_main.main() + assert exc.value.code == 0 + + with pytest.raises(SystemExit) as exc: + sys.argv = ["python -m impose", "--version"] + runpy.run_path(Path(impose_main.__file__), run_name="__main__") + assert exc.value.code == 0 diff --git a/src/tests/test_values.py b/src/tests/test_values.py new file mode 100644 index 0000000..d45e382 --- /dev/null +++ b/src/tests/test_values.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from typing import Literal + +import pytest +from helpers import Color, Payload + +from impose.values import ( + annotation_accepts_none, + annotation_name, + choices_for_annotation, + coerce_kwargs, + parse_value, + serialize_result, +) + + +def test_value_helpers_cover_supported_annotations() -> None: + assert annotation_accepts_none(str) is False + assert annotation_accepts_none(str | None) is True + assert annotation_accepts_none(__import__("inspect").Signature.empty) is False + + assert parse_value("1", int) == 1 + assert parse_value("1.5", float) == 1.5 + assert parse_value("yes", bool) is True + assert parse_value("red", Color) is Color.red + assert parse_value('{"name":"sam"}', Payload) == Payload(name="sam") + assert parse_value("x", Literal["x", "y"]) == "x" + assert parse_value("", str | None) is None + assert parse_value("2", int | None) == 2 + assert parse_value("raw", list[int] | dict[str, int]) == "raw" + assert parse_value("raw", object) == "raw" + + assert serialize_result(None) == "" + assert serialize_result(Payload(name="sam")) == '{"name":"sam"}' + assert serialize_result({"value": object()}).startswith('{"value":') + + assert choices_for_annotation(Color) == ["red", "blue"] + assert choices_for_annotation(Literal["x", "y"]) == ["x", "y"] + assert choices_for_annotation(str) is None + + assert annotation_name(bool) == "boolean" + assert annotation_name(str) == "str" + assert annotation_name(Color) == "choice" + assert annotation_name(Payload) == "json" + assert annotation_name(Literal["x"]) == "choice" + assert annotation_name(object) == "value" + + +def test_coerce_kwargs_handles_models_enums_defaults_and_errors() -> None: + def command(payload: Payload, color: Color, count: int = 1) -> tuple[Payload, Color, int]: + return payload, color, count + + assert coerce_kwargs( + command, + {"payload": {"name": "sam"}, "color": "red"}, + ) == {"payload": Payload(name="sam"), "color": Color.red, "count": 1} + + with pytest.raises(ValueError, match="Missing required argument"): + coerce_kwargs(command, {"payload": {"name": "sam"}}) diff --git a/unit/pytest.ini b/unit/pytest.ini new file mode 100644 index 0000000..cae529f --- /dev/null +++ b/unit/pytest.ini @@ -0,0 +1,9 @@ +[pytest] +addopts = + --cov=impose + --cov-report=term-missing + --cov-fail-under=100 +pythonpath = + ../src + ../src/tests +testpaths = . diff --git a/unit/test_unit.py b/unit/test_unit.py new file mode 100644 index 0000000..dc6a3d8 --- /dev/null +++ b/unit/test_unit.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import importlib +from typing import Any + +UNIT_MODULES = ( + "test_api", + "test_api_fuzz", + "test_cli_arguments", + "test_cli_confirmation", + "test_cli_runtime", + "test_commands", + "test_decorator", + "test_discovery", + "test_formatting", + "test_integration", + "test_main", + "test_values", +) + + +def _load_unit_tests() -> None: + for module_name in UNIT_MODULES: + module = importlib.import_module(module_name) + for name, value in vars(module).items(): + if name.startswith("test_"): + globals()[name] = value + + +_load_unit_tests() + +del _load_unit_tests +del Any diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..1bd2fbd --- /dev/null +++ b/uv.lock @@ -0,0 +1,423 @@ +version = 1 +revision = 2 +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.14.1" +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/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" }, +] + +[[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 = "coverage" +version = "7.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/8b/adeb62ea8951f13c4c7fef2e7a85e1a06b499c8d8237ea589d496029e53f/coverage-7.15.0.tar.gz", hash = "sha256:9ac3fe7a1435986463eaa8ee253ae2f2a268709ba4ae5c7dd1f52a05391ad78f", size = 925362, upload-time = "2026-07-02T13:10:50.535Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/74/fd4c0901137c4f8d81a76ada99e43c65163b4c94a02ece107a4ec0c6b615/coverage-7.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b75ee5e8cb7575636ac598719b4307ac529ec8fcd79608a35c3cd4d4dada812d", size = 220838, upload-time = "2026-07-02T13:09:02.084Z" }, + { url = "https://files.pythonhosted.org/packages/0f/2e/2347583467bd7f0402635101a916961915cc68fce652cd0db5f173ea04fc/coverage-7.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffb31267816b93b075302248cc1737506081b4f163df4401e9df1a6424aafabe", size = 221197, upload-time = "2026-07-02T13:09:03.617Z" }, + { url = "https://files.pythonhosted.org/packages/f0/17/99fa688541ae1d6e84543a0e544f83de0c944815b63e9e7b1ed411d15036/coverage-7.15.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e4d0bb73455bf97ab243a8f12c37c686ccf1c13bb614b7b85f1d062f06f42b2c", size = 252705, upload-time = "2026-07-02T13:09:05.059Z" }, + { url = "https://files.pythonhosted.org/packages/fb/02/6a95a5cd83b74839017ef9cf48d2d8c9ae60af919e17a3f336e6f9f1b7bd/coverage-7.15.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:20d9ccc4ebd0edc434d86dfd2a1dd2a8efa6b6b3073d0485a394fee86459ebb4", size = 255441, upload-time = "2026-07-02T13:09:06.559Z" }, + { url = "https://files.pythonhosted.org/packages/67/f2/406f6c57d600f68185942422c4c00f1a3255d60aee6e5fd961425cd9987e/coverage-7.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20c8a976c365c8cb12f0cbd099508772ea41fb5fa80657a8506df0e11bd278c5", size = 256556, upload-time = "2026-07-02T13:09:08.197Z" }, + { url = "https://files.pythonhosted.org/packages/74/8e/d3fa48489c15ecdec1ba48fd61f68798555dddd2f6716f9ad42adeb1a2a9/coverage-7.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f948fd5ba1b9cbca91f0ae08b4c1ce2b139509149a435e2585d056d57d70bf01", size = 258815, upload-time = "2026-07-02T13:09:09.691Z" }, + { url = "https://files.pythonhosted.org/packages/47/2e/2d40ddd110462c6a2769677cf7f1c119a52b45f568978fc6c98e4cc0dd0f/coverage-7.15.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f58185f06edf6ad68ec9fb155d63ef650c82f3fbd7e1770e2867751fb13158f4", size = 253117, upload-time = "2026-07-02T13:09:11.212Z" }, + { url = "https://files.pythonhosted.org/packages/51/c0/310782f0d7c3cb2b5ac05ba8d205fe91f24a36f6bf3256098f1782181c38/coverage-7.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:02adc79a920c73c647c5d117f55747df7f2de94571884758ce8bc58e04f0a796", size = 254475, upload-time = "2026-07-02T13:09:13.029Z" }, + { url = "https://files.pythonhosted.org/packages/86/f7/702da6c275f8ae6ade423d2877243122932c9b27f5403003b9ef8c927d12/coverage-7.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6eb7c300fbed667fd6e3588eba71c1904cdb06110ca6fdf908c26bdd88b8e382", size = 252619, upload-time = "2026-07-02T13:09:14.699Z" }, + { url = "https://files.pythonhosted.org/packages/fb/84/c5b15a7e5ecba4e56218d772d99fe80a63e63f8d11f12783723a6005ab45/coverage-7.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b5fb23fa2de9dce1f5c36c09066d8fcda16cd96e8e26686caa2d7cb9b567d65c", size = 256689, upload-time = "2026-07-02T13:09:16.103Z" }, + { url = "https://files.pythonhosted.org/packages/95/2f/c8b07559b57701230c61b23a953858c052890c12ef568d81780c6c46e92e/coverage-7.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cec79341dbe6281484024979976d0c7f22beae08b4a254655decd25d42cbe766", size = 252189, upload-time = "2026-07-02T13:09:17.828Z" }, + { url = "https://files.pythonhosted.org/packages/6b/80/6d2f049dd3fd3dbfd60b62ba6b2162a04009e2c002ce70b24cf3878dec7a/coverage-7.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c664c5444b1d970b1b2a450e21fb19ee5c9cfdf151ded2dda37260031cca0da", size = 254059, upload-time = "2026-07-02T13:09:19.304Z" }, + { url = "https://files.pythonhosted.org/packages/ce/92/b0287a2c42031d25c628f815f89a3cd9f8268ee78bb1252c9356cda1c689/coverage-7.15.0-cp312-cp312-win32.whl", hash = "sha256:5f764a3fa339bde6b3aa97657f5a6a3a9451e4a5b4ea98a2892c773a43525f77", size = 222893, upload-time = "2026-07-02T13:09:20.812Z" }, + { url = "https://files.pythonhosted.org/packages/a9/69/e34c481915fecb499b3146975061dac528752e37706edc1804f32c822469/coverage-7.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:52f9a4d2c4c56c8848bc2f524916698354b0211488b38c49ad9ae54f6cafbff6", size = 223429, upload-time = "2026-07-02T13:09:22.315Z" }, + { url = "https://files.pythonhosted.org/packages/fe/98/6e878f0b571d32684ef3f38d7c03db241ca5b82a5da8a5391596a8f209c4/coverage-7.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:31e5c3e70c85307ea35a12964e2e40f56ca2ee4b1c8c721ccf4609d17071080b", size = 222810, upload-time = "2026-07-02T13:09:23.812Z" }, + { url = "https://files.pythonhosted.org/packages/76/04/145a3748098bcc86b631a85408d2c3dc5c104e0bd86d605468239b25b6c4/coverage-7.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5be4caf3b28836f078abe700f8944dac4a65d78f16d6c600c89cb624e5535782", size = 220863, upload-time = "2026-07-02T13:09:25.371Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5c/4ed55708fed2c64b63c9bc5715daef670872202101938869b7fe5d5fbb8f/coverage-7.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dd58ad1404704303ca8d4f4b8a1095e7cbc7040ef17a66df1e6619aa10176430", size = 221230, upload-time = "2026-07-02T13:09:26.897Z" }, + { url = "https://files.pythonhosted.org/packages/7b/19/3a80b97d3b2a5c77a01ae359c6bed20c13738fe3d9380f08616d4fec0281/coverage-7.15.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bbcbb317c2e5ded5b21104af81c29f391be2af98d065693ffbe8d23949b948e5", size = 252227, upload-time = "2026-07-02T13:09:28.543Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/b70062750686bd7da454da27927622f48bbac6990ac7a4c4a4653e7b0036/coverage-7.15.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:27f31ecb458da3f859aab3f15ada871eb7a7768807d88df4a9f186bb17737970", size = 254823, upload-time = "2026-07-02T13:09:30.177Z" }, + { url = "https://files.pythonhosted.org/packages/a9/09/dad6a75a2e561b9dc5086a8c5257a7591d584246f67e23e70d2995b89ab6/coverage-7.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fb759be317fdc62e0f56bffdf61cfcb45c7761ad6b71e3e583e71a67ae753c", size = 256059, upload-time = "2026-07-02T13:09:31.979Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e7/b5d2941fa9564573d44b693a871ff3156f0c42cbefe977a09fa7fdc59971/coverage-7.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5cf007add5ab4bb8fa9f4c77e3732127c9e6cad501d7db43355fbfafca0be84", size = 258190, upload-time = "2026-07-02T13:09:34.035Z" }, + { url = "https://files.pythonhosted.org/packages/7c/1d/8e895bcde3c57ccd46d896dda5f2b3d5df761a1b0c6c9d450d175dedc632/coverage-7.15.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc78d9843bd576fbe2118248258d485e968dc535f95ed504a7b0867ba9b51389", size = 252456, upload-time = "2026-07-02T13:09:35.765Z" }, + { url = "https://files.pythonhosted.org/packages/14/4c/f6997da343ddeb959be82c3b05322793f92c071ad45f7cb8a96336e2dd5f/coverage-7.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a263060f1de0b4b74b4e089c2a70b8003b3781c733329a9c8fd54995328f9950", size = 254192, upload-time = "2026-07-02T13:09:37.445Z" }, + { url = "https://files.pythonhosted.org/packages/17/27/a0bc09d032267b9da89d95a2d874cfbef2a5aebbf0e87cf7aba221d79a99/coverage-7.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c48decf16e0dfd5b049c7d5e82200c23c08126719142998d4f172444e3d0529e", size = 252153, upload-time = "2026-07-02T13:09:39.422Z" }, + { url = "https://files.pythonhosted.org/packages/54/c0/77fc233d9fba07b244c40948c53fe27308b8f21732fb3417f87fbd6fd992/coverage-7.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:08fb028000ed0aaa0a4cbdfbb98be7cb42f370db973fbbb469733505ab20e13e", size = 256310, upload-time = "2026-07-02T13:09:41.006Z" }, + { url = "https://files.pythonhosted.org/packages/d5/24/601cecfb5825becacb8d45219a018a3b55b9dbaec624efdb0ea249d08be2/coverage-7.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb7dc0c3b7d8a1077abea0b8546ebc5e26d6ef6ecefc2f0f5ad2b8a53bdad837", size = 251974, upload-time = "2026-07-02T13:09:42.733Z" }, + { url = "https://files.pythonhosted.org/packages/47/1e/6f45e5a5b3d5484318d368702af6716b5ab8913b0428bec981a562fcf296/coverage-7.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cb3602054ccbe9f0d8c2dc04bbeba90d5719236e2cd06e042ddd6d3fc7b6e37", size = 253745, upload-time = "2026-07-02T13:09:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/8e/db/4df027a77bd11d0e527f44c53557c76e54ad027413d0304252ea3a78d67e/coverage-7.15.0-cp313-cp313-win32.whl", hash = "sha256:0bf781da64326b677be344df505171435b6f58716108606621d5d27d964fff8b", size = 222902, upload-time = "2026-07-02T13:09:46.122Z" }, + { url = "https://files.pythonhosted.org/packages/a0/10/0355894d34e231f2c5449e71287e81a50793a325df2e2b027b7bcd9dfd19/coverage-7.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:2c57a275078ee3fa185f83e400f765bc764a549de66d99b47881645cbd4ea629", size = 223444, upload-time = "2026-07-02T13:09:47.687Z" }, + { url = "https://files.pythonhosted.org/packages/06/ef/bb725f263befaaff851203ab338e68af15e195d7f7b5f323162532d9b6a8/coverage-7.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:3812c61afc6685c7999b39320779ab8f43b7a3081fdb0def39976e56fbdb9a21", size = 222839, upload-time = "2026-07-02T13:09:49.717Z" }, + { url = "https://files.pythonhosted.org/packages/4f/9c/1e3ca54f72a3185ece06c58d871099898c48f0ed6430d17b6ab75f0d180a/coverage-7.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:41cb79af843222e11da87127ad0ecbfa878abadd0f770a4a99391a27d3887324", size = 220906, upload-time = "2026-07-02T13:09:51.339Z" }, + { url = "https://files.pythonhosted.org/packages/09/37/f718613d83b274880382f6b67e78f3802549ae39b0b3e65ae5b5974df56e/coverage-7.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7d2008989ef8fe54188d3f3bfa2e3099b025af11e90a6a1b9e7dc433d04263d8", size = 221239, upload-time = "2026-07-02T13:09:53.138Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ce/22bae91e0b75445f68d365c7643ed0aa4880bbf77450ee74ca65bdae53a7/coverage-7.15.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:769e8ece11a596315ebf5aa7ec383aeeed016c091d2bf6363ffb996d41529092", size = 252286, upload-time = "2026-07-02T13:09:54.996Z" }, + { url = "https://files.pythonhosted.org/packages/dd/1e/bec5e32aa508615d9d7a2790effb25fb4dc28606e995816afe400b25ece3/coverage-7.15.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:65a6b6164ee5c39e2f3803f314292d6c61a607ba7fee253d1e03c42dc3903502", size = 254789, upload-time = "2026-07-02T13:09:56.678Z" }, + { url = "https://files.pythonhosted.org/packages/17/29/0e865435b4354e4a7c03b1b7920046d31d0a273d55decefea27e011cb9bf/coverage-7.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75128817f95a5c45bb01d65fd2d8b9cb54bbe03d81608fb70e3e14b437ad56c2", size = 256135, upload-time = "2026-07-02T13:09:58.343Z" }, + { url = "https://files.pythonhosted.org/packages/84/ff/33a870b58a13325d62fc0a6c8f01fa0ff667cef60c7498e2382a147dfa18/coverage-7.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9887bb428fe2d4cd4bee89bac1a6c9932f484afd5b36fbd4ff6ea5f825bb1f5e", size = 258449, upload-time = "2026-07-02T13:10:00.057Z" }, + { url = "https://files.pythonhosted.org/packages/18/7b/6fffe596bf3ddba8462758d02c5dad730fd91055a6634aa2e4226229181a/coverage-7.15.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0bfc0be1f702042207a93a00523b1065ee1fe951e96edf311581c0bbc2e34888", size = 252313, upload-time = "2026-07-02T13:10:01.946Z" }, + { url = "https://files.pythonhosted.org/packages/58/1b/11468dd6c1676ab831a70cb9a8d4e198e8607fa0b7220ab918b73fe9bfbd/coverage-7.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f64627d55def5a43282d70e08396672692f77e4da610a5bb8bb4060b432b6859", size = 254142, upload-time = "2026-07-02T13:10:04.065Z" }, + { url = "https://files.pythonhosted.org/packages/79/41/29328e21d16b1b95092c30dd700e08cf915bd3734f836df8f3bdb0e8fa9f/coverage-7.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:2c6f0fa473003905c6d5bac328ee4eba9fbea654f15bc24b8a3274b23363fa99", size = 252108, upload-time = "2026-07-02T13:10:06.11Z" }, + { url = "https://files.pythonhosted.org/packages/9b/de/05ccfb990439655b35afbfd8e0d13fe66677565a7d4eb38c3f5ef2635e1c/coverage-7.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2bcf9afaf064172c6ec3c58a325a9957ad1178c05dd934e25f253321776e0676", size = 256385, upload-time = "2026-07-02T13:10:08.141Z" }, + { url = "https://files.pythonhosted.org/packages/51/0e/486828a3d2695ea7a2609f17ff572f6b01905e608379440a11da4b8dffbe/coverage-7.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:baf06bc987115d6fb938d403f7eab684a057766c490367999a2b71a6883110c6", size = 251923, upload-time = "2026-07-02T13:10:10.179Z" }, + { url = "https://files.pythonhosted.org/packages/18/c7/03582b6715f078e5e558354c87616d945b9894cda2dace8e4009b17035e4/coverage-7.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f0405f2ff97b1c4c0e782cb32e02f32369bcf2e6b618b591d67e1ea754575dfe", size = 253580, upload-time = "2026-07-02T13:10:12.052Z" }, + { url = "https://files.pythonhosted.org/packages/db/dc/9e578bbaf2ecb4959a81b7e7601ad8cca772cba2892e8d144cb749b4a71a/coverage-7.15.0-cp314-cp314-win32.whl", hash = "sha256:ab282853ed5fbd64bbb162f19cb8fcb7087187508a6374b4f9c34ec1577c4e8f", size = 223107, upload-time = "2026-07-02T13:10:13.994Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3e/c8c3b75d8dbe0e35f7b0cc3ff5e949fc59500f70b21d0398813f66740664/coverage-7.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bb3040e9f4bbe26fcb0cd7cc85ac63e630d3f3a9c74f027abf4caa27e706663", size = 223597, upload-time = "2026-07-02T13:10:15.906Z" }, + { url = "https://files.pythonhosted.org/packages/cd/bc/3cbc9fb036eb388519bccd521f783499c39b64256013fbc362782f196fe1/coverage-7.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:346771144d34f7fa84ec28386f78e0f31653f33cf35e19d253d5b35f9e8201da", size = 223020, upload-time = "2026-07-02T13:10:17.844Z" }, + { url = "https://files.pythonhosted.org/packages/28/00/199c4a8d656dff63102577a056c0fce2ff6a79e40adac092fc986c49cbf1/coverage-7.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d34a010905fb6401324ba016b5da03d574967f7b21ce48ea41e66f0f1f95f641", size = 221638, upload-time = "2026-07-02T13:10:19.703Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8e/9d0092c96a3d3a26951ecc7020826aa57bcb1b119ca81acbba996884ab13/coverage-7.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bb25d825d885ca8036795dacfc3924d33091fc76d71ebc99420c6b79e77d96fa", size = 221903, upload-time = "2026-07-02T13:10:21.514Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b4/c0ca3028f42c9a08e51feb4561ef1192e5de99797cd1db5b04590c215bda/coverage-7.15.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:94c9686bfe8a9a6810297aecbd99beaa3445f9e8dc2f80b1382cca0d86b64461", size = 263267, upload-time = "2026-07-02T13:10:23.261Z" }, + { url = "https://files.pythonhosted.org/packages/5f/aa/a375e3846e5d3c013dc600b2a3231089055c73d77f5393dd2192a8d64da6/coverage-7.15.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9bd671c25f9d85f09d7ec481d0e43d5139f486c06a37139847a7ce569788af72", size = 265390, upload-time = "2026-07-02T13:10:25.152Z" }, + { url = "https://files.pythonhosted.org/packages/92/e1/5783cdabb797305e1c9e4809fea496d31834c51fa772514f73dc148bcfc9/coverage-7.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:110cbdf8d2e216577312cf06ccf85539c0e5a5420ef747e4a4719b5e483c88cd", size = 267811, upload-time = "2026-07-02T13:10:27.249Z" }, + { url = "https://files.pythonhosted.org/packages/85/31/96d8bbf58b8e9193bc8389574a91a0db48355ee98feb66aa6bf8d1b32eea/coverage-7.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2c5d4619214f1d9993e7b00a8600d14614b7e9d84e89507460b126aa5e6559e5", size = 268928, upload-time = "2026-07-02T13:10:29.242Z" }, + { url = "https://files.pythonhosted.org/packages/5e/7a/5294567e811a1cb7eda93140c628fa050d66189da28da320f93d1d815c73/coverage-7.15.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:781a704516e2d8346fbbd5be6c6f3412dd824785146528b3a01816f26c081007", size = 262378, upload-time = "2026-07-02T13:10:31.107Z" }, + { url = "https://files.pythonhosted.org/packages/69/3f/3f48538421f899f28946f90a3d272136a4686e1abf461cc9249a783ee0f3/coverage-7.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd4a1b44bcb65ee29e947ac92bbee04956df3a6bfc6143641bb6cae7ede00fc9", size = 265263, upload-time = "2026-07-02T13:10:32.942Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d3/092df15efcab8a9c1467ee960eb8019bbad3f9300d115d89ea6195f369ff/coverage-7.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0e4950c9d6d3e39c64c991814ff315e2d0b9cb8152363594212c9e55208c0a8f", size = 262866, upload-time = "2026-07-02T13:10:35.104Z" }, + { url = "https://files.pythonhosted.org/packages/e5/ab/0254d2b88665efb2c57ad368cc77ab5de3435bd8d5add4729c1b0e79431e/coverage-7.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:fe9c87ff42e5472d80d21704972e1f96e104a0a599d77c5e35db5a3c562e2571", size = 266599, upload-time = "2026-07-02T13:10:37.05Z" }, + { url = "https://files.pythonhosted.org/packages/a8/79/1cfa4023e489ce6fbc7be4a5d442dbc375edb4f4fda39a352cedb53263c2/coverage-7.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f00d5ae1dd2fe13fb8186e3e7d37bcbd8b25c0d764ff7d1b32cef9be058510a8", size = 261714, upload-time = "2026-07-02T13:10:38.966Z" }, + { url = "https://files.pythonhosted.org/packages/b7/eb/fee5c8665656be63f497418d410484637c438172568688e8ac92e06574e7/coverage-7.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:363ab38cc78b615f11c9cac3cf1d7eef950c18b9fdedfb9066f59461dcf84d68", size = 264025, upload-time = "2026-07-02T13:10:40.789Z" }, + { url = "https://files.pythonhosted.org/packages/ab/99/63005db722f91edc81abc16302f9cc2f6228c1679e46e15be9ae144b14d0/coverage-7.15.0-cp314-cp314t-win32.whl", hash = "sha256:54fd9c53a5fafff509195f1b6a3f9be615d8e8362a3629ff1de23d270c03c86b", size = 223413, upload-time = "2026-07-02T13:10:42.597Z" }, + { url = "https://files.pythonhosted.org/packages/c1/e8/2bc6181c4fb06f1a6b981eb85330cc57bfad7e3f710fc9c9d350013ba228/coverage-7.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:87b47553097ba185ed964866078e7e63adea9f5f51b5f39691c34f30afd21080", size = 224245, upload-time = "2026-07-02T13:10:44.47Z" }, + { url = "https://files.pythonhosted.org/packages/79/b8/4d959bf9cc45d0cfed2f4d35cafcab978cdb6ea02eb5100009cd740632a3/coverage-7.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aeefb2dd178fe7eee79f0ad25d75855cb35ee9ed472db2c5ea06f5b4fd00cec5", size = 223558, upload-time = "2026-07-02T13:10:46.368Z" }, + { url = "https://files.pythonhosted.org/packages/52/30/21b2ad45959cd50e909e02ebac1e30b4ceb7162e91c11d4c570223a458b7/coverage-7.15.0-py3-none-any.whl", hash = "sha256:56da6a4cbe8f7e9e80bd072ca9cefe67d7106a440a7ec06519ec6507ac94ad19", size = 212632, upload-time = "2026-07-02T13:10:48.641Z" }, +] + +[[package]] +name = "docstring-parser" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, +] + +[[package]] +name = "fastapi" +version = "0.139.0" +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/d3/af/a5f50ccfa659ec1802cb4ca842c23f06d906a8cc9aef6016a2caeea3d4ed/fastapi-0.139.0.tar.gz", hash = "sha256:99ab7b2d92223c76d6cf10757ab3f89d45b38267fc20b2a136cf02f6beac3145", size = 423016, upload-time = "2026-07-01T16:35:33.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/7c/8e3c6ad324ea5cb36604fc3f968554887891c316d9dfde57761611d907ad/fastapi-0.139.0-py3-none-any.whl", hash = "sha256:cf15e1e9e667ddb0ad63811e60bd11390d1aac838ca4a7a23f421807b2308189", size = 130339, upload-time = "2026-07-01T16:35:32.19Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "impose-cli" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "docstring-parser" }, + { name = "pydantic" }, +] + +[package.optional-dependencies] +api = [ + { name = "fastapi" }, +] + +[package.dev-dependencies] +dev = [ + { name = "fastapi" }, + { name = "pyright" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "docstring-parser", specifier = ">=0.18" }, + { name = "fastapi", marker = "extra == 'api'", specifier = ">=0.100" }, + { name = "pydantic", specifier = ">=2.0" }, +] +provides-extras = ["api"] + +[package.metadata.requires-dev] +dev = [ + { name = "fastapi", specifier = ">=0.100" }, + { name = "pyright", specifier = ">=1.1" }, + { name = "pytest", specifier = ">=8.0" }, + { name = "pytest-cov", specifier = ">=7.0" }, + { name = "ruff", specifier = ">=0.14" }, +] + +[[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 = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[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 = "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 = "pyright" +version = "1.1.411" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/ab/265f7dc69d28113ebba19092e57b075f41543b2ed048429c5f56e2b88eac/pyright-1.1.411.tar.gz", hash = "sha256:d885a0551f2e763b089a02702174e7f4ba77548cddabc972ab86d1f7f1b0f998", size = 4112861, upload-time = "2026-06-25T02:14:06.37Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/49/385be530a6a5b78d1cbcd5c2e38debc8959a2fc6bdb716f4e581002979fc/pyright-1.1.411-py3-none-any.whl", hash = "sha256:dc7c72a8e2700c55baa127554040e067041ea53ccfd50bf96308cc4291c7d5d9", size = 6181526, upload-time = "2026-06-25T02:14:04.691Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +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/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" }, + { url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" }, + { url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" }, + { url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" }, + { url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" }, + { url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" }, + { url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" }, + { url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" }, + { url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" }, +] + +[[package]] +name = "starlette" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[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" }, +]