From c40f125c9763e3aeac6f42a79c001b7de21f4797 Mon Sep 17 00:00:00 2001 From: Igor Racic Date: Thu, 12 Feb 2026 13:36:06 +0100 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20LF=20readiness=20=E2=80=94=20DNSSEC?= =?UTF-8?q?=20enforcement,=20DANE=20cert=20matching,=20Sigstore=20signing,?= =?UTF-8?q?=20security=20hardening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire require_dnssec into discover(): raises DNSSECError when AD flag absent. Add DANE full certificate matching with SPKI support. Add Sigstore cosign keyless signing to release workflow (wheel, tarball, SBOM). - DNSSEC enforcement with DNSSECError exception on validation failure - DANE/TLSA full certificate and SPKI matching (verify_dane_cert flag) - Sigstore cosign keyless signing in release workflow - SSRF protection for cap_fetcher and a2a_card (HTTPS-only, private IP blocking) - BANDAID custom SVCB params (cap, cap-sha256, bap, policy, realm, sig) - JWS application-layer signatures as DNSSEC alternative - Backend list_records and get_record support for all providers - MCP server transport options and new tools - Experimental metadata models (agent_metadata, capability_model) documented - MAINTAINERS.md with open roles and recruitment plan - Bandit config migrated to pyproject.toml for newer versions - 577 tests passing, ruff clean, mypy clean, bandit clean Signed-off-by: Igor Racic --- .bandit | 2 +- .github/workflows/ci.yml | 24 + .github/workflows/release.yml | 62 + .github/workflows/security.yml | 31 +- Dockerfile | 23 +- GOVERNANCE.md | 4 + MAINTAINERS.md | 34 + SECURITY.md | 88 +- docs/getting-started.md | 39 + pyproject.toml | 10 +- src/dns_aid/__init__.py | 6 +- src/dns_aid/backends/base.py | 26 + src/dns_aid/backends/cloudflare.py | 56 + src/dns_aid/backends/ddns.py | 35 + src/dns_aid/backends/infoblox/bloxone.py | 57 + src/dns_aid/backends/mock.py | 37 + src/dns_aid/backends/route53.py | 52 + src/dns_aid/cli/main.py | 180 +-- src/dns_aid/core/__init__.py | 9 + src/dns_aid/core/a2a_card.py | 13 +- src/dns_aid/core/agent_metadata.py | 146 ++ src/dns_aid/core/cap_fetcher.py | 34 +- src/dns_aid/core/capability_model.py | 91 ++ src/dns_aid/core/discoverer.py | 61 +- src/dns_aid/core/http_index.py | 1 + src/dns_aid/core/models.py | 74 +- src/dns_aid/core/publisher.py | 20 +- src/dns_aid/core/validator.py | 116 +- src/dns_aid/mcp/server.py | 191 ++- src/dns_aid/sdk/_config.py | 12 - src/dns_aid/sdk/client.py | 18 - src/dns_aid/sdk/repository.py | 160 -- src/dns_aid/sdk/signals/store.py | 216 --- src/dns_aid/utils/url_safety.py | 85 + src/dns_aid/utils/validation.py | 9 +- tests/integration/conftest.py | 322 ++++ tests/integration/test_ddns.py | 9 +- tests/integration/test_e2e.py | 9 +- tests/integration/test_infoblox.py | 13 +- tests/integration/test_mock_flows.py | 617 ++++++++ tests/integration/test_route53.py | 9 +- tests/unit/core/__init__.py | 0 tests/unit/core/test_agent_metadata.py | 209 +++ tests/unit/core/test_capability_model.py | 110 ++ tests/unit/test_a2a_card.py | 51 +- tests/unit/test_cap_fetcher.py | 50 +- tests/unit/test_cli.py | 28 + tests/unit/test_discoverer.py | 5 +- tests/unit/test_jws.py | 5 +- tests/unit/test_mcp_server.py | 70 + tests/unit/test_mock_backend.py | 44 + tests/unit/test_models.py | 83 +- tests/unit/test_publisher.py | 19 +- tests/unit/test_url_safety.py | 192 +++ tests/unit/test_validation.py | 11 +- uv.lock | 1791 ++++++++++++++++++++++ 56 files changed, 4936 insertions(+), 733 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 MAINTAINERS.md create mode 100644 src/dns_aid/core/agent_metadata.py create mode 100644 src/dns_aid/core/capability_model.py delete mode 100644 src/dns_aid/sdk/repository.py delete mode 100644 src/dns_aid/sdk/signals/store.py create mode 100644 src/dns_aid/utils/url_safety.py create mode 100644 tests/integration/conftest.py create mode 100644 tests/integration/test_mock_flows.py create mode 100644 tests/unit/core/__init__.py create mode 100644 tests/unit/core/test_agent_metadata.py create mode 100644 tests/unit/core/test_capability_model.py create mode 100644 tests/unit/test_url_safety.py create mode 100644 uv.lock diff --git a/.bandit b/.bandit index 22e7ad7..43a831f 100644 --- a/.bandit +++ b/.bandit @@ -1,3 +1,3 @@ [bandit] exclude_dirs = tests,.venv,venv,build,dist -skips = B101 +skips = B101,B110 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7892596..7cb377a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,10 @@ on: branches: [main] pull_request: branches: [main] + schedule: + # Nightly at 03:00 UTC + - cron: "0 3 * * *" + workflow_dispatch: permissions: contents: read @@ -44,6 +48,26 @@ jobs: name: coverage-report path: coverage.xml + integration: + name: Mock Integration Tests + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev,cli,mcp,route53]" + + - name: Run mock integration tests + run: pytest tests/integration/ -m "not live" -v --tb=short + lint: name: Lint runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..80e191e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,62 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + id-token: write # Required for Sigstore keyless OIDC signing + +jobs: + build: + name: Build & Release + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install build tools + run: | + python -m pip install --upgrade pip + pip install build cyclonedx-bom + + - name: Build wheel and sdist + run: python -m build + + - name: Install package for SBOM + run: pip install dist/*.whl + + - name: Generate SBOM + run: cyclonedx-py environment -o sbom.json --output-format json + + - name: Install Cosign + uses: sigstore/cosign-installer@v3 + + - name: Sign release artifacts + run: | + for f in dist/*.whl dist/*.tar.gz; do + cosign sign-blob --yes "$f" --output-signature "${f}.sig" --output-certificate "${f}.pem" + done + cosign sign-blob --yes sbom.json --output-signature sbom.json.sig --output-certificate sbom.json.pem + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true + files: | + dist/*.whl + dist/*.whl.sig + dist/*.whl.pem + dist/*.tar.gz + dist/*.tar.gz.sig + dist/*.tar.gz.pem + sbom.json + sbom.json.sig + sbom.json.pem diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index d50ee86..6ffe8bd 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -29,11 +29,11 @@ jobs: run: pip install bandit[toml] - name: Run Bandit - run: bandit -r src/dns_aid -c .bandit -f json -o bandit-report.json || true + run: bandit -r src/dns_aid -c pyproject.toml -f json -o bandit-report.json - name: Display results if: always() - run: bandit -r src/dns_aid -c .bandit || true + run: bandit -r src/dns_aid -c pyproject.toml - name: Upload Bandit report if: always() @@ -42,6 +42,33 @@ jobs: name: bandit-report path: bandit-report.json + sbom: + name: SBOM Generation + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install ".[dev,cli,mcp,route53]" + pip install cyclonedx-bom + + - name: Generate SBOM + run: cyclonedx-py environment -o sbom.json --output-format json + + - name: Upload SBOM + uses: actions/upload-artifact@v4 + with: + name: sbom + path: sbom.json + dependency-audit: name: Dependency Audit runs-on: ubuntu-latest diff --git a/Dockerfile b/Dockerfile index 740dfb3..0472183 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,8 @@ # dns-aid-mcp # Use multi-stage build for smaller final image -FROM python:3.11-slim AS builder +# Pin base image with digest for reproducible builds +FROM python:3.11-slim@sha256:6ed5bff4d7396e3244b0f3c2fe578c87862efedd3e5e8cebd14a4428ec39ee5a AS builder WORKDIR /app @@ -22,20 +23,22 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ gcc \ && rm -rf /var/lib/apt/lists/* -# Install Python dependencies +# Build wheels instead of editable install COPY pyproject.toml README.md ./ COPY src/ src/ RUN pip install --no-cache-dir --upgrade pip \ - && pip install --no-cache-dir -e ".[mcp,route53]" + && pip wheel --no-cache-dir --wheel-dir /wheels ".[mcp,route53]" \ + && pip wheel --no-cache-dir --wheel-dir /wheels . # Production image -FROM python:3.11-slim AS production +FROM python:3.11-slim@sha256:6ed5bff4d7396e3244b0f3c2fe578c87862efedd3e5e8cebd14a4428ec39ee5a AS production LABEL org.opencontainers.image.title="DNS-AID MCP Server" LABEL org.opencontainers.image.description="DNS-based Agent Identification and Discovery" -LABEL org.opencontainers.image.source="https://github.com/iracic82/dns-aid" +LABEL org.opencontainers.image.source="https://github.com/iracic82/dns-aid-core" LABEL org.opencontainers.image.licenses="Apache-2.0" +LABEL org.opencontainers.image.sbom="cyclonedx" # Create non-root user for security RUN groupadd --gid 1000 dnsaid \ @@ -43,13 +46,9 @@ RUN groupadd --gid 1000 dnsaid \ WORKDIR /app -# Copy installed packages from builder -COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages -COPY --from=builder /usr/local/bin/dns-aid* /usr/local/bin/ - -# Copy application -COPY --chown=dnsaid:dnsaid src/ src/ -COPY --chown=dnsaid:dnsaid pyproject.toml README.md ./ +# Install from pre-built wheels (no source code needed) +COPY --from=builder /wheels /wheels +RUN pip install --no-cache-dir /wheels/*.whl && rm -rf /wheels # Switch to non-root user USER dnsaid diff --git a/GOVERNANCE.md b/GOVERNANCE.md index d1547f4..2ab657e 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -56,6 +56,10 @@ Any Contributor can be nominated for Committer status by an existing Committer. - Understanding of the DNS-AID architecture and IETF draft - Adherence to the Code of Conduct +## Maintainers + +See [MAINTAINERS.md](MAINTAINERS.md) for the current list of maintainers and open roles. The project is actively seeking additional maintainers to ensure long-term sustainability and diverse representation across DNS providers, security expertise, and standards bodies. + ## Modifying Governance Changes to this governance document require a two-thirds majority vote of all Committers. diff --git a/MAINTAINERS.md b/MAINTAINERS.md new file mode 100644 index 0000000..11fc890 --- /dev/null +++ b/MAINTAINERS.md @@ -0,0 +1,34 @@ +# Maintainers + +This file lists the current maintainers of the DNS-AID project. The project is actively seeking additional maintainers to ensure long-term sustainability under the Linux Foundation. + +## Current Maintainers + +| Name | GitHub | Affiliation | Role | Since | +|------|--------|-------------|------|-------| +| Ivan Racic | [@iracic82](https://github.com/iracic82) | Independent | Project Lead | 2024-12 | + +## Desired Roles + +The project is looking for maintainers in the following areas: + +| Role | Responsibilities | Status | +|------|-----------------|--------| +| DNS Standards Lead | IETF draft alignment, RFC compliance review | **Open** | +| Security Lead | DNSSEC/DANE validation, vulnerability triage | **Open** | +| Backend Maintainer | DNS provider backends (Route53, Cloudflare, Infoblox, DDNS) | **Open** | +| CI/Release Engineer | GitHub Actions, release automation, SBOM generation | **Open** | +| Documentation Lead | User guides, API docs, architecture documentation | **Open** | + +## How to Become a Maintainer + +See [GOVERNANCE.md](GOVERNANCE.md) for the process. In brief: + +1. Contribute sustained, high-quality PRs over at least 3 months +2. Be nominated by an existing maintainer +3. Receive approval via Committer vote + +## Contact + +- GitHub Issues: [dns-aid-core/issues](https://github.com/iracic82/dns-aid-core/issues) +- Discussions: [dns-aid-core/discussions](https://github.com/iracic82/dns-aid-core/discussions) diff --git a/SECURITY.md b/SECURITY.md index aa72e6d..8aaa280 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,8 +4,8 @@ | Version | Supported | | ------- | ------------------ | -| 0.4.x | :white_check_mark: | -| < 0.4 | :x: | +| 0.6.x | :white_check_mark: | +| < 0.6 | :x: | ## Reporting a Vulnerability @@ -18,14 +18,10 @@ We take the security of DNS-AID seriously. If you believe you have found a secur Instead, please report security vulnerabilities using one of these methods: 1. **GitHub Private Reporting**: Go to the [Security tab](../../security) of this repository, click "Report a vulnerability", and provide a detailed description -2. **Email**: Send details to [dns-aid-private@lists.lfaidata.foundation](mailto:dns-aid-private@lists.lfaidata.foundation) (once the LF mailing list is provisioned) - -Do not file security vulnerabilities as public GitHub issues. +2. **Email**: Send details to [iracic82@gmail.com](mailto:iracic82@gmail.com) (interim; will migrate to LF mailing list when provisioned) ### What to Include -Please include the following information: - - Type of vulnerability (e.g., injection, authentication bypass, DNSSEC bypass) - Full paths of source file(s) related to the vulnerability - Step-by-step instructions to reproduce the issue @@ -38,23 +34,61 @@ Please include the following information: - **Status Update**: Within 7 days - **Resolution Target**: Within 30 days for critical issues -### Security Considerations +## Security Architecture -DNS-AID handles DNS operations which require special security attention: +### DNSSEC Validation -#### DNS-Specific Risks +DNS-AID checks the **AD (Authenticated Data) flag** returned by the upstream resolver to determine whether a DNS response was DNSSEC-validated. -- **DNS Injection**: All domain names and agent names are validated against RFC 1035 standards -- **Zone Transfer Attacks**: The library only performs authorized operations with proper credentials -- **DNSSEC Bypass**: The validator checks DNSSEC status but does not bypass security checks +**Limitations:** -#### Network Security +- DNS-AID does **not** perform independent DNSSEC chain validation (signature verification, key chain walking, or trust anchor management). +- The AD flag reflects the resolver's validation result. If the resolver is compromised or misconfigured, the AD flag may be unreliable. +- A validating resolver (e.g., Unbound, BIND with DNSSEC enabled) is required for meaningful results. -- **MCP HTTP Transport**: Binds to `127.0.0.1` by default for security -- **AWS Credentials**: Never logged or exposed; use IAM roles in production -- **TLS/HTTPS**: All endpoint connections use HTTPS by default +### DANE / TLSA Verification + +DNS-AID checks for the **existence of TLSA records** associated with agent endpoints. + +**Limitations:** + +- DNS-AID does **not** perform certificate matching against TLSA records (i.e., it does not verify that the TLS certificate presented by the endpoint matches the TLSA record's certificate association data). +- TLSA existence is treated as an advisory signal, not a security enforcement mechanism. + +### SSRF Protection + +All outbound HTTP fetches (capability document retrieval, A2A agent card fetches) are protected against Server-Side Request Forgery: + +- **HTTPS-only**: Only `https://` URLs are permitted; `http://` is rejected. +- **Private IP blocking**: Connections to private (RFC 1918), loopback (127.0.0.0/8), and link-local (169.254.0.0/16) addresses are blocked via DNS resolution checks before the request is made. +- **Redirect limits**: HTTP clients enforce `max_redirects=3` to prevent redirect-based SSRF. +- **Allowlist**: The `DNS_AID_FETCH_ALLOWLIST` environment variable can whitelist specific hostnames for testing purposes. + +### Capability Document Integrity (cap_sha256) -#### Input Validation +When a `cap-sha256` (key65002) value is present in an SVCB record, DNS-AID verifies the integrity of the fetched capability document: + +- The SHA-256 digest of the fetched document body is computed and base64url-encoded (unpadded). +- The computed digest is compared to the `cap-sha256` value from DNS. +- On mismatch, the capability document is rejected (treated as if the fetch failed). +- When no `cap-sha256` is present, the fetch proceeds without integrity verification. + +### SVCB Custom Parameter Keys + +DNS-AID uses SVCB SvcParamKeys in the **private-use range** (65001–65534) as defined by RFC 9460: + +| Key | Number | Purpose | +| ------- | -------- | -------------------------------- | +| cap | key65001 | Capability document URI | +| cap-sha256 | key65002 | Capability document SHA-256 hash | +| bap | key65003 | BANDAID Agent Profile URI | +| policy | key65004 | Policy document URI | +| realm | key65005 | Administrative realm | +| sig | key65006 | JWS signature | + +These key numbers are in the private-use range pending IANA registration through the IETF draft process. The numeric form (`key65001`) is the default wire format; the string form (`cap`) can be enabled via the `DNS_AID_SVCB_STRING_KEYS` environment variable for human-readable debugging. + +## Input Validation All user inputs are validated before use: - Agent names: alphanumeric with hyphens, max 63 characters @@ -62,21 +96,29 @@ All user inputs are validated before use: - Ports: 1-65535 - TTL: 60-604800 seconds +## Network Security + +- **MCP HTTP Transport**: Binds to `127.0.0.1` by default +- **AWS Credentials**: Never logged or exposed; use IAM roles in production +- **TLS/HTTPS**: All endpoint connections use HTTPS by default + ## Security Best Practices When using DNS-AID in production: 1. **Use IAM Roles**: Don't use access keys; use IAM roles for AWS services 2. **Enable DNSSEC**: Sign your zones with DNSSEC for authenticated DNS -3. **Network Isolation**: Run MCP servers in isolated network segments -4. **Reverse Proxy**: Use nginx/traefik in front of HTTP transport -5. **Audit Logging**: Enable structlog for audit trails +3. **Use a Validating Resolver**: The AD flag is only meaningful with a DNSSEC-validating resolver +4. **Network Isolation**: Run MCP servers in isolated network segments +5. **Reverse Proxy**: Use nginx/traefik in front of HTTP transport +6. **Audit Logging**: Enable structlog for audit trails ## Known Security Limitations - The mock backend is for testing only and should not be used in production -- DNSSEC validation requires a validating resolver -- DANE/TLSA support is advisory only +- DNSSEC validation depends on the upstream resolver's AD flag; no independent chain validation is performed +- DANE/TLSA support checks record existence only; no certificate matching is performed +- SVCB custom keys use private-use numbers pending IANA registration ## Security Updates diff --git a/docs/getting-started.md b/docs/getting-started.md index 11c369c..f38bca4 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1061,6 +1061,45 @@ python examples/demo_full.py - Test health endpoint: `curl http://localhost:8000/health` - Check Claude Desktop logs +## Environment Variables Reference + +### Core Configuration + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `DNS_AID_BACKEND` | Yes (if no `backend=` arg) | — | DNS backend: `route53`, `cloudflare`, `infoblox`, `ddns`, `mock` | +| `DNS_AID_SVCB_STRING_KEYS` | No | `0` | Set `1` to emit human-readable SVCB param names instead of keyNNNNN | +| `DNS_AID_FETCH_ALLOWLIST` | No | — | Comma-separated hostnames to bypass SSRF protection (testing only) | + +### SDK Configuration + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `DNS_AID_HTTP_PUSH_URL` | No | — | Telemetry push endpoint (POST /signals) | +| `DNS_AID_TELEMETRY_API_URL` | No | — | Community rankings endpoint (GET /rankings) | +| `DNS_AID_DIRECTORY_API_URL` | No | — | Directory search endpoint (GET /search) | + +### Backend-Specific Variables + +| Variable | Backend | Description | +|----------|---------|-------------| +| `AWS_REGION` | route53 | AWS region for Route 53 API calls | +| `INFOBLOX_API_KEY` | infoblox | BloxOne DDI API key | +| `INFOBLOX_DNS_VIEW` | infoblox | DNS view name (default: `default`) | +| `CLOUDFLARE_API_TOKEN` | cloudflare | Cloudflare API token with DNS edit permissions | + +## Experimental Models + +The following modules define forward-looking data models for `.well-known/agent.json` +enrichment. They are **defined but not yet wired** into `discover()` or `publish()`: + +- `dns_aid.core.agent_metadata` — `AgentMetadata` schema (identity, connection, auth, capabilities, contact) +- `dns_aid.core.capability_model` — `CapabilitySpec` with machine-readable `Action` descriptors (intent, semantics, tags) + +These models are available for import and experimentation but are not part of the +stable public API. They will be integrated in a future release once the +`.well-known/agent.json` enrichment pipeline is finalized. + ## Next Steps - Read the [API Reference](api-reference.md) diff --git a/pyproject.toml b/pyproject.toml index 5f1f838..f98512e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "dns-aid" -version = "0.5.1" +version = "0.6.0" description = "DNS-based Agent Identification and Discovery - Reference Implementation" readme = "README.md" license = "Apache-2.0" @@ -77,6 +77,7 @@ dev = [ "boto3-stubs[route53]>=1.34.0", "httpx>=0.27.0", "cryptography>=41.0.0", + "cyclonedx-bom>=4.0.0", ] all = [ # CLI @@ -117,6 +118,9 @@ packages = ["src/dns_aid"] asyncio_mode = "auto" testpaths = ["tests"] addopts = "-v" +markers = [ + "live: tests requiring real infrastructure (Route53, Infoblox, DDNS, etc.)", +] [tool.mypy] python_version = "3.11" @@ -138,6 +142,10 @@ ignore = ["E501", "SIM108"] # SIM108: if-else vs ternary is a style preference # Tests often use nested with statements for mocking and uppercase mock variable names "tests/**/*.py" = ["SIM117", "N806"] +[tool.bandit] +exclude_dirs = ["tests", ".venv", "venv", "build", "dist"] +skips = ["B101", "B110"] + [tool.coverage.run] source = ["src/dns_aid"] branch = true diff --git a/src/dns_aid/__init__.py b/src/dns_aid/__init__.py index dd1afac..391af79 100644 --- a/src/dns_aid/__init__.py +++ b/src/dns_aid/__init__.py @@ -28,7 +28,7 @@ from typing import TYPE_CHECKING from dns_aid.core.discoverer import discover -from dns_aid.core.models import AgentRecord, DiscoveryResult, Protocol, PublishResult +from dns_aid.core.models import AgentRecord, DiscoveryResult, DNSSECError, Protocol, PublishResult from dns_aid.core.publisher import publish, unpublish # Tier 0: DNS validation @@ -43,7 +43,7 @@ # Alias for convenience delete = unpublish -__version__ = "0.5.1" +__version__ = "0.6.0" __all__ = [ # Core functions (Tier 0) "publish", @@ -64,6 +64,8 @@ "DiscoveryResult", "PublishResult", "Protocol", + # Exceptions + "DNSSECError", # Version "__version__", ] diff --git a/src/dns_aid/backends/base.py b/src/dns_aid/backends/base.py index 3ba55c6..e03d7ab 100644 --- a/src/dns_aid/backends/base.py +++ b/src/dns_aid/backends/base.py @@ -143,6 +143,32 @@ async def zone_exists(self, zone: str) -> bool: """ ... + async def get_record( + self, + zone: str, + name: str, + record_type: str, + ) -> dict | None: + """ + Get a specific DNS record by name and type. + + This method queries the backend API directly (not DNS resolution), + providing reliable record existence checks for reconciliation. + + Args: + zone: DNS zone (e.g., "example.com") + name: Record name without zone (e.g., "_chat._a2a._agents") + record_type: Record type (SVCB, TXT, etc.) + + Returns: + Record dict with name, fqdn, type, ttl, values if found, None otherwise + """ + # Default implementation using list_records - backends can override for efficiency + async for record in self.list_records(zone, name_pattern=name, record_type=record_type): + if record.get("name") == name or record.get("fqdn") == f"{name}.{zone}": + return record + return None + async def publish_agent(self, agent: AgentRecord) -> list[str]: """ Publish an agent to DNS (convenience method). diff --git a/src/dns_aid/backends/cloudflare.py b/src/dns_aid/backends/cloudflare.py index 229571c..e3853ad 100644 --- a/src/dns_aid/backends/cloudflare.py +++ b/src/dns_aid/backends/cloudflare.py @@ -449,6 +449,62 @@ async def zone_exists(self, zone: str) -> bool: except (ValueError, httpx.HTTPStatusError): return False + async def get_record( + self, + zone: str, + name: str, + record_type: str, + ) -> dict | None: + """ + Get a specific DNS record by querying Cloudflare API directly. + + More efficient than list_records for single record lookup. + """ + zone_id = await self._get_zone_id(zone) + client = await self._get_client() + + # Build FQDN + fqdn = f"{name}.{zone}".rstrip(".") + + try: + response = await client.get( + f"/zones/{zone_id}/dns_records", + params={"name": fqdn, "type": record_type}, + ) + response.raise_for_status() + data = response.json() + + records = data.get("result", []) + if not records: + return None + + record = records[0] + + # Extract values based on record type + if record_type == "TXT": + values = [record.get("content", "")] + elif record_type == "SVCB": + svcb_data = record.get("data", {}) + priority = svcb_data.get("priority", 0) + target = svcb_data.get("target", "") + value = svcb_data.get("value", "") + values = [f"{priority} {target} {value}".strip()] + else: + values = [record.get("content", "")] + + return { + "name": name, + "fqdn": fqdn, + "type": record_type, + "ttl": record.get("ttl", 0), + "values": values, + "id": record.get("id"), + } + + except Exception as e: + logger.debug("Record not found", fqdn=fqdn, type=record_type, error=str(e)) + return None + async def list_zones(self) -> list[dict]: """ List all zones accessible with the API token. diff --git a/src/dns_aid/backends/ddns.py b/src/dns_aid/backends/ddns.py index af8e487..c3b453c 100644 --- a/src/dns_aid/backends/ddns.py +++ b/src/dns_aid/backends/ddns.py @@ -373,6 +373,41 @@ async def zone_exists(self, zone: str) -> bool: logger.warning(f"Failed to check zone {zone}: {e}") return False + async def get_record( + self, + zone: str, + name: str, + record_type: str, + ) -> dict | None: + """ + Get a specific record by querying the DNS server directly. + + Uses the configured DDNS server for resolution. + """ + fqdn = f"{name}.{zone}" + + try: + resolver = dns.resolver.Resolver() + resolver.nameservers = [self.server] + resolver.port = self.port + resolver.lifetime = self.timeout + + answers = resolver.resolve(fqdn, record_type) + values = [str(rdata) for rdata in answers] + + return { + "name": name, + "fqdn": fqdn, + "type": record_type, + "ttl": answers.rrset.ttl, + "values": values, + } + except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer): + return None + except Exception as e: + logger.debug(f"Failed to query {fqdn} {record_type}: {e}") + return None + async def __aenter__(self): """Async context manager entry.""" return self diff --git a/src/dns_aid/backends/infoblox/bloxone.py b/src/dns_aid/backends/infoblox/bloxone.py index c7f0c5c..5048bec 100644 --- a/src/dns_aid/backends/infoblox/bloxone.py +++ b/src/dns_aid/backends/infoblox/bloxone.py @@ -534,6 +534,63 @@ async def zone_exists(self, zone: str) -> bool: except ValueError: return False + async def get_record( + self, + zone: str, + name: str, + record_type: str, + ) -> dict | None: + """ + Get a specific record by querying BloxOne API directly. + """ + try: + # Build FQDN to search + fqdn = f"{name}.{zone}" + if not fqdn.endswith("."): + fqdn_search = f"{fqdn}." + else: + fqdn_search = fqdn + + # Query BloxOne API + response = await self._request( + "GET", + "/dns/record", + params={ + "_filter": f'absolute_name_spec=="{fqdn_search}" and type=="{record_type}"', + }, + ) + + results = response.get("results", []) + if not results: + return None + + record = results[0] + rdata = record.get("rdata", {}) + values = [] + + # Handle different record types + if record_type == "TXT": + values = [rdata.get("text", "")] + elif record_type == "SVCB": + target = rdata.get("target_name", "") + svc_params = rdata.get("svc_params", "") + values = [f"0 {target} {svc_params}".strip()] + else: + values = [str(rdata)] + + return { + "name": record.get("name_in_zone", ""), + "fqdn": record.get("absolute_name_spec", "").rstrip("."), + "type": record_type, + "ttl": record.get("ttl", 0), + "values": values, + "id": record.get("id"), + } + + except Exception as e: + logger.debug(f"Record not found: {e}") + return None + async def list_zones(self) -> list[dict]: """ List all authoritative zones in BloxOne. diff --git a/src/dns_aid/backends/mock.py b/src/dns_aid/backends/mock.py index 0864b4f..f817224 100644 --- a/src/dns_aid/backends/mock.py +++ b/src/dns_aid/backends/mock.py @@ -150,6 +150,43 @@ async def zone_exists(self, zone: str) -> bool: return True return zone in self._zones + async def get_record( + self, + zone: str, + name: str, + record_type: str, + ) -> dict | None: + """Get a specific record from memory.""" + try: + records = self.records[zone][name][record_type] + if not records: + return None + record = records[0] + + # Format values based on record type + if record_type == "SVCB": + # Format as "priority target params..." + priority = record.get("priority", 1) + target = record.get("target", "") + params = record.get("params", {}) + param_str = " ".join(f'{k}="{v}"' for k, v in params.items()) + values = [f"{priority} {target} {param_str}".strip()] + elif record_type == "TXT": + values = record.get("values", []) + else: + values = record.get("values", []) + + return { + "name": name, + "fqdn": f"{name}.{zone}", + "type": record_type, + "ttl": record.get("ttl", 3600), + "values": values, + "data": record, + } + except (KeyError, IndexError): + return None + def get_svcb_record(self, zone: str, name: str) -> dict | None: """ Get SVCB record data (helper for testing). diff --git a/src/dns_aid/backends/route53.py b/src/dns_aid/backends/route53.py index 3f3a079..62d17c5 100644 --- a/src/dns_aid/backends/route53.py +++ b/src/dns_aid/backends/route53.py @@ -390,6 +390,58 @@ async def zone_exists(self, zone: str) -> bool: except ValueError: return False + async def get_record( + self, + zone: str, + name: str, + record_type: str, + ) -> dict | None: + """ + Get a specific DNS record by querying Route 53 API directly. + + More efficient than list_records for single record lookup. + """ + zone_id = await self._get_zone_id(zone) + client = self._get_client() + + # Build FQDN + fqdn = f"{name}.{zone}" + if not fqdn.endswith("."): + fqdn = f"{fqdn}." + + try: + response = client.list_resource_record_sets( + HostedZoneId=zone_id, + StartRecordName=fqdn, + StartRecordType=record_type, # type: ignore[arg-type] + MaxItems="1", + ) + + record_sets = response.get("ResourceRecordSets", []) + if not record_sets: + return None + + record = record_sets[0] + + # Verify it's the exact record we want + if record["Name"] != fqdn or record["Type"] != record_type: + return None + + # Extract values + values = [rr["Value"] for rr in record.get("ResourceRecords", [])] + + return { + "name": name, + "fqdn": fqdn.rstrip("."), + "type": record_type, + "ttl": record.get("TTL", 0), + "values": values, + } + + except Exception as e: + logger.debug("Record not found", fqdn=fqdn, type=record_type, error=str(e)) + return None + async def list_zones(self) -> list[dict]: """ List all hosted zones in the account. diff --git a/src/dns_aid/cli/main.py b/src/dns_aid/cli/main.py index 450805f..081abe0 100644 --- a/src/dns_aid/cli/main.py +++ b/src/dns_aid/cli/main.py @@ -8,9 +8,6 @@ dns-aid list List DNS-AID records dns-aid zones List available DNS zones dns-aid delete Delete an agent from DNS - dns-aid domain submit Submit domain to Agent Directory - dns-aid domain verify Verify domain ownership - dns-aid domain status Check domain status in directory dns-aid index list List agents in domain's index dns-aid index sync Sync index with actual DNS records """ @@ -70,6 +67,20 @@ def publish( str | None, typer.Option("--category", help="Agent category (e.g., 'network', 'security', 'chat')"), ] = None, + transport: Annotated[ + str | None, + typer.Option( + "--transport", + help="Transport: streamable-http, https, ws, stdio, sse", + ), + ] = None, + auth_type: Annotated[ + str | None, + typer.Option( + "--auth-type", + help="Auth type: none, api_key, bearer, oauth2, mtls, http_msg_sig", + ), + ] = None, ttl: Annotated[int, typer.Option("--ttl", help="DNS TTL in seconds")] = 3600, backend: Annotated[ str, typer.Option("--backend", "-b", help="DNS backend: route53, mock") @@ -557,159 +568,6 @@ def delete( console.print("[yellow]No records found to delete[/yellow]") -# ============================================================================ -# DOMAIN COMMANDS -# ============================================================================ - -# Create a sub-app for domain commands -domain_app = typer.Typer( - name="domain", - help="Manage domains in the DNS-AID Agent Directory", - no_args_is_help=True, -) -app.add_typer(domain_app, name="domain") - - -@domain_app.command("submit") -def domain_submit( - domain: Annotated[str, typer.Argument(help="Domain to submit (e.g., example.com)")], - company_name: Annotated[ - str | None, - typer.Option("--company-name", help="Company/organization name"), - ] = None, - company_website: Annotated[ - str | None, - typer.Option("--company-website", help="Company website URL"), - ] = None, - company_logo: Annotated[ - str | None, - typer.Option("--company-logo", help="Company logo URL"), - ] = None, - company_description: Annotated[ - str | None, - typer.Option("--company-description", help="Company description"), - ] = None, - directory_url: Annotated[ - str, - typer.Option("--directory-url", help="Directory API URL"), - ] = "https://api.velosecurity-ai.io", -): - """ - Submit a domain to the DNS-AID Agent Directory. - - This registers your domain for agent indexing. You'll receive a DNS TXT record - to add for verification. - - Example: - dns-aid domain submit example.com - - # With company metadata: - dns-aid domain submit example.com \\ - --company-name "Acme Corp" \\ - --company-website "https://acme.com" \\ - --company-description "AI automation company" - """ - from dns_aid.core.directory import CompanyMetadata, submit_domain - - console.print(f"\n[bold]Submitting domain: {domain}[/bold]\n") - - # Build company metadata if any provided - company = None - if any([company_name, company_website, company_logo, company_description]): - company = CompanyMetadata( - name=company_name, - website=company_website, - logo_url=company_logo, - description=company_description, - ) - if company_name: - console.print(f" Company: {company_name}") - - result = run_async(submit_domain(domain, company=company, directory_url=directory_url)) - - if result.success: - console.print("[green]✓ Domain submitted successfully![/green]\n") - console.print("[bold]Next step:[/bold] Add this DNS TXT record to verify ownership:\n") - console.print(f" [bold]Name:[/bold] _dns-aid-verify.{domain}") - console.print(" [bold]Type:[/bold] TXT") - console.print(f" [bold]Value:[/bold] {result.verification_token}\n") - if result.dns_record: - console.print(f" [dim]Full record: {result.dns_record}[/dim]\n") - console.print("After adding the record, verify with:") - console.print(f" dns-aid domain verify {domain}") - else: - error_console.print(f"[red]✗ {result.message}[/red]") - raise typer.Exit(1) - - -@domain_app.command("verify") -def domain_verify( - domain: Annotated[str, typer.Argument(help="Domain to verify")], - directory_url: Annotated[ - str, - typer.Option("--directory-url", help="Directory API URL"), - ] = "https://api.velosecurity-ai.io", -): - """ - Verify domain ownership and trigger agent crawling. - - Run this after adding the DNS verification TXT record. - - Example: - dns-aid domain verify example.com - """ - from dns_aid.core.directory import verify_domain - - console.print(f"\n[bold]Verifying domain: {domain}[/bold]\n") - - result = run_async(verify_domain(domain, directory_url=directory_url)) - - if result.success and result.verified: - console.print("[green]✓ Domain verified successfully![/green]\n") - console.print(f" Agents discovered: {result.agents_found}") - console.print(f"\n View your agents at: https://directory.velosecurity-ai.io/?q={domain}") - else: - error_console.print(f"[red]✗ {result.message}[/red]") - console.print("\n[dim]Tip: Make sure the DNS TXT record has propagated.[/dim]") - console.print(f"[dim]Check with: dig _dns-aid-verify.{domain} TXT[/dim]") - raise typer.Exit(1) - - -@domain_app.command("status") -def domain_status( - domain: Annotated[str, typer.Argument(help="Domain to check")], - directory_url: Annotated[ - str, - typer.Option("--directory-url", help="Directory API URL"), - ] = "https://api.velosecurity-ai.io", -): - """ - Check the status of a domain in the directory. - - Example: - dns-aid domain status example.com - """ - from dns_aid.core.directory import get_domain_status - - console.print(f"\n[bold]Domain status: {domain}[/bold]\n") - - status = run_async(get_domain_status(domain, directory_url=directory_url)) - - if "error" in status: - error_console.print(f"[red]✗ {status['error']}[/red]") - raise typer.Exit(1) - - console.print(f" Domain: {status.get('domain', domain)}") - console.print( - f" Verified: {'[green]Yes[/green]' if status.get('verified') else '[yellow]No[/yellow]'}" - ) - console.print(f" Agent count: {status.get('agent_count', 0)}") - if status.get("last_crawled"): - console.print(f" Last crawled: {status.get('last_crawled')}") - if status.get("submitted_at"): - console.print(f" Submitted: {status.get('submitted_at')}") - - # ============================================================================ # INDEX COMMANDS # ============================================================================ @@ -1004,13 +862,21 @@ def _get_backend(backend_name: str): from dns_aid.backends.cloudflare import CloudflareBackend return CloudflareBackend() + elif backend_name == "infoblox": + from dns_aid.backends.infoblox import InfobloxBackend + + return InfobloxBackend() + elif backend_name == "ddns": + from dns_aid.backends.ddns import DDNSBackend + + return DDNSBackend() elif backend_name == "mock": from dns_aid.backends.mock import MockBackend return MockBackend() else: error_console.print(f"[red]Unknown backend: {backend_name}[/red]") - error_console.print("Available backends: route53, cloudflare, mock") + error_console.print("Available backends: route53, cloudflare, infoblox, ddns, mock") raise typer.Exit(1) diff --git a/src/dns_aid/core/__init__.py b/src/dns_aid/core/__init__.py index 71d0faa..2f35093 100644 --- a/src/dns_aid/core/__init__.py +++ b/src/dns_aid/core/__init__.py @@ -8,6 +8,8 @@ fetch_agent_card, fetch_agent_card_from_domain, ) +from dns_aid.core.agent_metadata import AgentMetadata, AuthType, TransportType +from dns_aid.core.capability_model import Action, ActionIntent, ActionSemantics, CapabilitySpec from dns_aid.core.models import AgentRecord, DiscoveryResult, Protocol, PublishResult __all__ = [ @@ -15,10 +17,17 @@ "A2AAuthentication", "A2AProvider", "A2ASkill", + "Action", + "ActionIntent", + "ActionSemantics", + "AgentMetadata", "AgentRecord", + "AuthType", + "CapabilitySpec", "DiscoveryResult", "Protocol", "PublishResult", + "TransportType", "fetch_agent_card", "fetch_agent_card_from_domain", ] diff --git a/src/dns_aid/core/a2a_card.py b/src/dns_aid/core/a2a_card.py index 8f8f08c..767ad7f 100644 --- a/src/dns_aid/core/a2a_card.py +++ b/src/dns_aid/core/a2a_card.py @@ -187,15 +187,24 @@ async def fetch_agent_card( "Process Payment" """ # Construct the well-known URL - if not endpoint.startswith(("http://", "https://")): + if not endpoint.startswith("https://"): endpoint = f"https://{endpoint}" card_url = urljoin(endpoint.rstrip("/") + "/", A2A_AGENT_CARD_PATH.lstrip("/")) + # SSRF protection: validate URL before fetching + try: + from dns_aid.utils.url_safety import UnsafeURLError, validate_fetch_url + + validate_fetch_url(card_url) + except UnsafeURLError as e: + logger.warning("Agent Card URL blocked by SSRF protection", url=card_url, error=str(e)) + return None + logger.debug("Fetching A2A Agent Card", url=card_url) try: - async with httpx.AsyncClient(timeout=timeout) as client: + async with httpx.AsyncClient(timeout=timeout, follow_redirects=False) as client: response = await client.get(card_url) if response.status_code != 200: diff --git a/src/dns_aid/core/agent_metadata.py b/src/dns_aid/core/agent_metadata.py new file mode 100644 index 0000000..4cb5ac0 --- /dev/null +++ b/src/dns_aid/core/agent_metadata.py @@ -0,0 +1,146 @@ +""" +Agent Metadata Contract — the `.well-known/agent.json` schema for DNS-AID. + +Status: Experimental — defined but not yet wired into discover()/publish(). + +Bridges DNS discovery (WHERE is this agent?) with actionable connection +metadata (HOW to connect, WHAT it can do, WHETHER it's still active). + +Both DNS-AID and Google A2A serve `/.well-known/agent.json`. DNS-AID native +documents include an ``aid_version`` key; the metadata fetcher uses this as +a discriminator to choose the right parser. + +Phase 5.5 — Agent Metadata Contract. +""" + +from __future__ import annotations + +from datetime import datetime +from enum import StrEnum + +from pydantic import BaseModel, Field + +from dns_aid.core.capability_model import CapabilitySpec + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class TransportType(StrEnum): + """Wire transport the agent listens on.""" + + streamable_http = "streamable-http" + https = "https" + ws = "ws" + stdio = "stdio" + sse = "sse" + + +class AuthType(StrEnum): + """Authentication method required to call the agent.""" + + none = "none" + api_key = "api_key" + bearer = "bearer" + oauth2 = "oauth2" + mtls = "mtls" + http_msg_sig = "http_msg_sig" # RFC 9421 HTTP Message Signatures (Web Bot Auth) + + +# --------------------------------------------------------------------------- +# Nested sections +# --------------------------------------------------------------------------- + + +class AgentIdentity(BaseModel): + """WHO — agent identification and lifecycle.""" + + agent_id: str | None = Field(None, max_length=36, description="UUID identifier for the agent") + name: str = Field(..., min_length=1, max_length=255, description="Human-readable agent name") + fqdn: str | None = Field( + None, max_length=512, description="DNS-AID FQDN (e.g., _chat._mcp._agents.example.com)" + ) + version: str | None = Field(None, max_length=20, description="Agent version string") + deprecated: bool = Field(False, description="Whether this agent is deprecated") + sunset_date: datetime | None = Field( + None, description="Date after which the agent will be decommissioned" + ) + successor: str | None = Field( + None, max_length=512, description="FQDN of the replacement agent (if deprecated)" + ) + + +class ConnectionSpec(BaseModel): + """HOW — transport and connection details.""" + + protocol: str = Field(..., min_length=1, max_length=20, description="Protocol: mcp, a2a, https") + transport: TransportType = Field( + TransportType.https, description="Wire transport for the connection" + ) + endpoint: str = Field(..., min_length=1, max_length=512, description="Agent endpoint URL") + base_url: str | None = Field(None, max_length=512, description="Base URL for relative paths") + + +class AuthSpec(BaseModel): + """ACCESS — authentication requirements.""" + + type: AuthType = Field(AuthType.none, description="Authentication method") + location: str | None = Field( + None, max_length=50, description="Where to send credentials: header, query, body" + ) + header_name: str | None = Field( + None, max_length=100, description="Header name for api_key/bearer (e.g., Authorization)" + ) + oauth_discovery: str | None = Field( + None, + max_length=512, + description="OAuth 2.0 discovery URL (.well-known/openid-configuration)", + ) + # Web Bot Auth (RFC 9421 HTTP Message Signatures) + key_directory_url: str | None = Field( + None, + max_length=512, + description="This agent's own JWKS key directory for identity verification", + ) + signature_agent_card_url: str | None = Field( + None, + max_length=512, + description="This agent's own Signature Agent Card (structured identity)", + ) + supported_algorithms: list[str] | None = Field( + None, + description="Signing algorithms this agent accepts from callers (e.g., ['ed25519'])", + ) + + +class MetadataContact(BaseModel): + """OWNER — organizational contact info.""" + + owner: str | None = Field(None, max_length=255, description="Organization or team name") + contact: str | None = Field(None, max_length=255, description="Contact email or URL") + documentation: str | None = Field(None, max_length=512, description="Documentation URL") + + +# --------------------------------------------------------------------------- +# Top-level schema +# --------------------------------------------------------------------------- + + +class AgentMetadata(BaseModel): + """ + Full `.well-known/agent.json` schema for DNS-AID agents. + + The ``aid_version`` field distinguishes DNS-AID native documents from + Google A2A Agent Cards (which share the same well-known path). + """ + + aid_version: str = Field( + "1.0", description="DNS-AID metadata schema version (discriminator vs A2A)" + ) + + identity: AgentIdentity + connection: ConnectionSpec + auth: AuthSpec = AuthSpec() # type: ignore[call-arg] + capabilities: CapabilitySpec = CapabilitySpec() # type: ignore[call-arg] + contact: MetadataContact = MetadataContact() # type: ignore[call-arg] diff --git a/src/dns_aid/core/cap_fetcher.py b/src/dns_aid/core/cap_fetcher.py index b6172da..3895d46 100644 --- a/src/dns_aid/core/cap_fetcher.py +++ b/src/dns_aid/core/cap_fetcher.py @@ -43,6 +43,7 @@ class CapabilityDocument: async def fetch_cap_document( cap_uri: str, timeout: float = 10.0, + expected_sha256: str | None = None, ) -> CapabilityDocument | None: """ Fetch and parse the capability document at the given URI. @@ -52,14 +53,26 @@ async def fetch_cap_document( Args: cap_uri: HTTPS URI to the capability document JSON. timeout: HTTP request timeout in seconds. + expected_sha256: Base64url-encoded SHA-256 digest to verify against + the fetched content. If provided and the digest doesn't match, + returns None. If None, skips integrity verification. Returns: CapabilityDocument if successfully fetched and parsed, None otherwise. """ logger.debug("Fetching capability document", cap_uri=cap_uri) + # SSRF protection: validate URL before fetching try: - async with httpx.AsyncClient(timeout=timeout) as client: + from dns_aid.utils.url_safety import UnsafeURLError, validate_fetch_url + + validate_fetch_url(cap_uri) + except UnsafeURLError as e: + logger.warning("Cap URI blocked by SSRF protection", cap_uri=cap_uri, error=str(e)) + return None + + try: + async with httpx.AsyncClient(timeout=timeout, max_redirects=3) as client: response = await client.get(cap_uri) if response.status_code != 200: @@ -70,6 +83,25 @@ async def fetch_cap_document( ) return None + # Verify cap_sha256 integrity if expected digest is provided + if expected_sha256: + import base64 + import hashlib + + actual_digest = ( + base64.urlsafe_b64encode(hashlib.sha256(response.content).digest()) + .rstrip(b"=") + .decode("ascii") + ) + if actual_digest != expected_sha256: + logger.warning( + "Cap document SHA-256 mismatch", + cap_uri=cap_uri, + expected=expected_sha256, + actual=actual_digest, + ) + return None + data = response.json() if not isinstance(data, dict): diff --git a/src/dns_aid/core/capability_model.py b/src/dns_aid/core/capability_model.py new file mode 100644 index 0000000..7c32140 --- /dev/null +++ b/src/dns_aid/core/capability_model.py @@ -0,0 +1,91 @@ +""" +Machine-readable capability model for DNS-AID Agent Metadata Contract. + +Status: Experimental — defined but not yet wired into discover()/publish(). + +Defines action intents and semantics that let orchestrators (LangGraph, CrewAI) +make routing decisions: Is this action safe to retry? Read-only? Requires +transaction handling? + +Phase 5.5 — Agent Metadata Contract. +""" + +from __future__ import annotations + +from enum import StrEnum + +from pydantic import BaseModel, Field + + +class ActionIntent(StrEnum): + """Purpose classification for an agent action. + + Orchestrators use this to decide caching, retry, and composition strategies. + """ + + query = "query" + """Read-only data retrieval — safe to cache and parallelize.""" + + command = "command" + """State-changing operation — fire-and-forget, may not be idempotent.""" + + transaction = "transaction" + """Multi-step operation requiring rollback/commit semantics.""" + + subscription = "subscription" + """Long-lived streaming operation — requires connection lifecycle management.""" + + +class ActionSemantics(StrEnum): + """Safety profile for an agent action. + + Tells orchestrators whether an action modifies state and whether + it can be safely retried on failure. + """ + + read = "read" + """No side effects — safe to run in parallel or cache.""" + + write = "write" + """Modifies state — NOT safe to retry blindly.""" + + idempotent = "idempotent" + """Modifies state but safe to retry — same input always yields same outcome.""" + + +class Action(BaseModel): + """A single action an agent can perform. + + Combines human-readable metadata (name, description, tags) with + machine-readable routing hints (intent, semantics). + """ + + name: str = Field(..., min_length=1, max_length=255, description="Action identifier") + description: str | None = Field(None, max_length=2000, description="Human-readable description") + intent: ActionIntent = Field( + ActionIntent.query, description="Purpose classification for routing decisions" + ) + semantics: ActionSemantics = Field( + ActionSemantics.read, description="Safety profile for retry/caching decisions" + ) + tags: list[str] = Field(default_factory=list, description="Freeform tags for filtering") + + +class CapabilitySpec(BaseModel): + """Structured capability specification for an agent. + + Goes beyond a flat list of capability strings — provides schema discovery, + streaming support, and per-action intent/semantics. + """ + + schema_discovery: str | None = Field( + None, + max_length=512, + description="URL to capability schema document (e.g., OpenAPI, MCP schema)", + ) + supports_streaming: bool = Field( + False, description="Whether the agent supports streaming responses" + ) + actions: list[Action] = Field( + default_factory=list, description="Machine-readable action descriptors" + ) diff --git a/src/dns_aid/core/discoverer.py b/src/dns_aid/core/discoverer.py index 88f7ac1..0be38b1 100644 --- a/src/dns_aid/core/discoverer.py +++ b/src/dns_aid/core/discoverer.py @@ -20,7 +20,7 @@ from dns_aid.core.a2a_card import fetch_agent_card from dns_aid.core.cap_fetcher import fetch_cap_document from dns_aid.core.http_index import HttpIndexAgent, fetch_http_index_or_empty -from dns_aid.core.models import AgentRecord, DiscoveryResult, Protocol +from dns_aid.core.models import AgentRecord, DiscoveryResult, DNSSECError, Protocol logger = structlog.get_logger(__name__) @@ -121,6 +121,17 @@ async def discover( except Exception as e: logger.exception("DNS query failed", error=str(e)) + # DNSSEC enforcement: check AD flag and raise if required but unsigned + if agents and require_dnssec: + from dns_aid.core.validator import _check_dnssec + + dnssec_validated = await _check_dnssec(agents[0].fqdn) + if not dnssec_validated: + raise DNSSECError( + f"DNSSEC validation required but DNS response for " + f"{agents[0].fqdn} is not authenticated (AD flag not set)" + ) + # Enrich agents with endpoint paths from .well-known/agent.json if enrich_endpoints and agents: try: @@ -208,7 +219,7 @@ async def _query_single_agent( capability_source: Literal["cap_uri", "txt_fallback", "none"] = "none" if cap_uri: - cap_doc = await fetch_cap_document(cap_uri) + cap_doc = await fetch_cap_document(cap_uri, expected_sha256=cap_sha256) if cap_doc and cap_doc.capabilities: capabilities = cap_doc.capabilities capability_source = "cap_uri" @@ -252,19 +263,20 @@ def _parse_svcb_custom_params(svcb_text: str) -> dict[str, str]: """ Parse BANDAID custom params from SVCB record text representation. - SVCB records in presentation format look like: - 1 mcp.example.com. alpn="mcp" port="443" cap="https://..." bap="mcp,a2a" - - This extracts key=value pairs where the key matches known BANDAID params. + Accepts both human-readable string names and RFC 9460 keyNNNNN format: + String form: cap="https://..." bap="mcp,a2a" realm="demo" + Numeric form: key65001="https://..." key65003="mcp,a2a" key65005="demo" Args: svcb_text: String representation of an SVCB rdata. Returns: - Dict of custom param names to their string values. + Dict of custom param names (always string form) to their string values. """ + from dns_aid.core.models import BANDAID_KEY_MAP_REVERSE + custom_params: dict[str, str] = {} - bandaid_keys = {"cap", "cap-sha256", "bap", "policy", "realm"} + bandaid_keys = {"cap", "cap-sha256", "bap", "policy", "realm", "sig"} # Split on spaces, then look for key="value" or key=value patterns parts = svcb_text.split() @@ -273,6 +285,11 @@ def _parse_svcb_custom_params(svcb_text: str) -> dict[str, str]: continue key, _, value = part.partition("=") key = key.strip().lower() + + # Normalize keyNNNNN to string name + if key in BANDAID_KEY_MAP_REVERSE: + key = BANDAID_KEY_MAP_REVERSE[key] + if key in bandaid_keys: # Remove surrounding quotes if present value = value.strip('"').strip("'") @@ -332,12 +349,21 @@ async def _discover_agents_in_zone( # Try TXT index first (direct DNS query, no backend credentials needed) index_entries = await read_index_via_dns(domain) + # Concurrency limiter: avoid overwhelming the DNS resolver + sem = asyncio.Semaphore(20) + + async def _query_with_sem(name: str, proto: Protocol) -> AgentRecord | None: + async with sem: + return await _query_single_agent(domain, name, proto) + if index_entries: logger.debug( "Using TXT index for discovery", domain=domain, entry_count=len(index_entries), ) + + tasks = [] for entry in index_entries: try: entry_protocol = Protocol(entry.protocol.lower()) @@ -347,9 +373,12 @@ async def _discover_agents_in_zone( if protocol and entry_protocol != protocol: continue - agent = await _query_single_agent(domain, entry.name, entry_protocol) - if agent: - agents.append(agent) + tasks.append(_query_with_sem(entry.name, entry_protocol)) + + results = await asyncio.gather(*tasks, return_exceptions=True) + for result in results: + if isinstance(result, AgentRecord): + agents.append(result) return agents @@ -371,11 +400,15 @@ async def _discover_agents_in_zone( protocols_to_try = [protocol] if protocol else [Protocol.MCP, Protocol.A2A] + tasks = [] for proto in protocols_to_try: for name in common_names: - agent = await _query_single_agent(domain, name, proto) - if agent: - agents.append(agent) + tasks.append(_query_with_sem(name, proto)) + + results = await asyncio.gather(*tasks, return_exceptions=True) + for result in results: + if isinstance(result, AgentRecord): + agents.append(result) return agents diff --git a/src/dns_aid/core/http_index.py b/src/dns_aid/core/http_index.py index cfef66f..671d9ec 100644 --- a/src/dns_aid/core/http_index.py +++ b/src/dns_aid/core/http_index.py @@ -201,6 +201,7 @@ async def fetch_http_index( timeout=timeout, verify=ssl_context if verify_ssl else False, follow_redirects=True, + max_redirects=3, ) as client: for pattern in HTTP_INDEX_PATTERNS: # Build URL from pattern diff --git a/src/dns_aid/core/models.py b/src/dns_aid/core/models.py index fa1c62d..8ca5c70 100644 --- a/src/dns_aid/core/models.py +++ b/src/dns_aid/core/models.py @@ -7,11 +7,46 @@ from __future__ import annotations +import os from enum import StrEnum from typing import Any, Literal from pydantic import BaseModel, Field, field_validator +# BANDAID custom SVCB param key mapping (IETF draft-02, Section 4.4.3) +# These are provisional private-use key numbers in the range 65001-65534. +# Once IANA assigns official SvcParamKey numbers, update these values. +BANDAID_KEY_MAP: dict[str, str] = { + "cap": "key65001", + "cap-sha256": "key65002", + "bap": "key65003", + "policy": "key65004", + "realm": "key65005", + "sig": "key65006", +} + +BANDAID_KEY_MAP_REVERSE: dict[str, str] = {v: k for k, v in BANDAID_KEY_MAP.items()} + + +def _use_string_keys() -> bool: + """Check if human-readable string keys should be used instead of keyNNNNN. + + Set DNS_AID_SVCB_STRING_KEYS=1 to emit string names (for DNS providers + that don't support keyNNNNN format or for human readability). + Default is keyNNNNN format per RFC 9460 requirements. + """ + return os.environ.get("DNS_AID_SVCB_STRING_KEYS", "").lower() in ("1", "true", "yes") + + +class DNSSECError(Exception): + """Raised when DNSSEC validation is required but the DNS response is unsigned. + + This error indicates that ``require_dnssec=True`` was passed to + :func:`dns_aid.discover` but the recursive resolver did not set the + AD (Authenticated Data) flag in its response, meaning the DNS answer + cannot be trusted as DNSSEC-validated. + """ + class Protocol(StrEnum): """ @@ -161,14 +196,22 @@ class AgentRecord(BaseModel): # Endpoint source - where the endpoint information came from endpoint_source: ( - Literal["dns_svcb", "dns_svcb_enriched", "http_index", "http_index_fallback", "direct"] + Literal[ + "dns_svcb", + "dns_svcb_enriched", + "http_index", + "http_index_fallback", + "direct", + "directory", + ] | None ) = Field( default=None, description="Source of endpoint: 'dns_svcb' (from DNS SVCB record), " "'dns_svcb_enriched' (DNS + .well-known/agent.json path), " "'http_index' (DNS + HTTP index endpoint), " - "'http_index_fallback' (HTTP index without DNS), 'direct' (explicitly provided)", + "'http_index_fallback' (HTTP index without DNS), 'direct' (explicitly provided), " + "'directory' (from directory API search, Phase 5.7)", ) # A2A Agent Card (populated from .well-known/agent.json when available) @@ -237,19 +280,26 @@ def to_svcb_params(self) -> dict[str, str]: if self.ipv6_hint: params["ipv6hint"] = self.ipv6_hint # BANDAID custom SVCB params (IETF draft-02, Section 4.4.3) + # Emit keyNNNNN format by default (RFC 9460 compliant for unregistered keys). + # Set DNS_AID_SVCB_STRING_KEYS=1 for human-readable string names. + use_strings = _use_string_keys() + + def _key(name: str) -> str: + return name if use_strings else BANDAID_KEY_MAP.get(name, name) + if self.cap_uri: - params["cap"] = self.cap_uri + params[_key("cap")] = self.cap_uri if self.cap_sha256: - params["cap-sha256"] = self.cap_sha256 + params[_key("cap-sha256")] = self.cap_sha256 if self.bap: - params["bap"] = ",".join(self.bap) + params[_key("bap")] = ",".join(self.bap) if self.policy_uri: - params["policy"] = self.policy_uri + params[_key("policy")] = self.policy_uri if self.realm: - params["realm"] = self.realm + params[_key("realm")] = self.realm # JWS signature for application-layer verification if self.sig: - params["sig"] = self.sig + params[_key("sig")] = self.sig return params def to_txt_values(self) -> list[str]: @@ -320,6 +370,14 @@ class VerifyResult(BaseModel): dane_valid: bool | None = Field( default=None, description="DANE/TLSA verified (None if not configured)" ) + dnssec_note: str = Field( + default="Checks AD flag from resolver; no independent DNSSEC chain validation", + description="Limitation note for DNSSEC validation", + ) + dane_note: str = Field( + default="Checks TLSA record existence only; no certificate matching performed", + description="Limitation note for DANE validation", + ) endpoint_reachable: bool = Field(default=False, description="Endpoint responds") endpoint_latency_ms: float | None = Field(default=None, description="Endpoint response time") diff --git a/src/dns_aid/core/publisher.py b/src/dns_aid/core/publisher.py index bfea007..8cb0a0f 100644 --- a/src/dns_aid/core/publisher.py +++ b/src/dns_aid/core/publisher.py @@ -34,13 +34,22 @@ def reset_default_backend() -> None: def get_default_backend() -> DNSBackend: """Get the default DNS backend based on DNS_AID_BACKEND env var. - Supported values: route53, cloudflare, infoblox, ddns, mock (default) + Supported values: route53, cloudflare, infoblox, ddns, mock + + Raises: + ValueError: If DNS_AID_BACKEND is not set (no silent fallback to mock). """ import os global _default_backend if _default_backend is None: - backend_type = os.environ.get("DNS_AID_BACKEND", "mock").lower() + backend_type = os.environ.get("DNS_AID_BACKEND", "").lower() + + if not backend_type: + raise ValueError( + "DNS_AID_BACKEND must be set. " + "Supported values: route53, cloudflare, infoblox, ddns, mock" + ) if backend_type == "route53": from dns_aid.backends.route53 import Route53Backend @@ -58,8 +67,13 @@ def get_default_backend() -> DNSBackend: from dns_aid.backends.ddns import DDNSBackend _default_backend = DDNSBackend() - else: + elif backend_type == "mock": _default_backend = MockBackend() + else: + raise ValueError( + f"Unknown DNS_AID_BACKEND: '{backend_type}'. " + "Supported values: route53, cloudflare, infoblox, ddns, mock" + ) logger.info( "Initialized default DNS backend", diff --git a/src/dns_aid/core/validator.py b/src/dns_aid/core/validator.py index 5c63484..e426128 100644 --- a/src/dns_aid/core/validator.py +++ b/src/dns_aid/core/validator.py @@ -6,6 +6,9 @@ from __future__ import annotations +import asyncio +import hashlib +import ssl import time import dns.asyncresolver @@ -20,7 +23,7 @@ logger = structlog.get_logger(__name__) -async def verify(fqdn: str) -> VerifyResult: +async def verify(fqdn: str, *, verify_dane_cert: bool = False) -> VerifyResult: """ Verify DNS-AID records for an agent. @@ -34,6 +37,9 @@ async def verify(fqdn: str) -> VerifyResult: Args: fqdn: Fully qualified domain name of agent record (e.g., "_chat._a2a._agents.example.com") + verify_dane_cert: If True, perform full DANE certificate matching + (connect to endpoint and compare TLS cert against + TLSA record). Default False (existence check only). Returns: VerifyResult with security validation results @@ -58,7 +64,7 @@ async def verify(fqdn: str) -> VerifyResult: # 3. Check DANE/TLSA (if target is available) if target: - result.dane_valid = await _check_dane(target, port) + result.dane_valid = await _check_dane(target, port, verify_cert=verify_dane_cert) # 4. Check endpoint reachability if target and port: @@ -137,6 +143,13 @@ async def _check_dnssec(fqdn: str) -> bool: """ Check if DNSSEC is validated for the FQDN. + Limitation: This only checks the AD (Authenticated Data) flag in the DNS + response from the configured recursive resolver. It does NOT perform + independent DNSSEC chain validation (DNSKEY → DS → RRSIG). The AD flag + is only trustworthy if the path to the resolver is secured (e.g., via + localhost or DoT/DoH). A resolver on an untrusted network could spoof + the AD flag. + Returns True if DNSSEC AD (Authenticated Data) flag is set. """ try: @@ -177,13 +190,23 @@ async def _check_dnssec(fqdn: str) -> bool: return False -async def _check_dane(target: str, port: int) -> bool | None: +async def _check_dane(target: str, port: int, *, verify_cert: bool = False) -> bool | None: """ Check DANE/TLSA record for the endpoint. + When ``verify_cert`` is False (default), this only checks whether a TLSA + record exists in DNS. When True, it additionally connects to the endpoint + via TLS, retrieves the certificate, and compares its digest against the + TLSA association data. + + Args: + target: Hostname of the endpoint. + port: Port number. + verify_cert: If True, perform full certificate matching against TLSA. + Returns: - True if TLSA record exists and matches - False if TLSA record exists but doesn't match + True if TLSA record exists (and optionally cert matches) + False if TLSA record exists but cert does NOT match (verify_cert=True) None if no TLSA record configured """ # TLSA record format: _port._tcp.hostname @@ -201,9 +224,29 @@ async def _check_dane(target: str, port: int) -> bool | None: selector=rdata.selector, mtype=rdata.mtype, ) - # For now, just check if TLSA exists - # Full DANE validation would verify certificate matches - return True + + if not verify_cert: + # Advisory mode: TLSA exists → True + return True + + # Full DANE cert matching + try: + cert_match = await _match_dane_cert( + target, port, rdata.selector, rdata.mtype, rdata.cert + ) + if cert_match: + logger.info("DANE certificate match verified", fqdn=tlsa_fqdn) + return True + else: + logger.warning("DANE certificate mismatch", fqdn=tlsa_fqdn) + return False + except Exception as e: + logger.warning( + "DANE certificate matching failed", + fqdn=tlsa_fqdn, + error=str(e), + ) + return False except dns.resolver.NXDOMAIN: logger.debug("No TLSA record (DANE not configured)", fqdn=tlsa_fqdn) @@ -215,6 +258,63 @@ async def _check_dane(target: str, port: int) -> bool | None: return None # Not configured +async def _match_dane_cert( + target: str, + port: int, + selector: int, + mtype: int, + tlsa_data: bytes, +) -> bool: + """ + Connect to ``target:port`` via TLS and compare cert against TLSA data. + + Args: + target: Hostname to connect to. + port: Port number. + selector: TLSA selector — 0 = full cert, 1 = SubjectPublicKeyInfo. + mtype: TLSA matching type — 0 = exact, 1 = SHA-256, 2 = SHA-512. + tlsa_data: Certificate association data from the TLSA record. + + Returns: + True if the presented certificate matches the TLSA record. + """ + ctx = ssl.create_default_context() + _, writer = await asyncio.open_connection(target, port, ssl=ctx) + + try: + ssl_object = writer.get_extra_info("ssl_object") + der_cert = ssl_object.getpeercert(binary_form=True) + + if selector == 1: + # SPKI: extract SubjectPublicKeyInfo from DER certificate + from cryptography.hazmat.primitives.serialization import ( + Encoding, + PublicFormat, + ) + from cryptography.x509 import load_der_x509_certificate + + x509_cert = load_der_x509_certificate(der_cert) + cert_bytes = x509_cert.public_key().public_bytes( + Encoding.DER, PublicFormat.SubjectPublicKeyInfo + ) + else: + # selector 0: full certificate DER bytes + cert_bytes = der_cert + + if mtype == 1: + computed = hashlib.sha256(cert_bytes).digest() + elif mtype == 2: + computed = hashlib.sha512(cert_bytes).digest() + else: + # mtype 0: exact match + computed = cert_bytes + + return computed == tlsa_data + finally: + writer.close() + await writer.wait_closed() + + async def _check_endpoint(target: str, port: int) -> dict: """ Check if endpoint is reachable. diff --git a/src/dns_aid/mcp/server.py b/src/dns_aid/mcp/server.py index 596048b..d641b04 100644 --- a/src/dns_aid/mcp/server.py +++ b/src/dns_aid/mcp/server.py @@ -75,6 +75,18 @@ # Shared thread pool for async operations (avoids creating pool per call) _executor: ThreadPoolExecutor | None = None +# Default telemetry push URL (overridden by DNS_AID_SDK_HTTP_PUSH_URL env var) +_DEFAULT_HTTP_PUSH_URL = "https://api.velosecurity-ai.io/api/v1/telemetry/signals" + +# Optionally delegate to SDK for telemetry capture +_sdk_available = False +try: + from dns_aid.sdk import AgentClient, SDKConfig # noqa: E402 + + _sdk_available = True +except ImportError: + pass + def _get_executor() -> ThreadPoolExecutor: """Get or create shared thread pool executor.""" @@ -124,6 +136,30 @@ def _run_async(coro): return asyncio.run(coro) +def _get_dns_backend(name: str): + """Get DNS backend instance by name.""" + from dns_aid.backends.mock import MockBackend + + if name == "route53": + from dns_aid.backends.route53 import Route53Backend + + return Route53Backend() + elif name == "cloudflare": + from dns_aid.backends.cloudflare import CloudflareBackend + + return CloudflareBackend() + elif name == "infoblox": + from dns_aid.backends.infoblox import InfobloxBackend + + return InfobloxBackend() + elif name == "ddns": + from dns_aid.backends.ddns import DDNSBackend + + return DDNSBackend() + else: + return MockBackend() + + def _format_validation_error(e: ValidationError) -> dict: """Format validation error for API response.""" return { @@ -148,7 +184,7 @@ def publish_agent_to_dns( use_cases: list[str] | None = None, category: str | None = None, ttl: int = 3600, - backend: Literal["route53", "mock"] = "route53", + backend: Literal["route53", "cloudflare", "infoblox", "ddns", "mock"] = "route53", update_index: bool = True, cap_uri: str | None = None, cap_sha256: str | None = None, @@ -222,17 +258,10 @@ def publish_agent_to_dns( except ValidationError as e: return _format_validation_error(e) - from dns_aid.backends.base import DNSBackend - from dns_aid.backends.mock import MockBackend - from dns_aid.backends.route53 import Route53Backend from dns_aid.core.publisher import publish # Get backend - dns_backend: DNSBackend - if backend == "route53": - dns_backend = Route53Backend() - else: - dns_backend = MockBackend() + dns_backend = _get_dns_backend(backend) async def _publish(): return await publish( @@ -427,6 +456,38 @@ async def _discover(): } +def _build_agent_record_from_endpoint(endpoint: str, protocol: str = "mcp"): + """Build a synthetic AgentRecord from an endpoint URL for SDK telemetry.""" + from urllib.parse import urlparse + + from dns_aid.core.models import AgentRecord, Protocol + + parsed = urlparse(endpoint) + hostname = parsed.hostname or "unknown" + port = parsed.port or 443 + + # Derive a reasonable domain and name from the URL + parts = hostname.split(".") + domain = ".".join(parts[-2:]) if len(parts) >= 2 else hostname + name = parts[0] if parts[0] not in ("www", "api", "mcp", "a2a") else "agent" + + proto_map = {"mcp": Protocol.MCP, "a2a": Protocol.A2A, "https": Protocol.HTTPS} + + # Preserve the full URL (including path) as endpoint_override so that + # agent.endpoint_url returns the complete URL, not just host:port. + # Without this, a URL like "https://host:443/mcp" loses the "/mcp" path. + endpoint_override = endpoint if parsed.path and parsed.path != "/" else None + + return AgentRecord( + name=name, + domain=domain, + protocol=proto_map.get(protocol, Protocol.MCP), + target_host=hostname, + port=port, + endpoint_override=endpoint_override, + ) + + @mcp.tool() def call_agent_tool( endpoint: str, @@ -448,8 +509,53 @@ def call_agent_tool( dict with: - success: Whether the call succeeded - result: The tool's response content + - telemetry: Invocation telemetry (latency, status) when SDK is available - error: Error message if failed """ + # Route through SDK for telemetry capture when available + if _sdk_available: + return _call_agent_tool_via_sdk(endpoint, tool_name, arguments) + return _call_agent_tool_raw(endpoint, tool_name, arguments) + + +def _call_agent_tool_via_sdk(endpoint: str, tool_name: str, arguments: dict | None) -> dict: + """Call agent tool through the SDK for automatic telemetry capture.""" + import os + + agent = _build_agent_record_from_endpoint(endpoint, protocol="mcp") + config = SDKConfig( + timeout_seconds=30.0, + console_signals=False, + caller_id="dns-aid-mcp-server", + http_push_url=os.getenv("DNS_AID_SDK_HTTP_PUSH_URL", _DEFAULT_HTTP_PUSH_URL), + ) + + async def _invoke(): + async with AgentClient(config=config) as client: + return await client.invoke( + agent, + method="tools/call", + arguments={"name": tool_name, "arguments": arguments or {}}, + ) + + try: + result = _run_async(_invoke()) + response: dict = {"success": result.success} + if result.success: + response["result"] = result.data + else: + response["error"] = str(result.data) if result.data else "Invocation failed" + response["telemetry"] = { + "latency_ms": round(result.signal.invocation_latency_ms, 2), + "status": result.signal.status.value, + } + return response + except Exception as e: + return {"success": False, "error": str(e)} + + +def _call_agent_tool_raw(endpoint: str, tool_name: str, arguments: dict | None) -> dict: + """Fallback: call agent tool with raw httpx (no telemetry).""" import httpx # Build MCP JSON-RPC request @@ -542,8 +648,57 @@ def list_agent_tools(endpoint: str) -> dict: dict with: - success: Whether the call succeeded - tools: List of available tools with name, description, and input schema + - telemetry: Invocation telemetry (latency, status) when SDK is available - error: Error message if failed """ + # Route through SDK for telemetry capture when available + if _sdk_available: + return _list_agent_tools_via_sdk(endpoint) + return _list_agent_tools_raw(endpoint) + + +def _list_agent_tools_via_sdk(endpoint: str) -> dict: + """List agent tools through the SDK for automatic telemetry capture.""" + import os + + agent = _build_agent_record_from_endpoint(endpoint, protocol="mcp") + config = SDKConfig( + timeout_seconds=30.0, + console_signals=False, + caller_id="dns-aid-mcp-server", + http_push_url=os.getenv("DNS_AID_SDK_HTTP_PUSH_URL", _DEFAULT_HTTP_PUSH_URL), + ) + + async def _invoke(): + async with AgentClient(config=config) as client: + return await client.invoke(agent, method="tools/list") + + try: + result = _run_async(_invoke()) + if result.success and isinstance(result.data, dict): + tools = result.data.get("tools", []) + elif result.success and isinstance(result.data, list): + tools = result.data + else: + tools = [] + response: dict = { + "success": result.success, + "tools": tools, + "count": len(tools), + "telemetry": { + "latency_ms": round(result.signal.invocation_latency_ms, 2), + "status": result.signal.status.value, + }, + } + if not result.success: + response["error"] = str(result.data) if result.data else "Invocation failed" + return response + except Exception as e: + return {"success": False, "error": str(e)} + + +def _list_agent_tools_raw(endpoint: str) -> dict: + """Fallback: list agent tools with raw httpx (no telemetry).""" import httpx mcp_request = { @@ -659,7 +814,7 @@ async def _verify(): @mcp.tool() def list_published_agents( domain: str, - backend: Literal["route53", "mock"] = "route53", + backend: Literal["route53", "cloudflare", "infoblox", "ddns", "mock"] = "route53", ) -> dict: """ List all agents published at a domain via DNS-AID. @@ -687,16 +842,8 @@ def list_published_agents( except ValidationError as e: return _format_validation_error(e) - from dns_aid.backends.base import DNSBackend - from dns_aid.backends.mock import MockBackend - from dns_aid.backends.route53 import Route53Backend - # Get backend - dns_backend: DNSBackend - if backend == "route53": - dns_backend = Route53Backend() - else: - dns_backend = MockBackend() + dns_backend = _get_dns_backend(backend) async def _list(): records = [] @@ -739,7 +886,7 @@ def delete_agent_from_dns( name: str, domain: str, protocol: Literal["mcp", "a2a"] = "mcp", - backend: Literal["route53", "mock"] = "route53", + backend: Literal["route53", "cloudflare", "infoblox", "ddns", "mock"] = "route53", update_index: bool = True, ) -> dict: """ @@ -838,7 +985,7 @@ async def _update_index(): @mcp.tool() def list_agent_index( domain: str, - backend: Literal["route53", "mock"] = "route53", + backend: Literal["route53", "cloudflare", "infoblox", "ddns", "mock"] = "route53", ) -> dict: """ List agents in a domain's index record. @@ -906,7 +1053,7 @@ async def _read_index(): @mcp.tool() def sync_agent_index( domain: str, - backend: Literal["route53", "mock"] = "route53", + backend: Literal["route53", "cloudflare", "infoblox", "ddns", "mock"] = "route53", ttl: int = 3600, ) -> dict: """ diff --git a/src/dns_aid/sdk/_config.py b/src/dns_aid/sdk/_config.py index c335b79..0d651e4 100644 --- a/src/dns_aid/sdk/_config.py +++ b/src/dns_aid/sdk/_config.py @@ -30,16 +30,6 @@ class SDKConfig(BaseModel): description="Identifier for the calling agent/service.", ) - # Signal storage - persist_signals: bool = Field( - default=False, - description="Whether to persist signals to the directory database.", - ) - database_url: str | None = Field( - default=None, - description="Database URL for signal persistence. Uses DATABASE_URL env var if not set.", - ) - # OTEL settings otel_enabled: bool = Field( default=False, @@ -81,8 +71,6 @@ def from_env(cls) -> SDKConfig: timeout_seconds=float(os.getenv("DNS_AID_SDK_TIMEOUT", "30")), max_retries=int(os.getenv("DNS_AID_SDK_MAX_RETRIES", "0")), caller_id=os.getenv("DNS_AID_SDK_CALLER_ID"), - persist_signals=os.getenv("DNS_AID_SDK_PERSIST_SIGNALS", "").lower() == "true", - database_url=os.getenv("DATABASE_URL"), http_push_url=os.getenv("DNS_AID_SDK_HTTP_PUSH_URL"), otel_enabled=os.getenv("DNS_AID_SDK_OTEL_ENABLED", "").lower() == "true", otel_endpoint=os.getenv("DNS_AID_SDK_OTEL_ENDPOINT"), diff --git a/src/dns_aid/sdk/client.py b/src/dns_aid/sdk/client.py index 8e16a4a..e0dd4c8 100644 --- a/src/dns_aid/sdk/client.py +++ b/src/dns_aid/sdk/client.py @@ -137,10 +137,6 @@ async def invoke( raw=raw, ) - # Auto-persist to DB if configured (fire-and-forget) - if self._config.persist_signals: - await self._persist_signal(signal) - # HTTP push to telemetry API if configured (true fire-and-forget via thread) if self._config.http_push_url: thread = threading.Thread( @@ -156,20 +152,6 @@ async def invoke( signal=signal, ) - async def _persist_signal(self, signal: InvocationSignal) -> None: - """Persist a signal to the database. Errors are logged, never raised.""" - try: - from dns_aid.directory.database import ensure_database_initialized - from dns_aid.sdk.signals.store import SignalStore - - db = await ensure_database_initialized() - async with db.session() as session: - store = SignalStore(session) - await store.save(signal) - logger.debug("sdk.signal_persisted", signal_id=str(signal.id)) - except Exception: - logger.warning("sdk.persist_failed", signal_id=str(signal.id), exc_info=True) - @staticmethod def _push_signal_http_sync(signal: InvocationSignal, push_url: str) -> None: """POST a signal to the telemetry API. Runs in a daemon thread, fire-and-forget.""" diff --git a/src/dns_aid/sdk/repository.py b/src/dns_aid/sdk/repository.py deleted file mode 100644 index 57cf73a..0000000 --- a/src/dns_aid/sdk/repository.py +++ /dev/null @@ -1,160 +0,0 @@ -""" -Telemetry repository — DB queries for the telemetry API. - -Follows the same pattern as dns_aid.directory.repository. -""" - -from __future__ import annotations - -from datetime import UTC, datetime, timedelta - -import structlog -from sqlalchemy import distinct, func, select -from sqlalchemy.ext.asyncio import AsyncSession - -from dns_aid.directory.models import InvocationSignalRecord -from dns_aid.sdk.signals.store import SignalStore - -logger = structlog.get_logger(__name__) - - -class TelemetryRepository: - """Repository for telemetry queries — extends SignalStore with analytics.""" - - def __init__(self, session: AsyncSession) -> None: - self.session = session - self._store = SignalStore(session) - - @property - def store(self) -> SignalStore: - return self._store - - async def get_signal(self, signal_id: str) -> InvocationSignalRecord | None: - return await self._store.get(signal_id) - - async def list_signals( - self, - *, - agent_fqdn: str | None = None, - protocol: str | None = None, - status: str | None = None, - since: datetime | None = None, - until: datetime | None = None, - limit: int = 50, - offset: int = 0, - ) -> tuple[list[InvocationSignalRecord], int]: - """List signals with count for pagination.""" - signals = await self._store.list_signals( - agent_fqdn=agent_fqdn, - protocol=protocol, - status=status, - since=since, - until=until, - limit=limit, - offset=offset, - ) - total = await self._store.count_signals( - agent_fqdn=agent_fqdn, - status=status, - ) - return signals, total - - async def get_scorecard(self, agent_fqdn: str): # noqa: ANN201 - """Get scorecard for a single agent.""" - return await self._store.scorecard(agent_fqdn) - - async def get_rankings(self, limit: int = 50) -> list[dict]: - """Get agent rankings by composite score (computed from signals).""" - # Get distinct agents with signals - result = await self.session.execute( - select(InvocationSignalRecord.agent_fqdn).distinct().limit(limit) - ) - fqdns = list(result.scalars().all()) - - rankings: list[dict] = [] - for fqdn in fqdns: - sc = await self._store.scorecard(fqdn) - rankings.append( - { - "agent_fqdn": fqdn, - "composite_score": sc.composite_score, - "success_rate": sc.success_rate, - "avg_latency_ms": sc.avg_latency_ms, - "total_invocations": sc.total_invocations, - "total_cost_units": sc.total_cost_units, - } - ) - - # Sort by composite score descending - rankings.sort(key=lambda r: float(r["composite_score"]), reverse=True) - return rankings - - async def get_global_stats(self) -> dict: - """Get global telemetry statistics.""" - now = datetime.now(UTC) - last_24h = now - timedelta(hours=24) - last_7d = now - timedelta(days=7) - - total = await self._store.count_signals() - error_count = await self._store.count_signals(status="error") - timeout_count = await self._store.count_signals(status="timeout") - - # Distinct agents - result = await self.session.execute( - select(func.count(distinct(InvocationSignalRecord.agent_fqdn))) - ) - total_agents = result.scalar_one() - - # Average latency - result = await self.session.execute( - select(func.avg(InvocationSignalRecord.invocation_latency_ms)) - ) - avg_latency = result.scalar_one() or 0.0 - - # Total cost - result = await self.session.execute(select(func.sum(InvocationSignalRecord.cost_units))) - total_cost = result.scalar_one() or 0.0 - - # Signals in last 24h - result = await self.session.execute( - select(func.count(InvocationSignalRecord.id)).where( - InvocationSignalRecord.timestamp >= last_24h - ) - ) - signals_24h = result.scalar_one() - - # Signals in last 7d - result = await self.session.execute( - select(func.count(InvocationSignalRecord.id)).where( - InvocationSignalRecord.timestamp >= last_7d - ) - ) - signals_7d = result.scalar_one() - - error_rate = ((error_count + timeout_count) / total * 100) if total > 0 else 0.0 - - return { - "total_invocations": total, - "total_agents_invoked": total_agents, - "avg_latency_ms": round(avg_latency, 2), - "error_rate": round(error_rate, 2), - "total_cost_units": round(total_cost, 4), - "signals_last_24h": signals_24h, - "signals_last_7d": signals_7d, - } - - async def export_signals( - self, - *, - agent_fqdn: str | None = None, - since: datetime | None = None, - until: datetime | None = None, - limit: int = 10000, - ) -> list[InvocationSignalRecord]: - """Export signals for external consumption (JSON/CSV).""" - return await self._store.list_signals( - agent_fqdn=agent_fqdn, - since=since, - until=until, - limit=limit, - ) diff --git a/src/dns_aid/sdk/signals/store.py b/src/dns_aid/sdk/signals/store.py deleted file mode 100644 index bed4f0e..0000000 --- a/src/dns_aid/sdk/signals/store.py +++ /dev/null @@ -1,216 +0,0 @@ -""" -Database-backed signal store. - -Persists InvocationSignal objects to the invocation_signals table -using the existing DatabaseManager infrastructure. -""" - -from __future__ import annotations - -from datetime import datetime - -import structlog -from sqlalchemy import func, select, update -from sqlalchemy.ext.asyncio import AsyncSession - -from dns_aid.directory.models import Agent, InvocationSignalRecord -from dns_aid.sdk.models import AgentScorecard, InvocationSignal, InvocationStatus -from dns_aid.sdk.signals.collector import _compute_scorecard - -logger = structlog.get_logger(__name__) - - -class SignalStore: - """ - Async database store for invocation signals. - - Uses the existing DatabaseManager session pattern for consistency - with the rest of the directory infrastructure. - """ - - def __init__(self, session: AsyncSession) -> None: - self.session = session - - async def save(self, signal: InvocationSignal) -> InvocationSignalRecord: - """Persist a single signal to the database.""" - record = InvocationSignalRecord( - id=str(signal.id), - agent_fqdn=signal.agent_fqdn, - agent_endpoint=signal.agent_endpoint, - protocol=signal.protocol, - method=signal.method, - timestamp=signal.timestamp, - discovery_latency_ms=signal.discovery_latency_ms, - invocation_latency_ms=signal.invocation_latency_ms, - total_latency_ms=signal.total_latency_ms, - ttfb_ms=signal.ttfb_ms, - status=signal.status.value, - error_type=signal.error_type, - error_message=signal.error_message, - http_status_code=signal.http_status_code, - cost_units=signal.cost_units, - cost_currency=signal.cost_currency, - response_size_bytes=signal.response_size_bytes, - dnssec_validated=signal.dnssec_validated, - tls_version=signal.tls_version, - caller_id=signal.caller_id, - ) - self.session.add(record) - await self.session.flush() - return record - - async def save_batch(self, signals: list[InvocationSignal]) -> int: - """Persist multiple signals in a single flush. Returns count saved.""" - for signal in signals: - record = InvocationSignalRecord( - id=str(signal.id), - agent_fqdn=signal.agent_fqdn, - agent_endpoint=signal.agent_endpoint, - protocol=signal.protocol, - method=signal.method, - timestamp=signal.timestamp, - discovery_latency_ms=signal.discovery_latency_ms, - invocation_latency_ms=signal.invocation_latency_ms, - total_latency_ms=signal.total_latency_ms, - ttfb_ms=signal.ttfb_ms, - status=signal.status.value, - error_type=signal.error_type, - error_message=signal.error_message, - http_status_code=signal.http_status_code, - cost_units=signal.cost_units, - cost_currency=signal.cost_currency, - response_size_bytes=signal.response_size_bytes, - dnssec_validated=signal.dnssec_validated, - tls_version=signal.tls_version, - caller_id=signal.caller_id, - ) - self.session.add(record) - await self.session.flush() - return len(signals) - - async def get(self, signal_id: str) -> InvocationSignalRecord | None: - """Retrieve a single signal by ID.""" - result = await self.session.execute( - select(InvocationSignalRecord).where(InvocationSignalRecord.id == signal_id) - ) - return result.scalar_one_or_none() - - async def list_signals( - self, - *, - agent_fqdn: str | None = None, - protocol: str | None = None, - status: str | None = None, - since: datetime | None = None, - until: datetime | None = None, - limit: int = 50, - offset: int = 0, - ) -> list[InvocationSignalRecord]: - """List signals with optional filters.""" - query = select(InvocationSignalRecord).order_by(InvocationSignalRecord.timestamp.desc()) - - if agent_fqdn: - query = query.where(InvocationSignalRecord.agent_fqdn == agent_fqdn) - if protocol: - query = query.where(InvocationSignalRecord.protocol == protocol) - if status: - query = query.where(InvocationSignalRecord.status == status) - if since: - query = query.where(InvocationSignalRecord.timestamp >= since) - if until: - query = query.where(InvocationSignalRecord.timestamp <= until) - - query = query.limit(limit).offset(offset) - result = await self.session.execute(query) - return list(result.scalars().all()) - - async def count_signals( - self, - *, - agent_fqdn: str | None = None, - status: str | None = None, - ) -> int: - """Count signals with optional filters.""" - query = select(func.count(InvocationSignalRecord.id)) - if agent_fqdn: - query = query.where(InvocationSignalRecord.agent_fqdn == agent_fqdn) - if status: - query = query.where(InvocationSignalRecord.status == status) - result = await self.session.execute(query) - return result.scalar_one() - - async def scorecard(self, agent_fqdn: str) -> AgentScorecard: - """Compute an aggregated scorecard for a single agent from the DB.""" - records = await self.list_signals(agent_fqdn=agent_fqdn, limit=10000) - signals = [_record_to_signal(r) for r in records] - return _compute_scorecard(agent_fqdn, signals) - - async def update_agent_scores(self, agent_fqdn: str) -> None: - """ - Roll up signal data into the agents table scores. - - Updates popularity_score and trust_score based on invocation telemetry. - """ - total = await self.count_signals(agent_fqdn=agent_fqdn) - success_count = await self.count_signals(agent_fqdn=agent_fqdn, status="success") - - # Popularity: logarithmic scale of invocation count (0-100) - import math - - popularity = min(100, int(math.log2(total + 1) * 10)) if total > 0 else 0 - - # Trust: weighted success rate (0-100) - success_rate = (success_count / total * 100) if total > 0 else 0 - trust = int(success_rate) if total >= 5 else 0 # Require minimum sample size - - await self.session.execute( - update(Agent) - .where(Agent.fqdn == agent_fqdn) - .values(popularity_score=popularity, trust_score=trust) - ) - await self.session.flush() - - logger.info( - "Updated agent scores", - agent_fqdn=agent_fqdn, - total_signals=total, - popularity_score=popularity, - trust_score=trust, - ) - - -def _ensure_utc(dt: datetime) -> datetime: - """Ensure a datetime is timezone-aware (UTC). SQLite returns naive datetimes.""" - if dt.tzinfo is None: - from datetime import UTC - - return dt.replace(tzinfo=UTC) - return dt - - -def _record_to_signal(record: InvocationSignalRecord) -> InvocationSignal: - """Convert a DB record back to an InvocationSignal for scorecard computation.""" - import uuid as _uuid - - return InvocationSignal( - id=_uuid.UUID(record.id), - timestamp=_ensure_utc(record.timestamp), - agent_fqdn=record.agent_fqdn, - agent_endpoint=record.agent_endpoint, - protocol=record.protocol, - method=record.method, - discovery_latency_ms=record.discovery_latency_ms or 0.0, - invocation_latency_ms=record.invocation_latency_ms, - total_latency_ms=record.total_latency_ms or 0.0, - ttfb_ms=record.ttfb_ms, - status=InvocationStatus(record.status), - error_type=record.error_type, - error_message=record.error_message, - http_status_code=record.http_status_code, - cost_units=record.cost_units, - cost_currency=record.cost_currency, - response_size_bytes=record.response_size_bytes, - dnssec_validated=record.dnssec_validated or False, - tls_version=record.tls_version, - caller_id=record.caller_id, - ) diff --git a/src/dns_aid/utils/url_safety.py b/src/dns_aid/utils/url_safety.py new file mode 100644 index 0000000..cb05a58 --- /dev/null +++ b/src/dns_aid/utils/url_safety.py @@ -0,0 +1,85 @@ +""" +URL safety validation for DNS-AID. + +Prevents SSRF attacks by enforcing HTTPS-only and blocking +requests to private/loopback/link-local IP addresses. +""" + +from __future__ import annotations + +import ipaddress +import os +import socket + +import structlog + +logger = structlog.get_logger(__name__) + + +class UnsafeURLError(ValueError): + """Raised when a URL fails safety validation.""" + + +def validate_fetch_url(url: str) -> str: + """ + Validate that a URL is safe to fetch. + + Enforces: + - HTTPS scheme only (no http://, file://, etc.) + - Resolved IP must not be private, loopback, or link-local + - Allows override via DNS_AID_FETCH_ALLOWLIST env var + + Args: + url: The URL to validate. + + Returns: + The validated URL (unchanged). + + Raises: + UnsafeURLError: If the URL fails validation. + """ + from urllib.parse import urlparse + + parsed = urlparse(url) + + # Enforce HTTPS + if parsed.scheme != "https": + raise UnsafeURLError(f"Only HTTPS URLs are allowed, got scheme '{parsed.scheme}': {url}") + + hostname = parsed.hostname + if not hostname: + raise UnsafeURLError(f"URL has no hostname: {url}") + + # Check allowlist + allowlist = _get_allowlist() + if allowlist and hostname in allowlist: + logger.debug("URL hostname in allowlist, skipping IP check", hostname=hostname) + return url + + # Resolve hostname and check IP addresses + try: + addrinfos = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM) + except socket.gaierror as e: + raise UnsafeURLError(f"Cannot resolve hostname '{hostname}': {e}") from e + + for _family, _type, _proto, _canonname, sockaddr in addrinfos: + ip_str = sockaddr[0] + try: + ip = ipaddress.ip_address(ip_str) + except ValueError: + continue + + if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved: + raise UnsafeURLError( + f"URL resolves to non-public IP {ip_str} (hostname '{hostname}'): {url}" + ) + + return url + + +def _get_allowlist() -> set[str]: + """Get the fetch allowlist from environment variable.""" + raw = os.environ.get("DNS_AID_FETCH_ALLOWLIST", "") + if not raw: + return set() + return {h.strip().lower() for h in raw.split(",") if h.strip()} diff --git a/src/dns_aid/utils/validation.py b/src/dns_aid/utils/validation.py index 36edde6..6c7a010 100644 --- a/src/dns_aid/utils/validation.py +++ b/src/dns_aid/utils/validation.py @@ -387,7 +387,9 @@ def validate_fqdn(fqdn: str) -> str: return fqdn -def validate_backend(backend: str) -> Literal["route53", "mock"]: +def validate_backend( + backend: str, +) -> Literal["route53", "cloudflare", "infoblox", "ddns", "mock"]: """ Validate backend type. @@ -405,10 +407,11 @@ def validate_backend(backend: str) -> Literal["route53", "mock"]: backend = backend.lower().strip() - if backend not in ("route53", "mock"): + valid_backends = ("route53", "cloudflare", "infoblox", "ddns", "mock") + if backend not in valid_backends: raise ValidationError( "backend", - "Backend must be 'route53' or 'mock'", + f"Backend must be one of: {', '.join(valid_backends)}", backend, ) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..80954b9 --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,322 @@ +""" +Fixtures for mock integration tests. + +Provides MockDNSBridge — the bridge between MockBackend's in-memory +store (publisher write path) and the dns.asyncresolver.Resolver / +httpx.AsyncClient mocks (discoverer/validator read path). +""" + +from __future__ import annotations + +import json +from contextlib import ExitStack, contextmanager +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch +from urllib.parse import urlparse + +import dns.flags +import dns.name +import dns.resolver +import httpx +import pytest + +from dns_aid.backends.mock import MockBackend + + +class MockDNSBridge: + """Bridge between MockBackend (publisher) and DNS/HTTP mocks (discoverer/validator). + + Reads what MockBackend stored and produces properly-shaped dnspython mock + objects so that discover() and verify() work against the same in-memory data + that publish() wrote to. + """ + + def __init__(self, backend: MockBackend) -> None: + self.backend = backend + self._dnssec_domains: set[str] = set() + self._tlsa_records: dict[str, dict[str, int]] = {} # "host:port" -> TLSA data + self._endpoint_responses: dict[str, dict[str, Any]] = {} # "host:port" -> response + self._cap_documents: dict[str, tuple[dict, bytes]] = {} # uri -> (json, raw_bytes) + self._http_index_data: dict[str, dict] = {} # domain -> index data + self._agent_cards: dict[str, dict] = {} # host -> card data + + # ── Configuration methods ────────────────────────────────────────── + + def enable_dnssec(self, domain: str) -> None: + """Set AD flag on DNS responses for this domain.""" + self._dnssec_domains.add(domain) + + def add_tlsa_record( + self, target: str, port: int, usage: int = 3, selector: int = 1, mtype: int = 1 + ) -> None: + """Register a mock DANE/TLSA record.""" + self._tlsa_records[f"{target}:{port}"] = { + "usage": usage, + "selector": selector, + "mtype": mtype, + } + + def set_endpoint_reachable(self, host: str, port: int = 443) -> None: + """Register an endpoint as reachable (returns HTTP 200).""" + self._endpoint_responses[f"{host}:{port}"] = {"status_code": 200} + + def set_cap_document(self, uri: str, data: dict) -> None: + """Register a capability document at the given URI.""" + raw_bytes = json.dumps(data, separators=(",", ":"), sort_keys=True).encode() + self._cap_documents[uri] = (data, raw_bytes) + + def set_http_index(self, domain: str, data: dict) -> None: + """Register HTTP index data for a domain.""" + self._http_index_data[domain] = data + + def set_agent_card(self, host: str, data: dict) -> None: + """Register an A2A agent card at /.well-known/agent.json on the host.""" + self._agent_cards[host] = data + + # ── DNS mock builders ────────────────────────────────────────────── + + def _parse_fqdn(self, fqdn: str) -> tuple[str, str] | None: + """Split FQDN into (zone, record_name) by matching against stored zones.""" + fqdn = fqdn.rstrip(".") + # Check zones that actually have records (not just defaultdict ghosts) + for zone in list(self.backend.records.keys()): + if fqdn.endswith(f".{zone}"): + record_name = fqdn[: -(len(zone) + 1)] + return zone, record_name + return None + + def _build_svcb_answer(self, zone: str, record_name: str) -> MagicMock | None: + """Build mock SVCB rdata from MockBackend's stored records.""" + records = self.backend.records.get(zone, {}).get(record_name, {}).get("SVCB", []) + if not records: + return None + + mock_rdatas = [] + for rec in records: + rdata = MagicMock() + target = rec["target"] + # svcb_target already includes trailing dot ("mcp.example.com.") + rdata.target = dns.name.from_text(target if target.endswith(".") else f"{target}.") + rdata.priority = rec["priority"] + + port = int(rec.get("params", {}).get("port", "443")) + rdata.port = port + + # Validator reads port from rdata.params[3].port (SVCB port param key) + port_param = MagicMock() + port_param.port = port + rdata.params = {3: port_param} + + # __str__() produces the presentation format that + # _parse_svcb_custom_params() splits on spaces. + params = rec.get("params", {}) + parts = [f'{k}="{v}"' for k, v in params.items()] + str_repr = f"{rec['priority']} {target}. {' '.join(parts)}" + # Use default argument capture to avoid late-binding closure bug + rdata.__str__ = lambda _self, _s=str_repr: _s + + mock_rdatas.append(rdata) + + mock_answer = MagicMock() + mock_answer.__iter__ = lambda _self, _r=mock_rdatas: iter(_r) + mock_answer.response = MagicMock() + mock_answer.response.flags = dns.flags.AD if zone in self._dnssec_domains else 0 + return mock_answer + + def _build_txt_answer(self, zone: str, record_name: str) -> MagicMock | None: + """Build mock TXT rdata from MockBackend's stored records.""" + records = self.backend.records.get(zone, {}).get(record_name, {}).get("TXT", []) + if not records: + return None + + mock_rdatas = [] + for rec in records: + rdata = MagicMock() + rdata.strings = [ + v.encode("utf-8") if isinstance(v, str) else v for v in rec.get("values", []) + ] + mock_rdatas.append(rdata) + + mock_answer = MagicMock() + mock_answer.__iter__ = lambda _self, _r=mock_rdatas: iter(_r) + mock_answer.response = MagicMock() + mock_answer.response.flags = dns.flags.AD if zone in self._dnssec_domains else 0 + return mock_answer + + def _build_tlsa_answer(self, fqdn: str) -> MagicMock | None: + """Build mock TLSA rdata for DANE checks. + + TLSA FQDN format: _{port}._tcp.{target} + """ + parts = fqdn.rstrip(".").split(".") + if len(parts) < 3: + return None + port_str = parts[0].lstrip("_") + target = ".".join(parts[2:]) + + key = f"{target}:{port_str}" + if key not in self._tlsa_records: + return None + + tlsa_data = self._tlsa_records[key] + rdata = MagicMock() + rdata.usage = tlsa_data["usage"] + rdata.selector = tlsa_data["selector"] + rdata.mtype = tlsa_data["mtype"] + + mock_answer = MagicMock() + mock_answer.__iter__ = lambda _self, _r=[rdata]: iter(_r) + return mock_answer + + def build_resolver_mock(self) -> MagicMock: + """Build a mock dns.asyncresolver.Resolver.""" + bridge = self + + async def resolve(fqdn: str, rdtype: str, **kwargs: Any) -> MagicMock: + fqdn_str = str(fqdn).rstrip(".") + rdtype_str = str(rdtype) + + # TLSA queries (DANE) + if rdtype_str == "TLSA": + answer = bridge._build_tlsa_answer(fqdn_str) + if answer: + return answer + raise dns.resolver.NXDOMAIN() + + # Parse FQDN to zone + record_name + parsed = bridge._parse_fqdn(fqdn_str) + if parsed is None: + raise dns.resolver.NXDOMAIN() + + zone, record_name = parsed + + if rdtype_str in ("SVCB", "HTTPS"): + answer = bridge._build_svcb_answer(zone, record_name) + if answer is None: + raise dns.resolver.NXDOMAIN() + return answer + + if rdtype_str == "TXT": + answer = bridge._build_txt_answer(zone, record_name) + if answer is None: + raise dns.resolver.NoAnswer() + return answer + + raise dns.resolver.NXDOMAIN() + + mock_resolver = MagicMock() + mock_resolver.resolve = AsyncMock(side_effect=resolve) + mock_resolver.use_edns = MagicMock() # DNSSEC check calls this; no-op + return mock_resolver + + # ── HTTP mock builder ────────────────────────────────────────────── + + def build_http_client_mock(self) -> AsyncMock: + """Build a mock httpx.AsyncClient that routes requests by URL.""" + bridge = self + + async def mock_get(url: str, **kwargs: Any) -> MagicMock: + parsed = urlparse(url) + host = parsed.hostname or "" + port = parsed.port or 443 + path = parsed.path + + # 1. Cap document — exact URI match (most specific) + if url in bridge._cap_documents: + data, raw_bytes = bridge._cap_documents[url] + resp = MagicMock() + resp.status_code = 200 + resp.json.return_value = data + resp.content = raw_bytes + resp.raise_for_status = MagicMock() + return resp + + # 2. Agent card — /.well-known/agent.json on known host + if path == "/.well-known/agent.json" and host in bridge._agent_cards: + resp = MagicMock() + resp.status_code = 200 + resp.json.return_value = bridge._agent_cards[host] + resp.content = json.dumps(bridge._agent_cards[host]).encode() + resp.raise_for_status = MagicMock() + return resp + + # 3. HTTP index — specific well-known paths + index_paths = { + "/index-wellknown", + "/.well-known/agents-index.json", + "/.well-known/agents.json", + } + if path in index_paths: + for domain, data in bridge._http_index_data.items(): + if domain in host: + resp = MagicMock() + resp.status_code = 200 + resp.json.return_value = data + resp.content = json.dumps(data).encode() + resp.raise_for_status = MagicMock() + return resp + + # 4. Endpoint reachability — host:port match + key = f"{host}:{port}" + if key in bridge._endpoint_responses: + resp_data = bridge._endpoint_responses[key] + resp = MagicMock() + resp.status_code = resp_data.get("status_code", 200) + resp.json.return_value = {} + resp.content = b"" + resp.raise_for_status = MagicMock() + return resp + + # 5. Default: connection error + raise httpx.ConnectError(f"Mock: no route for {url}") + + mock_client = AsyncMock() + mock_client.get = AsyncMock(side_effect=mock_get) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + return mock_client + + # ── Patch context manager ────────────────────────────────────────── + + @contextmanager + def patch_all(self): + """Patch all DNS and HTTP I/O so discover/verify use MockBackend data. + + Patches: + - dns.asyncresolver.Resolver (shared by discoverer + validator) + - httpx.AsyncClient in cap_fetcher, a2a_card, validator, http_index + - validate_fetch_url (SSRF bypass for test hostnames) + """ + resolver_mock = self.build_resolver_mock() + http_mock = self.build_http_client_mock() + + with ExitStack() as stack: + # DNS resolver — one patch covers all modules since they share + # the same dns.asyncresolver module object + stack.enter_context(patch("dns.asyncresolver.Resolver", return_value=resolver_mock)) + # HTTP clients — patch each consumer module + for mod in ( + "dns_aid.core.cap_fetcher", + "dns_aid.core.a2a_card", + "dns_aid.core.validator", + "dns_aid.core.http_index", + ): + stack.enter_context(patch(f"{mod}.httpx.AsyncClient", return_value=http_mock)) + # SSRF bypass — validate_fetch_url is lazy-imported inside + # cap_fetcher and a2a_card, so patching at the module level works + stack.enter_context( + patch( + "dns_aid.utils.url_safety.validate_fetch_url", + side_effect=lambda u: u, + ) + ) + yield + + +# ── Fixtures ─────────────────────────────────────────────────────────── + + +@pytest.fixture +def dns_bridge(mock_backend: MockBackend) -> MockDNSBridge: + """MockDNSBridge wired to the test's MockBackend.""" + return MockDNSBridge(mock_backend) diff --git a/tests/integration/test_ddns.py b/tests/integration/test_ddns.py index 2c43a96..5d37c83 100644 --- a/tests/integration/test_ddns.py +++ b/tests/integration/test_ddns.py @@ -17,10 +17,11 @@ import pytest -# Skip all tests if DDNS testing not enabled -pytestmark = pytest.mark.skipif( - not os.environ.get("DDNS_TEST_ENABLED"), reason="DDNS_TEST_ENABLED not set" -) +# Live backend tests — run with: pytest -m live +pytestmark = [ + pytest.mark.live, + pytest.mark.skipif(not os.environ.get("DDNS_TEST_ENABLED"), reason="DDNS_TEST_ENABLED not set"), +] # Test configuration matching bind/named.conf DDNS_SERVER = "127.0.0.1" diff --git a/tests/integration/test_e2e.py b/tests/integration/test_e2e.py index 339a6f6..f73e62a 100644 --- a/tests/integration/test_e2e.py +++ b/tests/integration/test_e2e.py @@ -27,10 +27,11 @@ DNS_RETRY_ATTEMPTS = 5 # Number of retry attempts DNS_RETRY_DELAY = 5 # Delay between retries in seconds -# Skip all tests if no test zone configured -pytestmark = pytest.mark.skipif( - not os.environ.get("DNS_AID_TEST_ZONE"), reason="DNS_AID_TEST_ZONE not set" -) +# Live backend tests — run with: pytest -m live +pytestmark = [ + pytest.mark.live, + pytest.mark.skipif(not os.environ.get("DNS_AID_TEST_ZONE"), reason="DNS_AID_TEST_ZONE not set"), +] @pytest.fixture diff --git a/tests/integration/test_infoblox.py b/tests/integration/test_infoblox.py index c215096..ce3a0bf 100644 --- a/tests/integration/test_infoblox.py +++ b/tests/integration/test_infoblox.py @@ -18,11 +18,14 @@ import pytest -# Skip all tests if no credentials configured -pytestmark = pytest.mark.skipif( - not os.environ.get("INFOBLOX_API_KEY") or not os.environ.get("INFOBLOX_TEST_ZONE"), - reason="INFOBLOX_API_KEY or INFOBLOX_TEST_ZONE not set", -) +# Live backend tests — run with: pytest -m live +pytestmark = [ + pytest.mark.live, + pytest.mark.skipif( + not os.environ.get("INFOBLOX_API_KEY") or not os.environ.get("INFOBLOX_TEST_ZONE"), + reason="INFOBLOX_API_KEY or INFOBLOX_TEST_ZONE not set", + ), +] @pytest.fixture diff --git a/tests/integration/test_mock_flows.py b/tests/integration/test_mock_flows.py new file mode 100644 index 0000000..5cfc9ed --- /dev/null +++ b/tests/integration/test_mock_flows.py @@ -0,0 +1,617 @@ +""" +Mock integration tests — Tier A (always runs in CI, no credentials needed). + +Exercises full publish → discover → verify flows with mocked DNS/HTTP. +The MockDNSBridge (from conftest.py) translates MockBackend's in-memory +records into dnspython and httpx mock responses. +""" + +from __future__ import annotations + +import base64 +import hashlib +import json + +import pytest + +import dns_aid +from dns_aid import DNSSECError +from dns_aid.backends.mock import MockBackend +from tests.integration.conftest import MockDNSBridge + + +# ── Scenario A: Full Lifecycle ───────────────────────────────────────── + + +class TestFullLifecycle: + """Publish → discover → verify → unpublish → verify-gone.""" + + async def test_full_publish_discover_verify_unpublish( + self, + mock_backend: MockBackend, + dns_bridge: MockDNSBridge, + ): + # 1. Publish + result = await dns_aid.publish( + name="network", + domain="example.com", + protocol="mcp", + endpoint="mcp.example.com", + capabilities=["ipam", "dns"], + backend=mock_backend, + ) + assert result.success + + # 2. Configure bridge for discover/verify + dns_bridge.set_endpoint_reachable("mcp.example.com") + + with dns_bridge.patch_all(): + # 3. Discover + discovery = await dns_aid.discover( + "example.com", + protocol="mcp", + name="network", + enrich_endpoints=False, + ) + assert discovery.count == 1 + agent = discovery.agents[0] + assert agent.name == "network" + assert agent.target_host == "mcp.example.com" + assert agent.capabilities == ["ipam", "dns"] + assert agent.capability_source == "txt_fallback" + + # 4. Verify + verify = await dns_aid.verify(agent.fqdn) + assert verify.record_exists + assert verify.svcb_valid + assert verify.endpoint_reachable + # record(20) + svcb(20) + endpoint(15) = 55 + assert verify.security_score >= 55 + + # 5. Unpublish + await dns_aid.unpublish( + name="network", + domain="example.com", + protocol="mcp", + backend=mock_backend, + ) + + # 6. Verify gone + with dns_bridge.patch_all(): + verify = await dns_aid.verify(agent.fqdn) + assert not verify.record_exists + assert verify.security_score == 0 + + +# ── Scenario B: Multi-Protocol ───────────────────────────────────────── + + +class TestMultiProtocol: + """Publish MCP + A2A agents, discover each by protocol.""" + + async def test_discover_mcp_agent( + self, + mock_backend: MockBackend, + dns_bridge: MockDNSBridge, + ): + await dns_aid.publish( + name="network", + domain="example.com", + protocol="mcp", + endpoint="mcp.example.com", + capabilities=["ipam"], + backend=mock_backend, + ) + await dns_aid.publish( + name="chat", + domain="example.com", + protocol="a2a", + endpoint="chat.example.com", + capabilities=["assistant"], + backend=mock_backend, + ) + + with dns_bridge.patch_all(): + result = await dns_aid.discover( + "example.com", + protocol="mcp", + name="network", + enrich_endpoints=False, + ) + assert result.count == 1 + assert result.agents[0].protocol.value == "mcp" + assert result.agents[0].name == "network" + + async def test_discover_a2a_agent( + self, + mock_backend: MockBackend, + dns_bridge: MockDNSBridge, + ): + await dns_aid.publish( + name="chat", + domain="example.com", + protocol="a2a", + endpoint="chat.example.com", + capabilities=["assistant"], + backend=mock_backend, + ) + + with dns_bridge.patch_all(): + result = await dns_aid.discover( + "example.com", + protocol="a2a", + name="chat", + enrich_endpoints=False, + ) + assert result.count == 1 + assert result.agents[0].protocol.value == "a2a" + assert result.agents[0].name == "chat" + + +# ── Scenario C: Capability Document ──────────────────────────────────── + + +class TestCapabilityDocumentFlow: + """Cap URI enrichment, SHA-256 match, SHA-256 mismatch → TXT fallback.""" + + @pytest.fixture + def cap_data(self) -> dict: + return { + "capabilities": ["travel", "booking", "calendar"], + "version": "2.0.0", + "description": "Travel booking agent", + } + + @pytest.fixture + def cap_uri(self) -> str: + return "https://cap.example.com/agent-cap.json" + + async def test_cap_uri_enrichment( + self, + mock_backend: MockBackend, + dns_bridge: MockDNSBridge, + cap_data: dict, + cap_uri: str, + ): + """Cap URI present → capabilities come from the document, not TXT.""" + await dns_aid.publish( + name="travel", + domain="example.com", + protocol="mcp", + endpoint="mcp.example.com", + capabilities=["fallback-cap"], + cap_uri=cap_uri, + backend=mock_backend, + ) + + dns_bridge.set_cap_document(cap_uri, cap_data) + + with dns_bridge.patch_all(): + result = await dns_aid.discover( + "example.com", + protocol="mcp", + name="travel", + enrich_endpoints=False, + ) + assert result.count == 1 + agent = result.agents[0] + assert agent.capability_source == "cap_uri" + assert "travel" in agent.capabilities + assert "booking" in agent.capabilities + + async def test_cap_sha256_match( + self, + mock_backend: MockBackend, + dns_bridge: MockDNSBridge, + cap_data: dict, + cap_uri: str, + ): + """Matching cap_sha256 → capabilities accepted.""" + # Compute the actual SHA-256 of the serialized JSON bytes + raw_bytes = json.dumps(cap_data, separators=(",", ":"), sort_keys=True).encode() + expected_sha256 = ( + base64.urlsafe_b64encode(hashlib.sha256(raw_bytes).digest()) + .rstrip(b"=") + .decode("ascii") + ) + + await dns_aid.publish( + name="travel", + domain="example.com", + protocol="mcp", + endpoint="mcp.example.com", + capabilities=["fallback-cap"], + cap_uri=cap_uri, + cap_sha256=expected_sha256, + backend=mock_backend, + ) + + dns_bridge.set_cap_document(cap_uri, cap_data) + + with dns_bridge.patch_all(): + result = await dns_aid.discover( + "example.com", + protocol="mcp", + name="travel", + enrich_endpoints=False, + ) + agent = result.agents[0] + assert agent.capability_source == "cap_uri" + assert agent.capabilities == ["travel", "booking", "calendar"] + + async def test_cap_sha256_mismatch_falls_back_to_txt( + self, + mock_backend: MockBackend, + dns_bridge: MockDNSBridge, + cap_data: dict, + cap_uri: str, + ): + """Wrong cap_sha256 → cap doc rejected → falls back to TXT capabilities.""" + await dns_aid.publish( + name="travel", + domain="example.com", + protocol="mcp", + endpoint="mcp.example.com", + capabilities=["fallback-cap"], + cap_uri=cap_uri, + cap_sha256="WRONG_HASH_VALUE", + backend=mock_backend, + ) + + dns_bridge.set_cap_document(cap_uri, cap_data) + + with dns_bridge.patch_all(): + result = await dns_aid.discover( + "example.com", + protocol="mcp", + name="travel", + enrich_endpoints=False, + ) + agent = result.agents[0] + # SHA-256 mismatch → cap doc rejected → TXT fallback + assert agent.capability_source == "txt_fallback" + assert agent.capabilities == ["fallback-cap"] + + +# ── Scenario D: HTTP Index Discovery ─────────────────────────────────── + + +class TestHttpIndexDiscovery: + """Discover via HTTP index (use_http_index=True).""" + + async def test_http_index_discovery( + self, + mock_backend: MockBackend, + dns_bridge: MockDNSBridge, + ): + # Publish the agent so DNS SVCB resolution works + await dns_aid.publish( + name="network", + domain="example.com", + protocol="mcp", + endpoint="mcp.example.com", + capabilities=["ipam", "dns"], + backend=mock_backend, + ) + + # Register HTTP index data (stakeholder JSON format) + dns_bridge.set_http_index( + "example.com", + { + "agents": { + "network": { + "location": { + "fqdn": "_network._mcp._agents.example.com", + "endpoint": "https://mcp.example.com/mcp", + }, + "model-card": { + "description": "Network management agent", + }, + "capability": { + "modality": "text", + "protocols": ["mcp"], + }, + } + } + }, + ) + + with dns_bridge.patch_all(): + result = await dns_aid.discover( + "example.com", + protocol="mcp", + use_http_index=True, + enrich_endpoints=False, + ) + assert result.count >= 1 + agent = result.agents[0] + assert agent.name == "network" + assert agent.target_host == "mcp.example.com" + + +# ── Scenario E: Security Scoring ─────────────────────────────────────── + + +class TestSecurityScoring: + """Verify security_score under different conditions.""" + + async def test_excellent_score( + self, + mock_backend: MockBackend, + dns_bridge: MockDNSBridge, + ): + """All checks pass → score 100 (Excellent).""" + await dns_aid.publish( + name="secure", + domain="example.com", + protocol="mcp", + endpoint="mcp.example.com", + capabilities=["ipam"], + backend=mock_backend, + ) + + dns_bridge.enable_dnssec("example.com") + dns_bridge.add_tlsa_record("mcp.example.com", 443) + dns_bridge.set_endpoint_reachable("mcp.example.com") + + with dns_bridge.patch_all(): + verify = await dns_aid.verify("_secure._mcp._agents.example.com") + assert verify.record_exists + assert verify.svcb_valid + assert verify.dnssec_valid + assert verify.dane_valid + assert verify.endpoint_reachable + assert verify.security_score == 100 + assert verify.security_rating == "Excellent" + + async def test_poor_score( + self, + mock_backend: MockBackend, + dns_bridge: MockDNSBridge, + ): + """Record + SVCB only, no DNSSEC/DANE/endpoint → score 40 (Poor).""" + await dns_aid.publish( + name="basic", + domain="example.com", + protocol="mcp", + endpoint="mcp.example.com", + capabilities=["ipam"], + backend=mock_backend, + ) + + with dns_bridge.patch_all(): + verify = await dns_aid.verify("_basic._mcp._agents.example.com") + assert verify.record_exists + assert verify.svcb_valid + assert not verify.dnssec_valid + assert not verify.endpoint_reachable + assert verify.security_score == 40 + assert verify.security_rating == "Poor" + + async def test_nonexistent_agent_score( + self, + mock_backend: MockBackend, + dns_bridge: MockDNSBridge, + ): + """Agent doesn't exist → score 0.""" + with dns_bridge.patch_all(): + verify = await dns_aid.verify("_ghost._mcp._agents.example.com") + assert not verify.record_exists + assert verify.security_score == 0 + + +# ── Scenario F: BANDAID Params Roundtrip ─────────────────────────────── + + +class TestBandaidParamsRoundtrip: + """Publish with BANDAID custom params → discover retrieves them.""" + + async def test_full_bandaid_params( + self, + mock_backend: MockBackend, + dns_bridge: MockDNSBridge, + ): + """All 6 BANDAID params round-trip through SVCB keyNNNNN encoding.""" + await dns_aid.publish( + name="rich", + domain="example.com", + protocol="mcp", + endpoint="mcp.example.com", + capabilities=["ipam"], + cap_uri="https://cap.example.com/rich.json", + cap_sha256="abc123", + bap=["mcp", "a2a"], + policy_uri="https://example.com/policy", + realm="demo", + backend=mock_backend, + ) + + # Cap document not set → cap_fetcher returns None → TXT fallback + with dns_bridge.patch_all(): + result = await dns_aid.discover( + "example.com", + protocol="mcp", + name="rich", + enrich_endpoints=False, + ) + agent = result.agents[0] + assert agent.cap_uri == "https://cap.example.com/rich.json" + assert agent.cap_sha256 == "abc123" + assert agent.bap == ["mcp", "a2a"] + assert agent.policy_uri == "https://example.com/policy" + assert agent.realm == "demo" + + async def test_partial_bandaid_params( + self, + mock_backend: MockBackend, + dns_bridge: MockDNSBridge, + ): + """Only cap_uri + realm → other params absent.""" + await dns_aid.publish( + name="partial", + domain="example.com", + protocol="mcp", + endpoint="mcp.example.com", + capabilities=["dns"], + cap_uri="https://cap.example.com/partial.json", + realm="staging", + backend=mock_backend, + ) + + with dns_bridge.patch_all(): + result = await dns_aid.discover( + "example.com", + protocol="mcp", + name="partial", + enrich_endpoints=False, + ) + agent = result.agents[0] + assert agent.cap_uri == "https://cap.example.com/partial.json" + assert agent.realm == "staging" + assert agent.cap_sha256 is None + assert agent.bap == [] + assert agent.policy_uri is None + + +# ── Scenario G: Unpublish Negative ───────────────────────────────────── + + +class TestUnpublishNegative: + """Unpublish makes agent undiscoverable.""" + + async def test_unpublish_makes_agent_undiscoverable( + self, + mock_backend: MockBackend, + dns_bridge: MockDNSBridge, + ): + # Publish + await dns_aid.publish( + name="temp", + domain="example.com", + protocol="mcp", + endpoint="mcp.example.com", + capabilities=["ipam"], + backend=mock_backend, + ) + + # Confirm discoverable + with dns_bridge.patch_all(): + result = await dns_aid.discover( + "example.com", + protocol="mcp", + name="temp", + enrich_endpoints=False, + ) + assert result.count == 1 + + # Unpublish + await dns_aid.unpublish( + name="temp", + domain="example.com", + protocol="mcp", + backend=mock_backend, + ) + + # Confirm undiscoverable + with dns_bridge.patch_all(): + result = await dns_aid.discover( + "example.com", + protocol="mcp", + name="temp", + enrich_endpoints=False, + ) + assert result.count == 0 + + +# ── Scenario H: DNSSEC Enforcement ──────────────────────────────────── + + +class TestDNSSECEnforcement: + """require_dnssec=True raises DNSSECError when AD flag is absent.""" + + async def test_require_dnssec_raises_when_unsigned( + self, + mock_backend: MockBackend, + dns_bridge: MockDNSBridge, + ): + """Publish without DNSSEC → discover(require_dnssec=True) → DNSSECError.""" + await dns_aid.publish( + name="unsigned", + domain="example.com", + protocol="mcp", + endpoint="mcp.example.com", + capabilities=["ipam"], + backend=mock_backend, + ) + + with dns_bridge.patch_all(): + with pytest.raises(DNSSECError): + await dns_aid.discover( + "example.com", + protocol="mcp", + name="unsigned", + require_dnssec=True, + enrich_endpoints=False, + ) + + async def test_require_dnssec_passes_when_signed( + self, + mock_backend: MockBackend, + dns_bridge: MockDNSBridge, + ): + """Publish + enable_dnssec → discover(require_dnssec=True) → succeeds.""" + await dns_aid.publish( + name="signed", + domain="example.com", + protocol="mcp", + endpoint="mcp.example.com", + capabilities=["ipam"], + backend=mock_backend, + ) + + dns_bridge.enable_dnssec("example.com") + + with dns_bridge.patch_all(): + result = await dns_aid.discover( + "example.com", + protocol="mcp", + name="signed", + require_dnssec=True, + enrich_endpoints=False, + ) + assert result.count == 1 + assert result.dnssec_validated is True + assert result.agents[0].name == "signed" + + +# ── Scenario I: DANE Certificate Matching ───────────────────────────── + + +class TestDANECertMatching: + """Default DANE behavior unchanged (TLSA existence only).""" + + async def test_dane_advisory_default( + self, + mock_backend: MockBackend, + dns_bridge: MockDNSBridge, + ): + """Default verify() → dane_valid=True when TLSA exists (no cert matching).""" + await dns_aid.publish( + name="dane-test", + domain="example.com", + protocol="mcp", + endpoint="mcp.example.com", + capabilities=["ipam"], + backend=mock_backend, + ) + + dns_bridge.add_tlsa_record("mcp.example.com", 443) + dns_bridge.set_endpoint_reachable("mcp.example.com") + + with dns_bridge.patch_all(): + verify = await dns_aid.verify("_dane-test._mcp._agents.example.com") + assert verify.record_exists + assert verify.svcb_valid + assert verify.dane_valid is True + # Confirm default note mentions existence-only + assert "existence" in verify.dane_note.lower() diff --git a/tests/integration/test_route53.py b/tests/integration/test_route53.py index 11d0e2e..50af52d 100644 --- a/tests/integration/test_route53.py +++ b/tests/integration/test_route53.py @@ -14,10 +14,11 @@ import pytest -# Skip all tests if no test zone configured -pytestmark = pytest.mark.skipif( - not os.environ.get("DNS_AID_TEST_ZONE"), reason="DNS_AID_TEST_ZONE not set" -) +# Live backend tests — run with: pytest -m live +pytestmark = [ + pytest.mark.live, + pytest.mark.skipif(not os.environ.get("DNS_AID_TEST_ZONE"), reason="DNS_AID_TEST_ZONE not set"), +] @pytest.fixture diff --git a/tests/unit/core/__init__.py b/tests/unit/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/core/test_agent_metadata.py b/tests/unit/core/test_agent_metadata.py new file mode 100644 index 0000000..775a5e2 --- /dev/null +++ b/tests/unit/core/test_agent_metadata.py @@ -0,0 +1,209 @@ +"""Tests for the Agent Metadata Contract (Phase 5.5).""" + +from datetime import datetime, timezone + +from dns_aid.core.agent_metadata import ( + AgentIdentity, + AgentMetadata, + AuthSpec, + AuthType, + ConnectionSpec, + MetadataContact, + TransportType, +) +from dns_aid.core.capability_model import Action, ActionIntent, CapabilitySpec + + +class TestTransportType: + """Test TransportType enum.""" + + def test_values(self): + assert TransportType.streamable_http == "streamable-http" + assert TransportType.https == "https" + assert TransportType.ws == "ws" + assert TransportType.stdio == "stdio" + assert TransportType.sse == "sse" + + def test_all_members(self): + assert len(TransportType) == 5 + + +class TestAuthType: + """Test AuthType enum.""" + + def test_values(self): + assert AuthType.none == "none" + assert AuthType.api_key == "api_key" + assert AuthType.bearer == "bearer" + assert AuthType.oauth2 == "oauth2" + assert AuthType.mtls == "mtls" + assert AuthType.http_msg_sig == "http_msg_sig" + + def test_all_members(self): + assert len(AuthType) == 6 + + +class TestAgentIdentity: + """Test AgentIdentity model.""" + + def test_minimal(self): + identity = AgentIdentity(name="chat-agent") + assert identity.name == "chat-agent" + assert identity.agent_id is None + assert identity.fqdn is None + assert identity.version is None + assert identity.deprecated is False + assert identity.sunset_date is None + assert identity.successor is None + + def test_full(self): + sunset = datetime(2025, 12, 31, tzinfo=timezone.utc) + identity = AgentIdentity( + agent_id="abc-123", + name="legacy-agent", + fqdn="_legacy._mcp._agents.example.com", + version="2.0.0", + deprecated=True, + sunset_date=sunset, + successor="_new._mcp._agents.example.com", + ) + assert identity.deprecated is True + assert identity.sunset_date == sunset + assert identity.successor == "_new._mcp._agents.example.com" + + +class TestConnectionSpec: + """Test ConnectionSpec model.""" + + def test_minimal(self): + conn = ConnectionSpec(protocol="mcp", endpoint="https://mcp.example.com") + assert conn.protocol == "mcp" + assert conn.transport == TransportType.https + assert conn.endpoint == "https://mcp.example.com" + assert conn.base_url is None + + def test_with_transport(self): + conn = ConnectionSpec( + protocol="mcp", + transport=TransportType.streamable_http, + endpoint="https://mcp.example.com/mcp", + base_url="https://mcp.example.com", + ) + assert conn.transport == TransportType.streamable_http + assert conn.base_url == "https://mcp.example.com" + + +class TestAuthSpec: + """Test AuthSpec model.""" + + def test_defaults(self): + auth = AuthSpec() + assert auth.type == AuthType.none + assert auth.location is None + assert auth.header_name is None + assert auth.oauth_discovery is None + + def test_api_key(self): + auth = AuthSpec( + type=AuthType.api_key, + location="header", + header_name="X-API-Key", + ) + assert auth.type == AuthType.api_key + assert auth.header_name == "X-API-Key" + + def test_oauth2(self): + auth = AuthSpec( + type=AuthType.oauth2, + oauth_discovery="https://auth.example.com/.well-known/openid-configuration", + ) + assert auth.type == AuthType.oauth2 + assert "openid-configuration" in auth.oauth_discovery + + def test_http_msg_sig(self): + auth = AuthSpec( + type=AuthType.http_msg_sig, + key_directory_url="https://example.com/.well-known/jwks.json", + signature_agent_card_url="https://example.com/.well-known/agent-card.json", + supported_algorithms=["ed25519"], + ) + assert auth.type == AuthType.http_msg_sig + assert auth.supported_algorithms == ["ed25519"] + + +class TestMetadataContact: + """Test MetadataContact model.""" + + def test_defaults(self): + contact = MetadataContact() + assert contact.owner is None + assert contact.contact is None + assert contact.documentation is None + + def test_full(self): + contact = MetadataContact( + owner="Acme Corp", + contact="agents@acme.com", + documentation="https://docs.acme.com/agents", + ) + assert contact.owner == "Acme Corp" + + +class TestAgentMetadata: + """Test top-level AgentMetadata model.""" + + def test_minimal(self): + metadata = AgentMetadata( + identity=AgentIdentity(name="test-agent"), + connection=ConnectionSpec( + protocol="mcp", + endpoint="https://mcp.example.com", + ), + ) + assert metadata.aid_version == "1.0" + assert metadata.identity.name == "test-agent" + assert metadata.auth.type == AuthType.none + assert metadata.capabilities.actions == [] + assert metadata.contact.owner is None + + def test_full(self): + metadata = AgentMetadata( + aid_version="1.0", + identity=AgentIdentity( + name="network-specialist", + fqdn="_network._mcp._agents.example.com", + version="3.0.0", + ), + connection=ConnectionSpec( + protocol="mcp", + transport=TransportType.streamable_http, + endpoint="https://mcp.example.com/mcp", + ), + auth=AuthSpec(type=AuthType.bearer, location="header"), + capabilities=CapabilitySpec( + supports_streaming=True, + actions=[ + Action(name="lookup-dns", intent=ActionIntent.query), + ], + ), + contact=MetadataContact(owner="Network Team"), + ) + assert metadata.identity.version == "3.0.0" + assert metadata.connection.transport == TransportType.streamable_http + assert metadata.auth.type == AuthType.bearer + assert len(metadata.capabilities.actions) == 1 + assert metadata.contact.owner == "Network Team" + + def test_serialization_roundtrip(self): + metadata = AgentMetadata( + identity=AgentIdentity(name="roundtrip"), + connection=ConnectionSpec( + protocol="a2a", + endpoint="https://a2a.example.com", + ), + ) + data = metadata.model_dump() + restored = AgentMetadata.model_validate(data) + assert restored.identity.name == "roundtrip" + assert restored.connection.protocol == "a2a" + assert restored.aid_version == "1.0" diff --git a/tests/unit/core/test_capability_model.py b/tests/unit/core/test_capability_model.py new file mode 100644 index 0000000..1c52565 --- /dev/null +++ b/tests/unit/core/test_capability_model.py @@ -0,0 +1,110 @@ +"""Tests for the capability model (Phase 5.5).""" + +from dns_aid.core.capability_model import ( + Action, + ActionIntent, + ActionSemantics, + CapabilitySpec, +) + + +class TestActionIntent: + """Test ActionIntent enum.""" + + def test_values(self): + assert ActionIntent.query == "query" + assert ActionIntent.command == "command" + assert ActionIntent.transaction == "transaction" + assert ActionIntent.subscription == "subscription" + + def test_all_members(self): + assert len(ActionIntent) == 4 + + +class TestActionSemantics: + """Test ActionSemantics enum.""" + + def test_values(self): + assert ActionSemantics.read == "read" + assert ActionSemantics.write == "write" + assert ActionSemantics.idempotent == "idempotent" + + def test_all_members(self): + assert len(ActionSemantics) == 3 + + +class TestAction: + """Test Action model.""" + + def test_minimal_action(self): + action = Action(name="get-data") + assert action.name == "get-data" + assert action.intent == ActionIntent.query + assert action.semantics == ActionSemantics.read + assert action.tags == [] + assert action.description is None + + def test_full_action(self): + action = Action( + name="create-order", + description="Creates a new order in the system", + intent=ActionIntent.command, + semantics=ActionSemantics.write, + tags=["billing", "orders"], + ) + assert action.name == "create-order" + assert action.description == "Creates a new order in the system" + assert action.intent == ActionIntent.command + assert action.semantics == ActionSemantics.write + assert action.tags == ["billing", "orders"] + + def test_name_validation_empty(self): + import pytest + + with pytest.raises(Exception): + Action(name="") + + def test_serialization(self): + action = Action(name="lookup", intent=ActionIntent.query, tags=["dns"]) + data = action.model_dump() + assert data["name"] == "lookup" + assert data["intent"] == "query" + assert data["tags"] == ["dns"] + + +class TestCapabilitySpec: + """Test CapabilitySpec model.""" + + def test_defaults(self): + spec = CapabilitySpec() + assert spec.schema_discovery is None + assert spec.supports_streaming is False + assert spec.actions == [] + + def test_with_actions(self): + spec = CapabilitySpec( + schema_discovery="https://example.com/.well-known/agent-cap.json", + supports_streaming=True, + actions=[ + Action(name="query-dns", intent=ActionIntent.query), + Action( + name="update-record", + intent=ActionIntent.command, + semantics=ActionSemantics.idempotent, + ), + ], + ) + assert spec.schema_discovery == "https://example.com/.well-known/agent-cap.json" + assert spec.supports_streaming is True + assert len(spec.actions) == 2 + assert spec.actions[0].name == "query-dns" + assert spec.actions[1].semantics == ActionSemantics.idempotent + + def test_serialization_roundtrip(self): + spec = CapabilitySpec( + actions=[Action(name="test", tags=["a", "b"])], + ) + data = spec.model_dump() + restored = CapabilitySpec.model_validate(data) + assert restored.actions[0].name == "test" + assert restored.actions[0].tags == ["a", "b"] diff --git a/tests/unit/test_a2a_card.py b/tests/unit/test_a2a_card.py index 1107241..6723543 100644 --- a/tests/unit/test_a2a_card.py +++ b/tests/unit/test_a2a_card.py @@ -179,19 +179,20 @@ async def test_fetch_success(self) -> None: "skills": [{"id": "ping", "name": "Ping"}], } - with patch("dns_aid.core.a2a_card.httpx.AsyncClient") as mock_client: - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = mock_card_data + with patch("dns_aid.utils.url_safety.validate_fetch_url", side_effect=lambda u: u): + with patch("dns_aid.core.a2a_card.httpx.AsyncClient") as mock_client: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = mock_card_data - mock_instance = AsyncMock() - mock_instance.get.return_value = mock_response - mock_instance.__aenter__.return_value = mock_instance - mock_instance.__aexit__.return_value = None + mock_instance = AsyncMock() + mock_instance.get.return_value = mock_response + mock_instance.__aenter__.return_value = mock_instance + mock_instance.__aexit__.return_value = None - mock_client.return_value = mock_instance + mock_client.return_value = mock_instance - card = await fetch_agent_card("https://agent.example.com") + card = await fetch_agent_card("https://agent.example.com") assert card is not None assert card.name == "Test Agent" @@ -201,7 +202,10 @@ async def test_fetch_success(self) -> None: @pytest.mark.asyncio async def test_fetch_adds_https(self) -> None: """Test that https:// is added if missing.""" - with patch("dns_aid.core.a2a_card.httpx.AsyncClient") as mock_client: + with ( + patch("dns_aid.utils.url_safety.validate_fetch_url", side_effect=lambda u: u), + patch("dns_aid.core.a2a_card.httpx.AsyncClient") as mock_client, + ): mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = {"name": "Test", "url": "https://x.com"} @@ -222,7 +226,10 @@ async def test_fetch_adds_https(self) -> None: @pytest.mark.asyncio async def test_fetch_404(self) -> None: """Test fetch returns None on 404.""" - with patch("dns_aid.core.a2a_card.httpx.AsyncClient") as mock_client: + with ( + patch("dns_aid.utils.url_safety.validate_fetch_url", side_effect=lambda u: u), + patch("dns_aid.core.a2a_card.httpx.AsyncClient") as mock_client, + ): mock_response = MagicMock() mock_response.status_code = 404 @@ -242,7 +249,10 @@ async def test_fetch_timeout(self) -> None: """Test fetch returns None on timeout.""" import httpx - with patch("dns_aid.core.a2a_card.httpx.AsyncClient") as mock_client: + with ( + patch("dns_aid.utils.url_safety.validate_fetch_url", side_effect=lambda u: u), + patch("dns_aid.core.a2a_card.httpx.AsyncClient") as mock_client, + ): mock_instance = AsyncMock() mock_instance.get.side_effect = httpx.TimeoutException("timeout") mock_instance.__aenter__.return_value = mock_instance @@ -259,7 +269,10 @@ async def test_fetch_connect_error(self) -> None: """Test fetch returns None on connection error.""" import httpx - with patch("dns_aid.core.a2a_card.httpx.AsyncClient") as mock_client: + with ( + patch("dns_aid.utils.url_safety.validate_fetch_url", side_effect=lambda u: u), + patch("dns_aid.core.a2a_card.httpx.AsyncClient") as mock_client, + ): mock_instance = AsyncMock() mock_instance.get.side_effect = httpx.ConnectError("failed") mock_instance.__aenter__.return_value = mock_instance @@ -274,7 +287,10 @@ async def test_fetch_connect_error(self) -> None: @pytest.mark.asyncio async def test_fetch_invalid_json(self) -> None: """Test fetch returns None on invalid JSON.""" - with patch("dns_aid.core.a2a_card.httpx.AsyncClient") as mock_client: + with ( + patch("dns_aid.utils.url_safety.validate_fetch_url", side_effect=lambda u: u), + patch("dns_aid.core.a2a_card.httpx.AsyncClient") as mock_client, + ): mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = "not an object" @@ -297,7 +313,10 @@ class TestFetchAgentCardFromDomain: @pytest.mark.asyncio async def test_constructs_url_correctly(self) -> None: """Test that domain is converted to full URL.""" - with patch("dns_aid.core.a2a_card.httpx.AsyncClient") as mock_client: + with ( + patch("dns_aid.utils.url_safety.validate_fetch_url", side_effect=lambda u: u), + patch("dns_aid.core.a2a_card.httpx.AsyncClient") as mock_client, + ): mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = {"name": "Test", "url": "https://x.com"} diff --git a/tests/unit/test_cap_fetcher.py b/tests/unit/test_cap_fetcher.py index e2b183e..c113869 100644 --- a/tests/unit/test_cap_fetcher.py +++ b/tests/unit/test_cap_fetcher.py @@ -56,7 +56,10 @@ async def test_successful_fetch(self): mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) - with patch("dns_aid.core.cap_fetcher.httpx.AsyncClient", return_value=mock_client): + with ( + patch("dns_aid.utils.url_safety.validate_fetch_url", side_effect=lambda u: u), + patch("dns_aid.core.cap_fetcher.httpx.AsyncClient", return_value=mock_client), + ): doc = await fetch_cap_document("https://example.com/.well-known/agent-cap.json") assert doc is not None @@ -78,7 +81,10 @@ async def test_returns_none_on_404(self): mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) - with patch("dns_aid.core.cap_fetcher.httpx.AsyncClient", return_value=mock_client): + with ( + patch("dns_aid.utils.url_safety.validate_fetch_url", side_effect=lambda u: u), + patch("dns_aid.core.cap_fetcher.httpx.AsyncClient", return_value=mock_client), + ): doc = await fetch_cap_document("https://example.com/.well-known/agent-cap.json") assert doc is None @@ -94,7 +100,10 @@ async def test_returns_none_on_500(self): mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) - with patch("dns_aid.core.cap_fetcher.httpx.AsyncClient", return_value=mock_client): + with ( + patch("dns_aid.utils.url_safety.validate_fetch_url", side_effect=lambda u: u), + patch("dns_aid.core.cap_fetcher.httpx.AsyncClient", return_value=mock_client), + ): doc = await fetch_cap_document("https://example.com/.well-known/agent-cap.json") assert doc is None @@ -107,7 +116,10 @@ async def test_returns_none_on_timeout(self): mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) - with patch("dns_aid.core.cap_fetcher.httpx.AsyncClient", return_value=mock_client): + with ( + patch("dns_aid.utils.url_safety.validate_fetch_url", side_effect=lambda u: u), + patch("dns_aid.core.cap_fetcher.httpx.AsyncClient", return_value=mock_client), + ): doc = await fetch_cap_document("https://example.com/.well-known/agent-cap.json") assert doc is None @@ -120,7 +132,10 @@ async def test_returns_none_on_connect_error(self): mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) - with patch("dns_aid.core.cap_fetcher.httpx.AsyncClient", return_value=mock_client): + with ( + patch("dns_aid.utils.url_safety.validate_fetch_url", side_effect=lambda u: u), + patch("dns_aid.core.cap_fetcher.httpx.AsyncClient", return_value=mock_client), + ): doc = await fetch_cap_document("https://unreachable.example.com/cap.json") assert doc is None @@ -137,7 +152,10 @@ async def test_returns_none_on_invalid_json(self): mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) - with patch("dns_aid.core.cap_fetcher.httpx.AsyncClient", return_value=mock_client): + with ( + patch("dns_aid.utils.url_safety.validate_fetch_url", side_effect=lambda u: u), + patch("dns_aid.core.cap_fetcher.httpx.AsyncClient", return_value=mock_client), + ): doc = await fetch_cap_document("https://example.com/bad.json") assert doc is None @@ -154,7 +172,10 @@ async def test_returns_none_on_non_dict_json(self): mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) - with patch("dns_aid.core.cap_fetcher.httpx.AsyncClient", return_value=mock_client): + with ( + patch("dns_aid.utils.url_safety.validate_fetch_url", side_effect=lambda u: u), + patch("dns_aid.core.cap_fetcher.httpx.AsyncClient", return_value=mock_client), + ): doc = await fetch_cap_document("https://example.com/array.json") assert doc is None @@ -174,7 +195,10 @@ async def test_empty_capabilities_list(self): mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) - with patch("dns_aid.core.cap_fetcher.httpx.AsyncClient", return_value=mock_client): + with ( + patch("dns_aid.utils.url_safety.validate_fetch_url", side_effect=lambda u: u), + patch("dns_aid.core.cap_fetcher.httpx.AsyncClient", return_value=mock_client), + ): doc = await fetch_cap_document("https://example.com/cap.json") assert doc is not None @@ -196,7 +220,10 @@ async def test_missing_capabilities_field(self): mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) - with patch("dns_aid.core.cap_fetcher.httpx.AsyncClient", return_value=mock_client): + with ( + patch("dns_aid.utils.url_safety.validate_fetch_url", side_effect=lambda u: u), + patch("dns_aid.core.cap_fetcher.httpx.AsyncClient", return_value=mock_client), + ): doc = await fetch_cap_document("https://example.com/cap.json") assert doc is not None @@ -224,7 +251,10 @@ async def test_extra_metadata_preserved(self): mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) - with patch("dns_aid.core.cap_fetcher.httpx.AsyncClient", return_value=mock_client): + with ( + patch("dns_aid.utils.url_safety.validate_fetch_url", side_effect=lambda u: u), + patch("dns_aid.core.cap_fetcher.httpx.AsyncClient", return_value=mock_client), + ): doc = await fetch_cap_document("https://example.com/cap.json") assert doc is not None diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 1a9f573..29c0800 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -1,5 +1,6 @@ """Unit tests for CLI commands.""" +import re from unittest.mock import patch from typer.testing import CliRunner @@ -8,6 +9,13 @@ runner = CliRunner() +_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") + + +def _strip_ansi(text: str) -> str: + """Strip ANSI escape codes from Rich/Typer output for reliable assertions.""" + return _ANSI_RE.sub("", text) + class TestVersion: """Test version display.""" @@ -174,6 +182,26 @@ def test_quiet_flag(self): assert "dns-aid version" in result.output +class TestPublishOptions: + """Test publish command options including --transport and --auth-type.""" + + @patch("dns_aid.cli.main.run_async") + def test_publish_help_shows_transport(self, mock_run_async): + result = runner.invoke(app, ["publish", "--help"]) + assert result.exit_code == 0 + plain = _strip_ansi(result.output) + assert "--transport" in plain + assert "streamable-http" in plain + + @patch("dns_aid.cli.main.run_async") + def test_publish_help_shows_auth_type(self, mock_run_async): + result = runner.invoke(app, ["publish", "--help"]) + assert result.exit_code == 0 + plain = _strip_ansi(result.output) + assert "--auth-type" in plain + assert "api_key" in plain + + class TestRunAsync: """Test run_async helper.""" diff --git a/tests/unit/test_discoverer.py b/tests/unit/test_discoverer.py index 3a5198c..1c8da5c 100644 --- a/tests/unit/test_discoverer.py +++ b/tests/unit/test_discoverer.py @@ -604,7 +604,10 @@ async def test_discovery_uses_cap_uri_when_present(self): assert agent.cap_uri == "https://mcp.example.com/.well-known/agent-cap.json" assert agent.cap_sha256 == "dGVzdGhhc2g" assert agent.realm == "demo" - mock_fetch.assert_called_once_with("https://mcp.example.com/.well-known/agent-cap.json") + mock_fetch.assert_called_once_with( + "https://mcp.example.com/.well-known/agent-cap.json", + expected_sha256="dGVzdGhhc2g", + ) @pytest.mark.asyncio async def test_discovery_falls_back_to_txt_when_no_cap(self): diff --git a/tests/unit/test_jws.py b/tests/unit/test_jws.py index e07f2a5..cec4e6a 100644 --- a/tests/unit/test_jws.py +++ b/tests/unit/test_jws.py @@ -479,8 +479,9 @@ def test_agent_record_sig_in_svcb_params(self): ) params = agent.to_svcb_params() - assert "sig" in params - assert params["sig"].startswith("eyJ") + # Default: keyNNNNN format (key65006 = sig) + assert "key65006" in params + assert params["key65006"].startswith("eyJ") def test_agent_record_no_sig_when_none(self): """Test that sig is not in SVCB params when None.""" diff --git a/tests/unit/test_mcp_server.py b/tests/unit/test_mcp_server.py index 77b1c76..6af7c15 100644 --- a/tests/unit/test_mcp_server.py +++ b/tests/unit/test_mcp_server.py @@ -236,3 +236,73 @@ def test_delete_nonexistent(self): assert result["success"] is False assert "No records found" in result["message"] + + +class TestBuildAgentRecordFromEndpoint: + """Test _build_agent_record_from_endpoint helper.""" + + def test_simple_url(self): + from dns_aid.mcp.server import _build_agent_record_from_endpoint + + agent = _build_agent_record_from_endpoint("https://booking.example.com:443") + assert agent.target_host == "booking.example.com" + assert agent.port == 443 + assert agent.domain == "example.com" + + def test_url_with_path(self): + from dns_aid.mcp.server import _build_agent_record_from_endpoint + + agent = _build_agent_record_from_endpoint("https://mcp.example.com/mcp") + assert agent.target_host == "mcp.example.com" + # endpoint_override should preserve the path + assert agent.endpoint_override == "https://mcp.example.com/mcp" + + def test_protocol_mapping(self): + from dns_aid.core.models import Protocol + from dns_aid.mcp.server import _build_agent_record_from_endpoint + + mcp_agent = _build_agent_record_from_endpoint("https://host.com", protocol="mcp") + assert mcp_agent.protocol == Protocol.MCP + + a2a_agent = _build_agent_record_from_endpoint("https://host.com", protocol="a2a") + assert a2a_agent.protocol == Protocol.A2A + + def test_default_port(self): + from dns_aid.mcp.server import _build_agent_record_from_endpoint + + agent = _build_agent_record_from_endpoint("https://example.com") + assert agent.port == 443 + + def test_name_derivation_skips_common_prefixes(self): + from dns_aid.mcp.server import _build_agent_record_from_endpoint + + # "mcp" prefix should be replaced with "agent" + agent = _build_agent_record_from_endpoint("https://mcp.example.com") + assert agent.name == "agent" + + # Non-common prefix should be used as the name + agent2 = _build_agent_record_from_endpoint("https://booking.example.com") + assert agent2.name == "booking" + + +class TestSDKAvailabilityFlag: + """Test that _sdk_available flag is set.""" + + def test_sdk_flag_is_boolean(self): + from dns_aid.mcp.server import _sdk_available + + assert isinstance(_sdk_available, bool) + + def test_call_agent_tool_registered(self): + """Test call_agent_tool is registered as an MCP tool.""" + from dns_aid.mcp.server import mcp + + tools = list(mcp._tool_manager._tools.keys()) + assert "call_agent_tool" in tools + + def test_list_agent_tools_registered(self): + """Test list_agent_tools is registered as an MCP tool.""" + from dns_aid.mcp.server import mcp + + tools = list(mcp._tool_manager._tools.keys()) + assert "list_agent_tools" in tools diff --git a/tests/unit/test_mock_backend.py b/tests/unit/test_mock_backend.py index d4d4540..d87c2ab 100644 --- a/tests/unit/test_mock_backend.py +++ b/tests/unit/test_mock_backend.py @@ -152,6 +152,50 @@ def test_clear(self, mock_backend: MockBackend): assert len(mock_backend.records) == 0 + @pytest.mark.asyncio + async def test_get_record_svcb(self, mock_backend: MockBackend): + """Test get_record() for SVCB records.""" + await mock_backend.create_svcb_record( + zone="example.com", + name="_chat._a2a._agents", + priority=1, + target="chat.example.com.", + params={"alpn": "a2a", "port": "443"}, + ttl=3600, + ) + + result = await mock_backend.get_record("example.com", "_chat._a2a._agents", "SVCB") + + assert result is not None + assert result["name"] == "_chat._a2a._agents" + assert result["fqdn"] == "_chat._a2a._agents.example.com" + assert result["type"] == "SVCB" + assert result["ttl"] == 3600 + # SVCB values formatted as "priority target params..." + assert len(result["values"]) == 1 + assert "chat.example.com." in result["values"][0] + + @pytest.mark.asyncio + async def test_get_record_txt(self, mock_backend: MockBackend): + """Test get_record() for TXT records.""" + await mock_backend.create_txt_record( + zone="example.com", + name="_chat._a2a._agents", + values=["capabilities=chat,assistant", "version=1.0.0"], + ) + + result = await mock_backend.get_record("example.com", "_chat._a2a._agents", "TXT") + + assert result is not None + assert result["type"] == "TXT" + assert "capabilities=chat,assistant" in result["values"] + + @pytest.mark.asyncio + async def test_get_record_not_found(self, mock_backend: MockBackend): + """Test get_record() returns None for missing records.""" + result = await mock_backend.get_record("example.com", "_nope._mcp._agents", "SVCB") + assert result is None + @pytest.mark.asyncio async def test_publish_agent_helper(self, mock_backend: MockBackend, sample_agent): """Test publish_agent convenience method.""" diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index 92567e7..0075dc3 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -24,6 +24,37 @@ def test_create_basic_agent(self): assert agent.target_host == "chat.example.com" assert agent.port == 443 # default + def test_endpoint_source_directory(self): + """Test endpoint_source accepts 'directory' (Phase 5.7).""" + agent = AgentRecord( + name="search", + domain="example.com", + protocol=Protocol.MCP, + target_host="mcp.example.com", + endpoint_source="directory", + ) + assert agent.endpoint_source == "directory" + + def test_endpoint_source_all_values(self): + """Test all endpoint_source Literal values are accepted.""" + valid_sources = [ + "dns_svcb", + "dns_svcb_enriched", + "http_index", + "http_index_fallback", + "direct", + "directory", + ] + for source in valid_sources: + agent = AgentRecord( + name="test", + domain="example.com", + protocol=Protocol.MCP, + target_host="mcp.example.com", + endpoint_source=source, + ) + assert agent.endpoint_source == source + def test_fqdn_generation(self): """Test FQDN is generated correctly per DNS-AID spec.""" agent = AgentRecord( @@ -78,8 +109,8 @@ def test_svcb_params(self): # BANDAID compliance: mandatory param must be set assert params["mandatory"] == "alpn,port" - def test_svcb_params_with_bandaid_custom_params(self): - """Test SVCB params include BANDAID custom params when set.""" + def test_svcb_params_with_bandaid_custom_params_keynnnnn(self): + """Test SVCB params emit keyNNNNN format by default.""" agent = AgentRecord( name="booking", domain="example.com", @@ -94,14 +125,41 @@ def test_svcb_params_with_bandaid_custom_params(self): params = agent.to_svcb_params() + # Default: keyNNNNN format per RFC 9460 + assert params["key65001"] == "https://mcp.example.com/.well-known/agent-cap.json" + assert params["key65002"] == "abc123base64url" + assert params["key65003"] == "mcp/1,a2a/1" + assert params["key65004"] == "https://example.com/agent-policy" + assert params["key65005"] == "production" + # Standard params still present + assert params["alpn"] == "mcp" + assert params["port"] == "443" + + def test_svcb_params_with_bandaid_custom_params_string_keys(self): + """Test SVCB params emit string names when DNS_AID_SVCB_STRING_KEYS=1.""" + import os + from unittest.mock import patch + + agent = AgentRecord( + name="booking", + domain="example.com", + protocol=Protocol.MCP, + target_host="mcp.example.com", + cap_uri="https://mcp.example.com/.well-known/agent-cap.json", + cap_sha256="abc123base64url", + bap=["mcp/1", "a2a/1"], + policy_uri="https://example.com/agent-policy", + realm="production", + ) + + with patch.dict(os.environ, {"DNS_AID_SVCB_STRING_KEYS": "1"}): + params = agent.to_svcb_params() + assert params["cap"] == "https://mcp.example.com/.well-known/agent-cap.json" assert params["cap-sha256"] == "abc123base64url" assert params["bap"] == "mcp/1,a2a/1" assert params["policy"] == "https://example.com/agent-policy" assert params["realm"] == "production" - # Standard params still present - assert params["alpn"] == "mcp" - assert params["port"] == "443" def test_svcb_params_without_bandaid_params(self): """Test SVCB params exclude BANDAID custom params when None/empty.""" @@ -137,11 +195,12 @@ def test_svcb_params_partial_bandaid_params(self): params = agent.to_svcb_params() - assert params["cap"] == "https://mcp.example.com/.well-known/agent-cap.json" - assert params["realm"] == "demo" - assert "cap-sha256" not in params - assert "bap" not in params - assert "policy" not in params + # Default: keyNNNNN format + assert params["key65001"] == "https://mcp.example.com/.well-known/agent-cap.json" + assert params["key65005"] == "demo" + assert "key65002" not in params + assert "key65003" not in params + assert "key65004" not in params def test_svcb_params_cap_sha256_without_cap_uri(self): """Test cap-sha256 can be set independently (unlikely but valid).""" @@ -155,8 +214,8 @@ def test_svcb_params_cap_sha256_without_cap_uri(self): params = agent.to_svcb_params() - assert params["cap-sha256"] == "dGVzdGhhc2g" - assert "cap" not in params + assert params["key65002"] == "dGVzdGhhc2g" + assert "key65001" not in params def test_txt_values(self): """Test TXT record values generation.""" diff --git a/tests/unit/test_publisher.py b/tests/unit/test_publisher.py index 0a2d796..67a100a 100644 --- a/tests/unit/test_publisher.py +++ b/tests/unit/test_publisher.py @@ -141,11 +141,12 @@ async def test_publish_with_cap_uri(self, mock_backend: MockBackend): # SVCB params should include custom BANDAID params svcb = mock_backend.get_svcb_record("example.com", "_booking._mcp._agents") assert svcb is not None - assert svcb["params"]["cap"] == "https://mcp.example.com/.well-known/agent-cap.json" - assert svcb["params"]["cap-sha256"] == "dGVzdGhhc2g" - assert svcb["params"]["bap"] == "mcp/1,a2a/1" - assert svcb["params"]["policy"] == "https://example.com/agent-policy" - assert svcb["params"]["realm"] == "production" + # keyNNNNN format by default (RFC 9460 compliant) + assert svcb["params"]["key65001"] == "https://mcp.example.com/.well-known/agent-cap.json" + assert svcb["params"]["key65002"] == "dGVzdGhhc2g" + assert svcb["params"]["key65003"] == "mcp/1,a2a/1" + assert svcb["params"]["key65004"] == "https://example.com/agent-policy" + assert svcb["params"]["key65005"] == "production" @pytest.mark.asyncio async def test_publish_without_cap_uri_unchanged(self, mock_backend: MockBackend): @@ -189,10 +190,10 @@ async def test_publish_with_partial_bandaid_params(self, mock_backend: MockBacke assert result.success is True svcb = mock_backend.get_svcb_record("example.com", "_booking._mcp._agents") assert svcb is not None - assert svcb["params"]["cap"] == "https://mcp.example.com/.well-known/agent-cap.json" - assert svcb["params"]["realm"] == "demo" - assert "bap" not in svcb["params"] - assert "policy" not in svcb["params"] + assert svcb["params"]["key65001"] == "https://mcp.example.com/.well-known/agent-cap.json" + assert svcb["params"]["key65005"] == "demo" + assert "key65003" not in svcb["params"] + assert "key65004" not in svcb["params"] class TestUnpublish: diff --git a/tests/unit/test_url_safety.py b/tests/unit/test_url_safety.py new file mode 100644 index 0000000..5aa83b2 --- /dev/null +++ b/tests/unit/test_url_safety.py @@ -0,0 +1,192 @@ +"""Tests for URL safety validation (SSRF protection).""" + +from __future__ import annotations + +import os +from unittest.mock import patch + +import pytest + +from dns_aid.utils.url_safety import UnsafeURLError, validate_fetch_url + + +class TestValidateFetchUrl: + """Tests for validate_fetch_url().""" + + def test_https_url_passes(self): + """HTTPS URLs with public hosts should pass.""" + # Use a well-known public DNS name + url = "https://example.com/cap.json" + # Will resolve to public IP, should pass + result = validate_fetch_url(url) + assert result == url + + def test_http_url_blocked(self): + """HTTP (non-HTTPS) URLs must be blocked.""" + with pytest.raises(UnsafeURLError, match="Only HTTPS"): + validate_fetch_url("http://example.com/cap.json") + + def test_file_scheme_blocked(self): + """file:// scheme must be blocked.""" + with pytest.raises(UnsafeURLError, match="Only HTTPS"): + validate_fetch_url("file:///etc/passwd") + + def test_ftp_scheme_blocked(self): + """ftp:// scheme must be blocked.""" + with pytest.raises(UnsafeURLError, match="Only HTTPS"): + validate_fetch_url("ftp://evil.com/data") + + def test_no_hostname_blocked(self): + """URLs without a hostname must be blocked.""" + with pytest.raises(UnsafeURLError, match="no hostname"): + validate_fetch_url("https://") + + def test_loopback_ipv4_blocked(self): + """127.0.0.1 must be blocked.""" + with pytest.raises(UnsafeURLError, match="non-public IP"): + validate_fetch_url("https://127.0.0.1/secret") + + def test_loopback_localhost_blocked(self): + """localhost must be blocked (resolves to 127.0.0.1).""" + with pytest.raises(UnsafeURLError, match="non-public IP"): + validate_fetch_url("https://localhost/secret") + + def test_private_ip_10_blocked(self): + """10.x.x.x private IPs must be blocked.""" + with pytest.raises(UnsafeURLError, match="non-public IP"): + validate_fetch_url("https://10.0.0.1/internal") + + def test_private_ip_172_blocked(self): + """172.16.x.x private IPs must be blocked.""" + with pytest.raises(UnsafeURLError, match="non-public IP"): + validate_fetch_url("https://172.16.0.1/internal") + + def test_private_ip_192_blocked(self): + """192.168.x.x private IPs must be blocked.""" + with pytest.raises(UnsafeURLError, match="non-public IP"): + validate_fetch_url("https://192.168.1.1/admin") + + def test_link_local_blocked(self): + """169.254.x.x (AWS metadata) must be blocked.""" + with pytest.raises(UnsafeURLError, match="non-public IP"): + validate_fetch_url("https://169.254.169.254/latest/meta-data/") + + def test_unresolvable_hostname(self): + """Unresolvable hostnames should raise UnsafeURLError.""" + with pytest.raises(UnsafeURLError, match="Cannot resolve"): + validate_fetch_url("https://this-domain-definitely-does-not-exist-12345.invalid/cap") + + def test_allowlist_bypasses_ip_check(self): + """Hosts in DNS_AID_FETCH_ALLOWLIST should bypass IP checks.""" + with patch.dict(os.environ, {"DNS_AID_FETCH_ALLOWLIST": "localhost,127.0.0.1"}): + # localhost would normally be blocked, but allowlist overrides + result = validate_fetch_url("https://localhost/test") + assert result == "https://localhost/test" + + def test_allowlist_case_insensitive(self): + """Allowlist matching should be case-insensitive.""" + with patch.dict(os.environ, {"DNS_AID_FETCH_ALLOWLIST": "LocalHost"}): + result = validate_fetch_url("https://localhost/test") + assert result == "https://localhost/test" + + +class TestCapSha256Verification: + """Tests for cap_sha256 integrity verification in cap_fetcher.""" + + @pytest.mark.asyncio + async def test_hash_match_passes(self): + """Correct hash should allow document to be returned.""" + import base64 + import hashlib + + from unittest.mock import AsyncMock + + import httpx + + from dns_aid.core.cap_fetcher import fetch_cap_document + + content = b'{"capabilities": ["test"]}' + expected_hash = ( + base64.urlsafe_b64encode(hashlib.sha256(content).digest()).rstrip(b"=").decode("ascii") + ) + + mock_response = AsyncMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.content = content + mock_response.json.return_value = {"capabilities": ["test"]} + + with patch("dns_aid.utils.url_safety.validate_fetch_url", return_value="https://ok.com"): + with patch("httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.get = AsyncMock(return_value=mock_response) + mock_client_cls.return_value = mock_client + + doc = await fetch_cap_document( + "https://ok.com/cap.json", + expected_sha256=expected_hash, + ) + assert doc is not None + assert doc.capabilities == ["test"] + + @pytest.mark.asyncio + async def test_hash_mismatch_returns_none(self): + """Wrong hash should cause fetch to return None.""" + from unittest.mock import AsyncMock + + import httpx + + from dns_aid.core.cap_fetcher import fetch_cap_document + + content = b'{"capabilities": ["test"]}' + + mock_response = AsyncMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.content = content + mock_response.json.return_value = {"capabilities": ["test"]} + + with patch("dns_aid.utils.url_safety.validate_fetch_url", return_value="https://ok.com"): + with patch("httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.get = AsyncMock(return_value=mock_response) + mock_client_cls.return_value = mock_client + + doc = await fetch_cap_document( + "https://ok.com/cap.json", + expected_sha256="WRONG_HASH", + ) + assert doc is None + + @pytest.mark.asyncio + async def test_no_hash_skips_verification(self): + """When expected_sha256 is None, skip verification.""" + from unittest.mock import AsyncMock + + import httpx + + from dns_aid.core.cap_fetcher import fetch_cap_document + + content = b'{"capabilities": ["test"]}' + + mock_response = AsyncMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.content = content + mock_response.json.return_value = {"capabilities": ["test"]} + + with patch("dns_aid.utils.url_safety.validate_fetch_url", return_value="https://ok.com"): + with patch("httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.get = AsyncMock(return_value=mock_response) + mock_client_cls.return_value = mock_client + + doc = await fetch_cap_document( + "https://ok.com/cap.json", + expected_sha256=None, + ) + assert doc is not None + assert doc.capabilities == ["test"] diff --git a/tests/unit/test_validation.py b/tests/unit/test_validation.py index deb9f74..15a1bc5 100644 --- a/tests/unit/test_validation.py +++ b/tests/unit/test_validation.py @@ -279,12 +279,21 @@ def test_valid_route53(self): def test_valid_mock(self): assert validate_backend("mock") == "mock" + def test_valid_cloudflare(self): + assert validate_backend("cloudflare") == "cloudflare" + + def test_valid_infoblox(self): + assert validate_backend("infoblox") == "infoblox" + + def test_valid_ddns(self): + assert validate_backend("ddns") == "ddns" + def test_normalizes_to_lowercase(self): assert validate_backend("ROUTE53") == "route53" def test_invalid_backend_raises(self): with pytest.raises(ValidationError) as exc: - validate_backend("cloudflare") + validate_backend("nonexistent") assert exc.value.field == "backend" def test_empty_backend_raises(self): diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..dd68059 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1791 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[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.12.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/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "arrow" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/33/032cdc44182491aa708d06a68b62434140d8c50820a087fac7af37703357/arrow-1.4.0.tar.gz", hash = "sha256:ed0cc050e98001b8779e84d461b0098c4ac597e88704a655582b21d116e526d7", size = 152931, upload-time = "2025-10-18T17:46:46.761Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/c9/d7977eaacb9df673210491da99e6a247e93df98c715fc43fd136ce1d3d33/arrow-1.4.0-py3-none-any.whl", hash = "sha256:749f0769958ebdc79c173ff0b0670d59051a535fa26e8eba02953dc19eb43205", size = 68797, upload-time = "2025-10-18T17:46:45.663Z" }, +] + +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + +[[package]] +name = "boolean-py" +version = "5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/cf/85379f13b76f3a69bca86b60237978af17d6aa0bc5998978c3b8cf05abb2/boolean_py-5.0.tar.gz", hash = "sha256:60cbc4bad079753721d32649545505362c754e121570ada4658b852a3a318d95", size = 37047, upload-time = "2025-04-03T10:39:49.734Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/ca/78d423b324b8d77900030fa59c4aa9054261ef0925631cd2501dd015b7b7/boolean_py-5.0-py3-none-any.whl", hash = "sha256:ef28a70bd43115208441b53a045d1549e2f0ec6e3d08a9d142cbc41c1938e8d9", size = 26577, upload-time = "2025-04-03T10:39:48.449Z" }, +] + +[[package]] +name = "boto3" +version = "1.42.47" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/fe/3363024b6dda5968401f45d8b345ed95ce4fd536d58f799988b4b28184ad/boto3-1.42.47.tar.gz", hash = "sha256:74812a2e29de7c2bd19e446d765cb887394f20f1517388484b51891a410f33b2", size = 112884, upload-time = "2026-02-11T20:49:49.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/7b/884e30adab2339ce5cce7b800f5fa619254d36e89e50a8cf39a5524edc35/boto3-1.42.47-py3-none-any.whl", hash = "sha256:ed881ed246027028af566acbb80f008aa619be4d3fdbcc4ad3c75dbe8c34bfaf", size = 140608, upload-time = "2026-02-11T20:49:47.664Z" }, +] + +[[package]] +name = "boto3-stubs" +version = "1.42.47" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore-stubs" }, + { name = "types-s3transfer" }, + { name = "typing-extensions", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/72/288bfed2a5c263af6a3f878e86b6a92a944a0545208264c885e4be497932/boto3_stubs-1.42.47.tar.gz", hash = "sha256:cf9ab800bb5862116264e59fb01593a25751d81a27751d01fc3ac791f1d89b31", size = 100903, upload-time = "2026-02-11T21:13:32.659Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/c1/ddd10b1ca9b633f1dc27e00b4ce7dba7f3af9e7ff7419ddf3d3af62cea18/boto3_stubs-1.42.47-py3-none-any.whl", hash = "sha256:6be7390de7c7dd0decc870cf393079d495446e86237a7fcdbff2cdd776f87762", size = 69782, upload-time = "2026-02-11T21:13:28.569Z" }, +] + +[package.optional-dependencies] +route53 = [ + { name = "mypy-boto3-route53" }, +] + +[[package]] +name = "botocore" +version = "1.42.47" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ed/a6/d15f5dfe990abd76dbdb2105a7697e0d948e04c41dfd97c058bc76c7cebd/botocore-1.42.47.tar.gz", hash = "sha256:c26e190c1b4d863ba7b44dc68cc574d8eb862ddae5f0fe3472801daee12a0378", size = 14952255, upload-time = "2026-02-11T20:49:40.157Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/5e/50e3a59b243894088eeb949a654fb21d9ab7d0d703034470de016828d85a/botocore-1.42.47-py3-none-any.whl", hash = "sha256:c60f5feaf189423e17755aca3f1d672b7466620dd2032440b32aaac64ae8cac8", size = 14625351, upload-time = "2026-02-11T20:49:36.143Z" }, +] + +[[package]] +name = "botocore-stubs" +version = "1.42.41" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "types-awscrt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/a8/a26608ff39e3a5866c6c79eda10133490205cbddd45074190becece3ff2a/botocore_stubs-1.42.41.tar.gz", hash = "sha256:dbeac2f744df6b814ce83ec3f3777b299a015cbea57a2efc41c33b8c38265825", size = 42411, upload-time = "2026-02-03T20:46:14.479Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/76/cab7af7f16c0b09347f2ebe7ffda7101132f786acb767666dce43055faab/botocore_stubs-1.42.41-py3-none-any.whl", hash = "sha256:9423110fb0e391834bd2ed44ae5f879d8cb370a444703d966d30842ce2bcb5f0", size = 66759, upload-time = "2026-02-03T20:46:13.02Z" }, +] + +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "chardet" +version = "5.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/f7b6ab21ec75897ed80c17d79b15951a719226b9fababf1e40ea74d69079/chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7", size = 2069618, upload-time = "2023-08-01T19:23:02.662Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/6f/f5fbc992a329ee4e0f288c1fe0e2ad9485ed064cac731ed2fe47dcc38cbf/chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970", size = 199385, upload-time = "2023-08-01T19:23:00.661Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "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.13.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/ad/b59e5b451cf7172b8d1043dc0fa718f23aab379bc1521ee13d4bd9bfa960/coverage-7.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d490ba50c3f35dd7c17953c68f3270e7ccd1c6642e2d2afe2d8e720b98f5a053", size = 219278, upload-time = "2026-02-09T12:56:31.673Z" }, + { url = "https://files.pythonhosted.org/packages/f1/17/0cb7ca3de72e5f4ef2ec2fa0089beafbcaaaead1844e8b8a63d35173d77d/coverage-7.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:19bc3c88078789f8ef36acb014d7241961dbf883fd2533d18cb1e7a5b4e28b11", size = 219783, upload-time = "2026-02-09T12:56:33.104Z" }, + { url = "https://files.pythonhosted.org/packages/ab/63/325d8e5b11e0eaf6d0f6a44fad444ae58820929a9b0de943fa377fe73e85/coverage-7.13.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3998e5a32e62fdf410c0dbd3115df86297995d6e3429af80b8798aad894ca7aa", size = 250200, upload-time = "2026-02-09T12:56:34.474Z" }, + { url = "https://files.pythonhosted.org/packages/76/53/c16972708cbb79f2942922571a687c52bd109a7bd51175aeb7558dff2236/coverage-7.13.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e264226ec98e01a8e1054314af91ee6cde0eacac4f465cc93b03dbe0bce2fd7", size = 252114, upload-time = "2026-02-09T12:56:35.749Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c2/7ab36d8b8cc412bec9ea2d07c83c48930eb4ba649634ba00cb7e4e0f9017/coverage-7.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3aa4e7b9e416774b21797365b358a6e827ffadaaca81b69ee02946852449f00", size = 254220, upload-time = "2026-02-09T12:56:37.796Z" }, + { url = "https://files.pythonhosted.org/packages/d6/4d/cf52c9a3322c89a0e6febdfbc83bb45c0ed3c64ad14081b9503adee702e7/coverage-7.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71ca20079dd8f27fcf808817e281e90220475cd75115162218d0e27549f95fef", size = 256164, upload-time = "2026-02-09T12:56:39.016Z" }, + { url = "https://files.pythonhosted.org/packages/78/e9/eb1dd17bd6de8289df3580e967e78294f352a5df8a57ff4671ee5fc3dcd0/coverage-7.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e2f25215f1a359ab17320b47bcdaca3e6e6356652e8256f2441e4ef972052903", size = 250325, upload-time = "2026-02-09T12:56:40.668Z" }, + { url = "https://files.pythonhosted.org/packages/71/07/8c1542aa873728f72267c07278c5cc0ec91356daf974df21335ccdb46368/coverage-7.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d65b2d373032411e86960604dc4edac91fdfb5dca539461cf2cbe78327d1e64f", size = 251913, upload-time = "2026-02-09T12:56:41.97Z" }, + { url = "https://files.pythonhosted.org/packages/74/d7/c62e2c5e4483a748e27868e4c32ad3daa9bdddbba58e1bc7a15e252baa74/coverage-7.13.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94eb63f9b363180aff17de3e7c8760c3ba94664ea2695c52f10111244d16a299", size = 249974, upload-time = "2026-02-09T12:56:43.323Z" }, + { url = "https://files.pythonhosted.org/packages/98/9f/4c5c015a6e98ced54efd0f5cf8d31b88e5504ecb6857585fc0161bb1e600/coverage-7.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e856bf6616714c3a9fbc270ab54103f4e685ba236fa98c054e8f87f266c93505", size = 253741, upload-time = "2026-02-09T12:56:45.155Z" }, + { url = "https://files.pythonhosted.org/packages/bd/59/0f4eef89b9f0fcd9633b5d350016f54126ab49426a70ff4c4e87446cabdc/coverage-7.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:65dfcbe305c3dfe658492df2d85259e0d79ead4177f9ae724b6fb245198f55d6", size = 249695, upload-time = "2026-02-09T12:56:46.636Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2c/b7476f938deb07166f3eb281a385c262675d688ff4659ad56c6c6b8e2e70/coverage-7.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b507778ae8a4c915436ed5c2e05b4a6cecfa70f734e19c22a005152a11c7b6a9", size = 250599, upload-time = "2026-02-09T12:56:48.13Z" }, + { url = "https://files.pythonhosted.org/packages/b8/34/c3420709d9846ee3785b9f2831b4d94f276f38884032dca1457fa83f7476/coverage-7.13.4-cp311-cp311-win32.whl", hash = "sha256:784fc3cf8be001197b652d51d3fd259b1e2262888693a4636e18879f613a62a9", size = 221780, upload-time = "2026-02-09T12:56:50.479Z" }, + { url = "https://files.pythonhosted.org/packages/61/08/3d9c8613079d2b11c185b865de9a4c1a68850cfda2b357fae365cf609f29/coverage-7.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:2421d591f8ca05b308cf0092807308b2facbefe54af7c02ac22548b88b95c98f", size = 222715, upload-time = "2026-02-09T12:56:51.815Z" }, + { url = "https://files.pythonhosted.org/packages/18/1a/54c3c80b2f056164cc0a6cdcb040733760c7c4be9d780fe655f356f433e4/coverage-7.13.4-cp311-cp311-win_arm64.whl", hash = "sha256:79e73a76b854d9c6088fe5d8b2ebe745f8681c55f7397c3c0a016192d681045f", size = 221385, upload-time = "2026-02-09T12:56:53.194Z" }, + { url = "https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", size = 219449, upload-time = "2026-02-09T12:56:54.889Z" }, + { url = "https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", size = 219810, upload-time = "2026-02-09T12:56:56.33Z" }, + { url = "https://files.pythonhosted.org/packages/78/72/2f372b726d433c9c35e56377cf1d513b4c16fe51841060d826b95caacec1/coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", size = 251308, upload-time = "2026-02-09T12:56:57.858Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", size = 254052, upload-time = "2026-02-09T12:56:59.754Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ac/45dc2e19a1939098d783c846e130b8f862fbb50d09e0af663988f2f21973/coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", size = 255165, upload-time = "2026-02-09T12:57:01.287Z" }, + { url = "https://files.pythonhosted.org/packages/2d/4d/26d236ff35abc3b5e63540d3386e4c3b192168c1d96da5cb2f43c640970f/coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", size = 257432, upload-time = "2026-02-09T12:57:02.637Z" }, + { url = "https://files.pythonhosted.org/packages/ec/55/14a966c757d1348b2e19caf699415a2a4c4f7feaa4bbc6326a51f5c7dd1b/coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", size = 251716, upload-time = "2026-02-09T12:57:04.056Z" }, + { url = "https://files.pythonhosted.org/packages/77/33/50116647905837c66d28b2af1321b845d5f5d19be9655cb84d4a0ea806b4/coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", size = 253089, upload-time = "2026-02-09T12:57:05.503Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b4/8efb11a46e3665d92635a56e4f2d4529de6d33f2cb38afd47d779d15fc99/coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", size = 251232, upload-time = "2026-02-09T12:57:06.879Z" }, + { url = "https://files.pythonhosted.org/packages/51/24/8cd73dd399b812cc76bb0ac260e671c4163093441847ffe058ac9fda1e32/coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", size = 255299, upload-time = "2026-02-09T12:57:08.245Z" }, + { url = "https://files.pythonhosted.org/packages/03/94/0a4b12f1d0e029ce1ccc1c800944a9984cbe7d678e470bb6d3c6bc38a0da/coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", size = 250796, upload-time = "2026-02-09T12:57:10.142Z" }, + { url = "https://files.pythonhosted.org/packages/73/44/6002fbf88f6698ca034360ce474c406be6d5a985b3fdb3401128031eef6b/coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0", size = 252673, upload-time = "2026-02-09T12:57:12.197Z" }, + { url = "https://files.pythonhosted.org/packages/de/c6/a0279f7c00e786be75a749a5674e6fa267bcbd8209cd10c9a450c655dfa7/coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", size = 221990, upload-time = "2026-02-09T12:57:14.085Z" }, + { url = "https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", size = 222800, upload-time = "2026-02-09T12:57:15.944Z" }, + { url = "https://files.pythonhosted.org/packages/47/ac/92da44ad9a6f4e3a7debd178949d6f3769bedca33830ce9b1dcdab589a37/coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", size = 221415, upload-time = "2026-02-09T12:57:17.497Z" }, + { url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" }, + { url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" }, + { url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" }, + { url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" }, + { url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" }, + { url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" }, + { url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" }, + { url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" }, + { url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" }, + { url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" }, + { url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" }, + { url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" }, + { url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" }, + { url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" }, + { url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" }, + { url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" }, + { url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" }, + { url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" }, + { url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" }, + { url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" }, + { url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" }, + { url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" }, + { url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" }, + { url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" }, + { url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" }, + { url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" }, + { url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" }, + { url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" }, + { url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" }, + { url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" }, + { url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" }, + { url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" }, + { url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" }, + { url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" }, + { url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" }, + { url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" }, + { url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" }, + { url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" }, + { url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" }, + { url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" }, + { url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" }, + { url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "cryptography" +version = "46.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, + { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, + { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, + { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, + { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, + { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, + { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, + { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, + { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" }, + { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" }, + { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" }, + { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" }, + { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" }, + { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, + { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, + { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, + { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, + { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, + { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, + { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, + { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, + { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, + { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, + { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, + { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, + { url = "https://files.pythonhosted.org/packages/eb/dd/2d9fdb07cebdf3d51179730afb7d5e576153c6744c3ff8fded23030c204e/cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c", size = 3476964, upload-time = "2026-02-10T19:18:20.687Z" }, + { url = "https://files.pythonhosted.org/packages/e9/6f/6cc6cc9955caa6eaf83660b0da2b077c7fe8ff9950a3c5e45d605038d439/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a", size = 4218321, upload-time = "2026-02-10T19:18:22.349Z" }, + { url = "https://files.pythonhosted.org/packages/3e/5d/c4da701939eeee699566a6c1367427ab91a8b7088cc2328c09dbee940415/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356", size = 4381786, upload-time = "2026-02-10T19:18:24.529Z" }, + { url = "https://files.pythonhosted.org/packages/ac/97/a538654732974a94ff96c1db621fa464f455c02d4bb7d2652f4edc21d600/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da", size = 4217990, upload-time = "2026-02-10T19:18:25.957Z" }, + { url = "https://files.pythonhosted.org/packages/ae/11/7e500d2dd3ba891197b9efd2da5454b74336d64a7cc419aa7327ab74e5f6/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257", size = 4381252, upload-time = "2026-02-10T19:18:27.496Z" }, + { url = "https://files.pythonhosted.org/packages/bc/58/6b3d24e6b9bc474a2dcdee65dfd1f008867015408a271562e4b690561a4d/cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", size = 3407605, upload-time = "2026-02-10T19:18:29.233Z" }, +] + +[[package]] +name = "cyclonedx-bom" +version = "7.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "chardet" }, + { name = "cyclonedx-python-lib", extra = ["validation"] }, + { name = "packageurl-python" }, + { name = "packaging" }, + { name = "pip-requirements-parser" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/b4/d6a3eee8622389893480758ada629842b8667e326ec8da311dbc7f5087f4/cyclonedx_bom-7.2.1.tar.gz", hash = "sha256:ead9923a23c71426bcc83ea371c87945b85f76c31728625dde35ecfe0fa2e712", size = 4416994, upload-time = "2025-10-29T15:31:47.238Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/3a/c30b624eb2b5f33d9f5a55f23a65f529c875897639961cf51d2af8a5e527/cyclonedx_bom-7.2.1-py3-none-any.whl", hash = "sha256:fdeabfec4f3274085320a40d916fc4dc2850abef7da5953d544eb5c98aa4afdd", size = 60696, upload-time = "2025-10-29T15:31:45.594Z" }, +] + +[[package]] +name = "cyclonedx-python-lib" +version = "11.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "license-expression" }, + { name = "packageurl-python" }, + { name = "py-serializable" }, + { name = "sortedcontainers" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/89/ed/54ecfa25fc145c58bf4f98090f7b6ffe5188d0759248c57dde44427ea239/cyclonedx_python_lib-11.6.0.tar.gz", hash = "sha256:7fb85a4371fa3a203e5be577ac22b7e9a7157f8b0058b7448731474d6dea7bf0", size = 1408147, upload-time = "2025-12-02T12:28:46.446Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/1b/534ad8a5e0f9470522811a8e5a9bc5d328fb7738ba29faf357467a4ef6d0/cyclonedx_python_lib-11.6.0-py3-none-any.whl", hash = "sha256:94f4aae97db42a452134dafdddcfab9745324198201c4777ed131e64c8380759", size = 511157, upload-time = "2025-12-02T12:28:44.158Z" }, +] + +[package.optional-dependencies] +validation = [ + { name = "jsonschema", extra = ["format-nongpl"] }, + { name = "lxml" }, + { name = "referencing" }, +] + +[[package]] +name = "defusedxml" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, +] + +[[package]] +name = "dns-aid" +version = "0.6.0" +source = { editable = "." } +dependencies = [ + { name = "dnspython" }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "structlog" }, +] + +[package.optional-dependencies] +all = [ + { name = "boto3" }, + { name = "boto3-stubs", extra = ["route53"] }, + { name = "cryptography" }, + { name = "mcp" }, + { name = "mypy" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-sdk" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "rich" }, + { name = "ruff" }, + { name = "typer" }, + { name = "uvicorn" }, +] +cli = [ + { name = "rich" }, + { name = "typer" }, +] +dev = [ + { name = "boto3-stubs", extra = ["route53"] }, + { name = "cryptography" }, + { name = "cyclonedx-bom" }, + { name = "httpx" }, + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] +jws = [ + { name = "cryptography" }, +] +mcp = [ + { name = "mcp" }, + { name = "uvicorn" }, +] +otel = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-sdk" }, +] +route53 = [ + { name = "boto3" }, +] + +[package.metadata] +requires-dist = [ + { name = "boto3", marker = "extra == 'all'", specifier = ">=1.34.0" }, + { name = "boto3", marker = "extra == 'route53'", specifier = ">=1.34.0" }, + { name = "boto3-stubs", extras = ["route53"], marker = "extra == 'all'", specifier = ">=1.34.0" }, + { name = "boto3-stubs", extras = ["route53"], marker = "extra == 'dev'", specifier = ">=1.34.0" }, + { name = "cryptography", marker = "extra == 'all'", specifier = ">=41.0.0" }, + { name = "cryptography", marker = "extra == 'dev'", specifier = ">=41.0.0" }, + { name = "cryptography", marker = "extra == 'jws'", specifier = ">=41.0.0" }, + { name = "cyclonedx-bom", marker = "extra == 'dev'", specifier = ">=4.0.0" }, + { name = "dnspython", specifier = ">=2.6.0" }, + { name = "httpx", specifier = ">=0.27.0" }, + { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27.0" }, + { name = "mcp", marker = "extra == 'all'", specifier = ">=1.0.0" }, + { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.0.0" }, + { name = "mypy", marker = "extra == 'all'", specifier = ">=1.8.0" }, + { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.8.0" }, + { name = "opentelemetry-api", marker = "extra == 'all'", specifier = ">=1.20.0" }, + { name = "opentelemetry-api", marker = "extra == 'otel'", specifier = ">=1.20.0" }, + { name = "opentelemetry-sdk", marker = "extra == 'all'", specifier = ">=1.20.0" }, + { name = "opentelemetry-sdk", marker = "extra == 'otel'", specifier = ">=1.20.0" }, + { name = "pydantic", specifier = ">=2.5.0" }, + { name = "pytest", marker = "extra == 'all'", specifier = ">=8.0.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, + { name = "pytest-asyncio", marker = "extra == 'all'", specifier = ">=0.23.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" }, + { name = "pytest-cov", marker = "extra == 'all'", specifier = ">=4.0.0" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0.0" }, + { name = "rich", marker = "extra == 'all'", specifier = ">=13.0.0" }, + { name = "rich", marker = "extra == 'cli'", specifier = ">=13.0.0" }, + { name = "ruff", marker = "extra == 'all'", specifier = ">=0.3.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.3.0" }, + { name = "structlog", specifier = ">=24.0.0" }, + { name = "typer", marker = "extra == 'all'", specifier = ">=0.12.0" }, + { name = "typer", marker = "extra == 'cli'", specifier = ">=0.12.0" }, + { name = "uvicorn", marker = "extra == 'all'", specifier = ">=0.30.0" }, + { name = "uvicorn", marker = "extra == 'mcp'", specifier = ">=0.30.0" }, +] +provides-extras = ["cli", "mcp", "route53", "infoblox", "jws", "sdk", "otel", "dev", "all"] + +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "fqdn" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/3e/a80a8c077fd798951169626cde3e239adeba7dab75deb3555716415bd9b0/fqdn-1.5.1.tar.gz", hash = "sha256:105ed3677e767fb5ca086a0c1f4bb66ebc3c100be518f0e0d755d9eae164d89f", size = 6015, upload-time = "2021-03-11T07:16:29.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/58/8acf1b3e91c58313ce5cb67df61001fc9dcd21be4fadb76c1a2d540e09ed/fqdn-1.5.1-py3-none-any.whl", hash = "sha256:3a179af3761e4df6eb2e026ff9e1a3033d3587bf980a0b1b2e1e5d08d7358014", size = 9121, upload-time = "2021-03-11T07:16:28.351Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "8.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, +] + +[[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 = "isoduration" +version = "20.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "arrow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7c/1a/3c8edc664e06e6bd06cce40c6b22da5f1429aa4224d0c590f3be21c91ead/isoduration-20.11.0.tar.gz", hash = "sha256:ac2f9015137935279eac671f94f89eb00584f940f5dc49462a0c4ee692ba1bd9", size = 11649, upload-time = "2020-11-01T11:00:00.312Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/55/e5326141505c5d5e34c5e0935d2908a74e4561eca44108fbfb9c13d2911a/isoduration-20.11.0-py3-none-any.whl", hash = "sha256:b2904c2a4228c3d44f409c8ae8e2370eb21a26f7ac2ec5446df141dde3452042", size = 11321, upload-time = "2020-11-01T10:59:58.02Z" }, +] + +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, +] + +[[package]] +name = "jsonpointer" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/0a/eebeb1fa92507ea94016a2a790b93c2ae41a7e18778f85471dc54475ed25/jsonpointer-3.0.0.tar.gz", hash = "sha256:2b2d729f2091522d61c3b31f82e11870f60b68f43fbc705cb76bf4b832af59ef", size = 9114, upload-time = "2024-06-10T19:24:42.462Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/92/5e77f98553e9e75130c78900d000368476aed74276eb8ae8796f65f00918/jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942", size = 7595, upload-time = "2024-06-10T19:24:40.698Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[package.optional-dependencies] +format-nongpl = [ + { name = "fqdn" }, + { name = "idna" }, + { name = "isoduration" }, + { name = "jsonpointer" }, + { name = "rfc3339-validator" }, + { name = "rfc3986-validator" }, + { name = "rfc3987-syntax" }, + { name = "uri-template" }, + { name = "webcolors" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "lark" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/34/28fff3ab31ccff1fd4f6c7c7b0ceb2b6968d8ea4950663eadcb5720591a0/lark-1.3.1.tar.gz", hash = "sha256:b426a7a6d6d53189d318f2b6236ab5d6429eaf09259f1ca33eb716eed10d2905", size = 382732, upload-time = "2025-10-27T18:25:56.653Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3d/14ce75ef66813643812f3093ab17e46d3a206942ce7376d31ec2d36229e7/lark-1.3.1-py3-none-any.whl", hash = "sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12", size = 113151, upload-time = "2025-10-27T18:25:54.882Z" }, +] + +[[package]] +name = "librt" +version = "0.7.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/24/5f3646ff414285e0f7708fa4e946b9bf538345a41d1c375c439467721a5e/librt-0.7.8.tar.gz", hash = "sha256:1a4ede613941d9c3470b0368be851df6bb78ab218635512d0370b27a277a0862", size = 148323, upload-time = "2026-01-14T12:56:16.876Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/a3/87ea9c1049f2c781177496ebee29430e4631f439b8553a4969c88747d5d8/librt-0.7.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ff3e9c11aa260c31493d4b3197d1e28dd07768594a4f92bec4506849d736248f", size = 56507, upload-time = "2026-01-14T12:54:54.156Z" }, + { url = "https://files.pythonhosted.org/packages/5e/4a/23bcef149f37f771ad30203d561fcfd45b02bc54947b91f7a9ac34815747/librt-0.7.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ddb52499d0b3ed4aa88746aaf6f36a08314677d5c346234c3987ddc506404eac", size = 58455, upload-time = "2026-01-14T12:54:55.978Z" }, + { url = "https://files.pythonhosted.org/packages/22/6e/46eb9b85c1b9761e0f42b6e6311e1cc544843ac897457062b9d5d0b21df4/librt-0.7.8-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e9c0afebbe6ce177ae8edba0c7c4d626f2a0fc12c33bb993d163817c41a7a05c", size = 164956, upload-time = "2026-01-14T12:54:57.311Z" }, + { url = "https://files.pythonhosted.org/packages/7a/3f/aa7c7f6829fb83989feb7ba9aa11c662b34b4bd4bd5b262f2876ba3db58d/librt-0.7.8-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:631599598e2c76ded400c0a8722dec09217c89ff64dc54b060f598ed68e7d2a8", size = 174364, upload-time = "2026-01-14T12:54:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/3f/2d/d57d154b40b11f2cb851c4df0d4c4456bacd9b1ccc4ecb593ddec56c1a8b/librt-0.7.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c1ba843ae20db09b9d5c80475376168feb2640ce91cd9906414f23cc267a1ff", size = 188034, upload-time = "2026-01-14T12:55:00.141Z" }, + { url = "https://files.pythonhosted.org/packages/59/f9/36c4dad00925c16cd69d744b87f7001792691857d3b79187e7a673e812fb/librt-0.7.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b5b007bb22ea4b255d3ee39dfd06d12534de2fcc3438567d9f48cdaf67ae1ae3", size = 186295, upload-time = "2026-01-14T12:55:01.303Z" }, + { url = "https://files.pythonhosted.org/packages/23/9b/8a9889d3df5efb67695a67785028ccd58e661c3018237b73ad081691d0cb/librt-0.7.8-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:dbd79caaf77a3f590cbe32dc2447f718772d6eea59656a7dcb9311161b10fa75", size = 181470, upload-time = "2026-01-14T12:55:02.492Z" }, + { url = "https://files.pythonhosted.org/packages/43/64/54d6ef11afca01fef8af78c230726a9394759f2addfbf7afc5e3cc032a45/librt-0.7.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:87808a8d1e0bd62a01cafc41f0fd6818b5a5d0ca0d8a55326a81643cdda8f873", size = 201713, upload-time = "2026-01-14T12:55:03.919Z" }, + { url = "https://files.pythonhosted.org/packages/2d/29/73e7ed2991330b28919387656f54109139b49e19cd72902f466bd44415fd/librt-0.7.8-cp311-cp311-win32.whl", hash = "sha256:31724b93baa91512bd0a376e7cf0b59d8b631ee17923b1218a65456fa9bda2e7", size = 43803, upload-time = "2026-01-14T12:55:04.996Z" }, + { url = "https://files.pythonhosted.org/packages/3f/de/66766ff48ed02b4d78deea30392ae200bcbd99ae61ba2418b49fd50a4831/librt-0.7.8-cp311-cp311-win_amd64.whl", hash = "sha256:978e8b5f13e52cf23a9e80f3286d7546baa70bc4ef35b51d97a709d0b28e537c", size = 50080, upload-time = "2026-01-14T12:55:06.489Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e3/33450438ff3a8c581d4ed7f798a70b07c3206d298cf0b87d3806e72e3ed8/librt-0.7.8-cp311-cp311-win_arm64.whl", hash = "sha256:20e3946863d872f7cabf7f77c6c9d370b8b3d74333d3a32471c50d3a86c0a232", size = 43383, upload-time = "2026-01-14T12:55:07.49Z" }, + { url = "https://files.pythonhosted.org/packages/56/04/79d8fcb43cae376c7adbab7b2b9f65e48432c9eced62ac96703bcc16e09b/librt-0.7.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9b6943885b2d49c48d0cff23b16be830ba46b0152d98f62de49e735c6e655a63", size = 57472, upload-time = "2026-01-14T12:55:08.528Z" }, + { url = "https://files.pythonhosted.org/packages/b4/ba/60b96e93043d3d659da91752689023a73981336446ae82078cddf706249e/librt-0.7.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:46ef1f4b9b6cc364b11eea0ecc0897314447a66029ee1e55859acb3dd8757c93", size = 58986, upload-time = "2026-01-14T12:55:09.466Z" }, + { url = "https://files.pythonhosted.org/packages/7c/26/5215e4cdcc26e7be7eee21955a7e13cbf1f6d7d7311461a6014544596fac/librt-0.7.8-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:907ad09cfab21e3c86e8f1f87858f7049d1097f77196959c033612f532b4e592", size = 168422, upload-time = "2026-01-14T12:55:10.499Z" }, + { url = "https://files.pythonhosted.org/packages/0f/84/e8d1bc86fa0159bfc24f3d798d92cafd3897e84c7fea7fe61b3220915d76/librt-0.7.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2991b6c3775383752b3ca0204842743256f3ad3deeb1d0adc227d56b78a9a850", size = 177478, upload-time = "2026-01-14T12:55:11.577Z" }, + { url = "https://files.pythonhosted.org/packages/57/11/d0268c4b94717a18aa91df1100e767b010f87b7ae444dafaa5a2d80f33a6/librt-0.7.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03679b9856932b8c8f674e87aa3c55ea11c9274301f76ae8dc4d281bda55cf62", size = 192439, upload-time = "2026-01-14T12:55:12.7Z" }, + { url = "https://files.pythonhosted.org/packages/8d/56/1e8e833b95fe684f80f8894ae4d8b7d36acc9203e60478fcae599120a975/librt-0.7.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3968762fec1b2ad34ce57458b6de25dbb4142713e9ca6279a0d352fa4e9f452b", size = 191483, upload-time = "2026-01-14T12:55:13.838Z" }, + { url = "https://files.pythonhosted.org/packages/17/48/f11cf28a2cb6c31f282009e2208312aa84a5ee2732859f7856ee306176d5/librt-0.7.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:bb7a7807523a31f03061288cc4ffc065d684c39db7644c676b47d89553c0d714", size = 185376, upload-time = "2026-01-14T12:55:15.017Z" }, + { url = "https://files.pythonhosted.org/packages/b8/6a/d7c116c6da561b9155b184354a60a3d5cdbf08fc7f3678d09c95679d13d9/librt-0.7.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad64a14b1e56e702e19b24aae108f18ad1bf7777f3af5fcd39f87d0c5a814449", size = 206234, upload-time = "2026-01-14T12:55:16.571Z" }, + { url = "https://files.pythonhosted.org/packages/61/de/1975200bb0285fc921c5981d9978ce6ce11ae6d797df815add94a5a848a3/librt-0.7.8-cp312-cp312-win32.whl", hash = "sha256:0241a6ed65e6666236ea78203a73d800dbed896cf12ae25d026d75dc1fcd1dac", size = 44057, upload-time = "2026-01-14T12:55:18.077Z" }, + { url = "https://files.pythonhosted.org/packages/8e/cd/724f2d0b3461426730d4877754b65d39f06a41ac9d0a92d5c6840f72b9ae/librt-0.7.8-cp312-cp312-win_amd64.whl", hash = "sha256:6db5faf064b5bab9675c32a873436b31e01d66ca6984c6f7f92621656033a708", size = 50293, upload-time = "2026-01-14T12:55:19.179Z" }, + { url = "https://files.pythonhosted.org/packages/bd/cf/7e899acd9ee5727ad8160fdcc9994954e79fab371c66535c60e13b968ffc/librt-0.7.8-cp312-cp312-win_arm64.whl", hash = "sha256:57175aa93f804d2c08d2edb7213e09276bd49097611aefc37e3fa38d1fb99ad0", size = 43574, upload-time = "2026-01-14T12:55:20.185Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fe/b1f9de2829cf7fc7649c1dcd202cfd873837c5cc2fc9e526b0e7f716c3d2/librt-0.7.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4c3995abbbb60b3c129490fa985dfe6cac11d88fc3c36eeb4fb1449efbbb04fc", size = 57500, upload-time = "2026-01-14T12:55:21.219Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d4/4a60fbe2e53b825f5d9a77325071d61cd8af8506255067bf0c8527530745/librt-0.7.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:44e0c2cbc9bebd074cf2cdbe472ca185e824be4e74b1c63a8e934cea674bebf2", size = 59019, upload-time = "2026-01-14T12:55:22.256Z" }, + { url = "https://files.pythonhosted.org/packages/6a/37/61ff80341ba5159afa524445f2d984c30e2821f31f7c73cf166dcafa5564/librt-0.7.8-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d2f1e492cae964b3463a03dc77a7fe8742f7855d7258c7643f0ee32b6651dd3", size = 169015, upload-time = "2026-01-14T12:55:23.24Z" }, + { url = "https://files.pythonhosted.org/packages/1c/86/13d4f2d6a93f181ebf2fc953868826653ede494559da8268023fe567fca3/librt-0.7.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:451e7ffcef8f785831fdb791bd69211f47e95dc4c6ddff68e589058806f044c6", size = 178161, upload-time = "2026-01-14T12:55:24.826Z" }, + { url = "https://files.pythonhosted.org/packages/88/26/e24ef01305954fc4d771f1f09f3dd682f9eb610e1bec188ffb719374d26e/librt-0.7.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3469e1af9f1380e093ae06bedcbdd11e407ac0b303a56bbe9afb1d6824d4982d", size = 193015, upload-time = "2026-01-14T12:55:26.04Z" }, + { url = "https://files.pythonhosted.org/packages/88/a0/92b6bd060e720d7a31ed474d046a69bd55334ec05e9c446d228c4b806ae3/librt-0.7.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f11b300027ce19a34f6d24ebb0a25fd0e24a9d53353225a5c1e6cadbf2916b2e", size = 192038, upload-time = "2026-01-14T12:55:27.208Z" }, + { url = "https://files.pythonhosted.org/packages/06/bb/6f4c650253704279c3a214dad188101d1b5ea23be0606628bc6739456624/librt-0.7.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4adc73614f0d3c97874f02f2c7fd2a27854e7e24ad532ea6b965459c5b757eca", size = 186006, upload-time = "2026-01-14T12:55:28.594Z" }, + { url = "https://files.pythonhosted.org/packages/dc/00/1c409618248d43240cadf45f3efb866837fa77e9a12a71481912135eb481/librt-0.7.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:60c299e555f87e4c01b2eca085dfccda1dde87f5a604bb45c2906b8305819a93", size = 206888, upload-time = "2026-01-14T12:55:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/d9/83/b2cfe8e76ff5c1c77f8a53da3d5de62d04b5ebf7cf913e37f8bca43b5d07/librt-0.7.8-cp313-cp313-win32.whl", hash = "sha256:b09c52ed43a461994716082ee7d87618096851319bf695d57ec123f2ab708951", size = 44126, upload-time = "2026-01-14T12:55:31.44Z" }, + { url = "https://files.pythonhosted.org/packages/a9/0b/c59d45de56a51bd2d3a401fc63449c0ac163e4ef7f523ea8b0c0dee86ec5/librt-0.7.8-cp313-cp313-win_amd64.whl", hash = "sha256:f8f4a901a3fa28969d6e4519deceab56c55a09d691ea7b12ca830e2fa3461e34", size = 50262, upload-time = "2026-01-14T12:55:33.01Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b9/973455cec0a1ec592395250c474164c4a58ebf3e0651ee920fef1a2623f1/librt-0.7.8-cp313-cp313-win_arm64.whl", hash = "sha256:43d4e71b50763fcdcf64725ac680d8cfa1706c928b844794a7aa0fa9ac8e5f09", size = 43600, upload-time = "2026-01-14T12:55:34.054Z" }, + { url = "https://files.pythonhosted.org/packages/1a/73/fa8814c6ce2d49c3827829cadaa1589b0bf4391660bd4510899393a23ebc/librt-0.7.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:be927c3c94c74b05128089a955fba86501c3b544d1d300282cc1b4bd370cb418", size = 57049, upload-time = "2026-01-14T12:55:35.056Z" }, + { url = "https://files.pythonhosted.org/packages/53/fe/f6c70956da23ea235fd2e3cc16f4f0b4ebdfd72252b02d1164dd58b4e6c3/librt-0.7.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7b0803e9008c62a7ef79058233db7ff6f37a9933b8f2573c05b07ddafa226611", size = 58689, upload-time = "2026-01-14T12:55:36.078Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4d/7a2481444ac5fba63050d9abe823e6bc16896f575bfc9c1e5068d516cdce/librt-0.7.8-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:79feb4d00b2a4e0e05c9c56df707934f41fcb5fe53fd9efb7549068d0495b758", size = 166808, upload-time = "2026-01-14T12:55:37.595Z" }, + { url = "https://files.pythonhosted.org/packages/ac/3c/10901d9e18639f8953f57c8986796cfbf4c1c514844a41c9197cf87cb707/librt-0.7.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b9122094e3f24aa759c38f46bd8863433820654927370250f460ae75488b66ea", size = 175614, upload-time = "2026-01-14T12:55:38.756Z" }, + { url = "https://files.pythonhosted.org/packages/db/01/5cbdde0951a5090a80e5ba44e6357d375048123c572a23eecfb9326993a7/librt-0.7.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e03bea66af33c95ce3addf87a9bf1fcad8d33e757bc479957ddbc0e4f7207ac", size = 189955, upload-time = "2026-01-14T12:55:39.939Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b4/e80528d2f4b7eaf1d437fcbd6fc6ba4cbeb3e2a0cb9ed5a79f47c7318706/librt-0.7.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f1ade7f31675db00b514b98f9ab9a7698c7282dad4be7492589109471852d398", size = 189370, upload-time = "2026-01-14T12:55:41.057Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ab/938368f8ce31a9787ecd4becb1e795954782e4312095daf8fd22420227c8/librt-0.7.8-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a14229ac62adcf1b90a15992f1ab9c69ae8b99ffb23cb64a90878a6e8a2f5b81", size = 183224, upload-time = "2026-01-14T12:55:42.328Z" }, + { url = "https://files.pythonhosted.org/packages/3c/10/559c310e7a6e4014ac44867d359ef8238465fb499e7eb31b6bfe3e3f86f5/librt-0.7.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5bcaaf624fd24e6a0cb14beac37677f90793a96864c67c064a91458611446e83", size = 203541, upload-time = "2026-01-14T12:55:43.501Z" }, + { url = "https://files.pythonhosted.org/packages/f8/db/a0db7acdb6290c215f343835c6efda5b491bb05c3ddc675af558f50fdba3/librt-0.7.8-cp314-cp314-win32.whl", hash = "sha256:7aa7d5457b6c542ecaed79cec4ad98534373c9757383973e638ccced0f11f46d", size = 40657, upload-time = "2026-01-14T12:55:44.668Z" }, + { url = "https://files.pythonhosted.org/packages/72/e0/4f9bdc2a98a798511e81edcd6b54fe82767a715e05d1921115ac70717f6f/librt-0.7.8-cp314-cp314-win_amd64.whl", hash = "sha256:3d1322800771bee4a91f3b4bd4e49abc7d35e65166821086e5afd1e6c0d9be44", size = 46835, upload-time = "2026-01-14T12:55:45.655Z" }, + { url = "https://files.pythonhosted.org/packages/f9/3d/59c6402e3dec2719655a41ad027a7371f8e2334aa794ed11533ad5f34969/librt-0.7.8-cp314-cp314-win_arm64.whl", hash = "sha256:5363427bc6a8c3b1719f8f3845ea53553d301382928a86e8fab7984426949bce", size = 39885, upload-time = "2026-01-14T12:55:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/4e/9c/2481d80950b83085fb14ba3c595db56330d21bbc7d88a19f20165f3538db/librt-0.7.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ca916919793a77e4a98d4a1701e345d337ce53be4a16620f063191f7322ac80f", size = 59161, upload-time = "2026-01-14T12:55:48.45Z" }, + { url = "https://files.pythonhosted.org/packages/96/79/108df2cfc4e672336765d54e3ff887294c1cc36ea4335c73588875775527/librt-0.7.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:54feb7b4f2f6706bb82325e836a01be805770443e2400f706e824e91f6441dde", size = 61008, upload-time = "2026-01-14T12:55:49.527Z" }, + { url = "https://files.pythonhosted.org/packages/46/f2/30179898f9994a5637459d6e169b6abdc982012c0a4b2d4c26f50c06f911/librt-0.7.8-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:39a4c76fee41007070f872b648cc2f711f9abf9a13d0c7162478043377b52c8e", size = 187199, upload-time = "2026-01-14T12:55:50.587Z" }, + { url = "https://files.pythonhosted.org/packages/b4/da/f7563db55cebdc884f518ba3791ad033becc25ff68eb70902b1747dc0d70/librt-0.7.8-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac9c8a458245c7de80bc1b9765b177055efff5803f08e548dd4bb9ab9a8d789b", size = 198317, upload-time = "2026-01-14T12:55:51.991Z" }, + { url = "https://files.pythonhosted.org/packages/b3/6c/4289acf076ad371471fa86718c30ae353e690d3de6167f7db36f429272f1/librt-0.7.8-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b67aa7eff150f075fda09d11f6bfb26edffd300f6ab1666759547581e8f666", size = 210334, upload-time = "2026-01-14T12:55:53.682Z" }, + { url = "https://files.pythonhosted.org/packages/4a/7f/377521ac25b78ac0a5ff44127a0360ee6d5ddd3ce7327949876a30533daa/librt-0.7.8-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:535929b6eff670c593c34ff435d5440c3096f20fa72d63444608a5aef64dd581", size = 211031, upload-time = "2026-01-14T12:55:54.827Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b1/e1e96c3e20b23d00cf90f4aad48f0deb4cdfec2f0ed8380d0d85acf98bbf/librt-0.7.8-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:63937bd0f4d1cb56653dc7ae900d6c52c41f0015e25aaf9902481ee79943b33a", size = 204581, upload-time = "2026-01-14T12:55:56.811Z" }, + { url = "https://files.pythonhosted.org/packages/43/71/0f5d010e92ed9747e14bef35e91b6580533510f1e36a8a09eb79ee70b2f0/librt-0.7.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cf243da9e42d914036fd362ac3fa77d80a41cadcd11ad789b1b5eec4daaf67ca", size = 224731, upload-time = "2026-01-14T12:55:58.175Z" }, + { url = "https://files.pythonhosted.org/packages/22/f0/07fb6ab5c39a4ca9af3e37554f9d42f25c464829254d72e4ebbd81da351c/librt-0.7.8-cp314-cp314t-win32.whl", hash = "sha256:171ca3a0a06c643bd0a2f62a8944e1902c94aa8e5da4db1ea9a8daf872685365", size = 41173, upload-time = "2026-01-14T12:55:59.315Z" }, + { url = "https://files.pythonhosted.org/packages/24/d4/7e4be20993dc6a782639625bd2f97f3c66125c7aa80c82426956811cfccf/librt-0.7.8-cp314-cp314t-win_amd64.whl", hash = "sha256:445b7304145e24c60288a2f172b5ce2ca35c0f81605f5299f3fa567e189d2e32", size = 47668, upload-time = "2026-01-14T12:56:00.261Z" }, + { url = "https://files.pythonhosted.org/packages/fc/85/69f92b2a7b3c0f88ffe107c86b952b397004b5b8ea5a81da3d9c04c04422/librt-0.7.8-cp314-cp314t-win_arm64.whl", hash = "sha256:8766ece9de08527deabcd7cb1b4f1a967a385d26e33e536d6d8913db6ef74f06", size = 40550, upload-time = "2026-01-14T12:56:01.542Z" }, +] + +[[package]] +name = "license-expression" +version = "30.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "boolean-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/71/d89bb0e71b1415453980fd32315f2a037aad9f7f70f695c7cec7035feb13/license_expression-30.4.4.tar.gz", hash = "sha256:73448f0aacd8d0808895bdc4b2c8e01a8d67646e4188f887375398c761f340fd", size = 186402, upload-time = "2025-07-22T11:13:32.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/40/791891d4c0c4dab4c5e187c17261cedc26285fd41541577f900470a45a4d/license_expression-30.4.4-py3-none-any.whl", hash = "sha256:421788fdcadb41f049d2dc934ce666626265aeccefddd25e162a26f23bcbf8a4", size = 120615, upload-time = "2025-07-22T11:13:31.217Z" }, +] + +[[package]] +name = "lxml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/d5/becbe1e2569b474a23f0c672ead8a29ac50b2dc1d5b9de184831bda8d14c/lxml-6.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:13e35cbc684aadf05d8711a5d1b5857c92e5e580efa9a0d2be197199c8def607", size = 8634365, upload-time = "2025-09-22T04:00:45.672Z" }, + { url = "https://files.pythonhosted.org/packages/28/66/1ced58f12e804644426b85d0bb8a4478ca77bc1761455da310505f1a3526/lxml-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b1675e096e17c6fe9c0e8c81434f5736c0739ff9ac6123c87c2d452f48fc938", size = 4650793, upload-time = "2025-09-22T04:00:47.783Z" }, + { url = "https://files.pythonhosted.org/packages/11/84/549098ffea39dfd167e3f174b4ce983d0eed61f9d8d25b7bf2a57c3247fc/lxml-6.0.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac6e5811ae2870953390452e3476694196f98d447573234592d30488147404d", size = 4944362, upload-time = "2025-09-22T04:00:49.845Z" }, + { url = "https://files.pythonhosted.org/packages/ac/bd/f207f16abf9749d2037453d56b643a7471d8fde855a231a12d1e095c4f01/lxml-6.0.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5aa0fc67ae19d7a64c3fe725dc9a1bb11f80e01f78289d05c6f62545affec438", size = 5083152, upload-time = "2025-09-22T04:00:51.709Z" }, + { url = "https://files.pythonhosted.org/packages/15/ae/bd813e87d8941d52ad5b65071b1affb48da01c4ed3c9c99e40abb266fbff/lxml-6.0.2-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de496365750cc472b4e7902a485d3f152ecf57bd3ba03ddd5578ed8ceb4c5964", size = 5023539, upload-time = "2025-09-22T04:00:53.593Z" }, + { url = "https://files.pythonhosted.org/packages/02/cd/9bfef16bd1d874fbe0cb51afb00329540f30a3283beb9f0780adbb7eec03/lxml-6.0.2-cp311-cp311-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:200069a593c5e40b8f6fc0d84d86d970ba43138c3e68619ffa234bc9bb806a4d", size = 5344853, upload-time = "2025-09-22T04:00:55.524Z" }, + { url = "https://files.pythonhosted.org/packages/b8/89/ea8f91594bc5dbb879734d35a6f2b0ad50605d7fb419de2b63d4211765cc/lxml-6.0.2-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d2de809c2ee3b888b59f995625385f74629707c9355e0ff856445cdcae682b7", size = 5225133, upload-time = "2025-09-22T04:00:57.269Z" }, + { url = "https://files.pythonhosted.org/packages/b9/37/9c735274f5dbec726b2db99b98a43950395ba3d4a1043083dba2ad814170/lxml-6.0.2-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:b2c3da8d93cf5db60e8858c17684c47d01fee6405e554fb55018dd85fc23b178", size = 4677944, upload-time = "2025-09-22T04:00:59.052Z" }, + { url = "https://files.pythonhosted.org/packages/20/28/7dfe1ba3475d8bfca3878365075abe002e05d40dfaaeb7ec01b4c587d533/lxml-6.0.2-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:442de7530296ef5e188373a1ea5789a46ce90c4847e597856570439621d9c553", size = 5284535, upload-time = "2025-09-22T04:01:01.335Z" }, + { url = "https://files.pythonhosted.org/packages/e7/cf/5f14bc0de763498fc29510e3532bf2b4b3a1c1d5d0dff2e900c16ba021ef/lxml-6.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2593c77efde7bfea7f6389f1ab249b15ed4aa5bc5cb5131faa3b843c429fbedb", size = 5067343, upload-time = "2025-09-22T04:01:03.13Z" }, + { url = "https://files.pythonhosted.org/packages/1c/b0/bb8275ab5472f32b28cfbbcc6db7c9d092482d3439ca279d8d6fa02f7025/lxml-6.0.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3e3cb08855967a20f553ff32d147e14329b3ae70ced6edc2f282b94afbc74b2a", size = 4725419, upload-time = "2025-09-22T04:01:05.013Z" }, + { url = "https://files.pythonhosted.org/packages/25/4c/7c222753bc72edca3b99dbadba1b064209bc8ed4ad448af990e60dcce462/lxml-6.0.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2ed6c667fcbb8c19c6791bbf40b7268ef8ddf5a96940ba9404b9f9a304832f6c", size = 5275008, upload-time = "2025-09-22T04:01:07.327Z" }, + { url = "https://files.pythonhosted.org/packages/6c/8c/478a0dc6b6ed661451379447cdbec77c05741a75736d97e5b2b729687828/lxml-6.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b8f18914faec94132e5b91e69d76a5c1d7b0c73e2489ea8929c4aaa10b76bbf7", size = 5248906, upload-time = "2025-09-22T04:01:09.452Z" }, + { url = "https://files.pythonhosted.org/packages/2d/d9/5be3a6ab2784cdf9accb0703b65e1b64fcdd9311c9f007630c7db0cfcce1/lxml-6.0.2-cp311-cp311-win32.whl", hash = "sha256:6605c604e6daa9e0d7f0a2137bdc47a2e93b59c60a65466353e37f8272f47c46", size = 3610357, upload-time = "2025-09-22T04:01:11.102Z" }, + { url = "https://files.pythonhosted.org/packages/e2/7d/ca6fb13349b473d5732fb0ee3eec8f6c80fc0688e76b7d79c1008481bf1f/lxml-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e5867f2651016a3afd8dd2c8238baa66f1e2802f44bc17e236f547ace6647078", size = 4036583, upload-time = "2025-09-22T04:01:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a2/51363b5ecd3eab46563645f3a2c3836a2fc67d01a1b87c5017040f39f567/lxml-6.0.2-cp311-cp311-win_arm64.whl", hash = "sha256:4197fb2534ee05fd3e7afaab5d8bfd6c2e186f65ea7f9cd6a82809c887bd1285", size = 3680591, upload-time = "2025-09-22T04:01:14.874Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887, upload-time = "2025-09-22T04:01:17.265Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818, upload-time = "2025-09-22T04:01:19.688Z" }, + { url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807, upload-time = "2025-09-22T04:01:21.487Z" }, + { url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179, upload-time = "2025-09-22T04:01:23.32Z" }, + { url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044, upload-time = "2025-09-22T04:01:25.118Z" }, + { url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685, upload-time = "2025-09-22T04:01:27.398Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127, upload-time = "2025-09-22T04:01:29.629Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958, upload-time = "2025-09-22T04:01:31.535Z" }, + { url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541, upload-time = "2025-09-22T04:01:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426, upload-time = "2025-09-22T04:01:35.639Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917, upload-time = "2025-09-22T04:01:37.448Z" }, + { url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795, upload-time = "2025-09-22T04:01:39.165Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759, upload-time = "2025-09-22T04:01:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666, upload-time = "2025-09-22T04:01:43.363Z" }, + { url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989, upload-time = "2025-09-22T04:01:45.215Z" }, + { url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456, upload-time = "2025-09-22T04:01:48.243Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793, upload-time = "2025-09-22T04:01:50.042Z" }, + { url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836, upload-time = "2025-09-22T04:01:52.145Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494, upload-time = "2025-09-22T04:01:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146, upload-time = "2025-09-22T04:01:56.282Z" }, + { url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932, upload-time = "2025-09-22T04:01:58.989Z" }, + { url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060, upload-time = "2025-09-22T04:02:00.812Z" }, + { url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000, upload-time = "2025-09-22T04:02:02.671Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496, upload-time = "2025-09-22T04:02:04.904Z" }, + { url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779, upload-time = "2025-09-22T04:02:06.689Z" }, + { url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072, upload-time = "2025-09-22T04:02:08.587Z" }, + { url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675, upload-time = "2025-09-22T04:02:10.783Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171, upload-time = "2025-09-22T04:02:12.631Z" }, + { url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175, upload-time = "2025-09-22T04:02:14.718Z" }, + { url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688, upload-time = "2025-09-22T04:02:16.957Z" }, + { url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655, upload-time = "2025-09-22T04:02:18.815Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695, upload-time = "2025-09-22T04:02:20.593Z" }, + { url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841, upload-time = "2025-09-22T04:02:22.489Z" }, + { url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700, upload-time = "2025-09-22T04:02:24.465Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347, upload-time = "2025-09-22T04:02:26.286Z" }, + { url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248, upload-time = "2025-09-22T04:02:27.918Z" }, + { url = "https://files.pythonhosted.org/packages/03/15/d4a377b385ab693ce97b472fe0c77c2b16ec79590e688b3ccc71fba19884/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe", size = 8659801, upload-time = "2025-09-22T04:02:30.113Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e8/c128e37589463668794d503afaeb003987373c5f94d667124ffd8078bbd9/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d", size = 4659403, upload-time = "2025-09-22T04:02:32.119Z" }, + { url = "https://files.pythonhosted.org/packages/00/ce/74903904339decdf7da7847bb5741fc98a5451b42fc419a86c0c13d26fe2/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d", size = 4966974, upload-time = "2025-09-22T04:02:34.155Z" }, + { url = "https://files.pythonhosted.org/packages/1f/d3/131dec79ce61c5567fecf82515bd9bc36395df42501b50f7f7f3bd065df0/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5", size = 5102953, upload-time = "2025-09-22T04:02:36.054Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ea/a43ba9bb750d4ffdd885f2cd333572f5bb900cd2408b67fdda07e85978a0/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0", size = 5055054, upload-time = "2025-09-22T04:02:38.154Z" }, + { url = "https://files.pythonhosted.org/packages/60/23/6885b451636ae286c34628f70a7ed1fcc759f8d9ad382d132e1c8d3d9bfd/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba", size = 5352421, upload-time = "2025-09-22T04:02:40.413Z" }, + { url = "https://files.pythonhosted.org/packages/48/5b/fc2ddfc94ddbe3eebb8e9af6e3fd65e2feba4967f6a4e9683875c394c2d8/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0", size = 5673684, upload-time = "2025-09-22T04:02:42.288Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/47293c58cc91769130fbf85531280e8cc7868f7fbb6d92f4670071b9cb3e/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d", size = 5252463, upload-time = "2025-09-22T04:02:44.165Z" }, + { url = "https://files.pythonhosted.org/packages/9b/da/ba6eceb830c762b48e711ded880d7e3e89fc6c7323e587c36540b6b23c6b/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37", size = 4698437, upload-time = "2025-09-22T04:02:46.524Z" }, + { url = "https://files.pythonhosted.org/packages/a5/24/7be3f82cb7990b89118d944b619e53c656c97dc89c28cfb143fdb7cd6f4d/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9", size = 5269890, upload-time = "2025-09-22T04:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bd/dcfb9ea1e16c665efd7538fc5d5c34071276ce9220e234217682e7d2c4a5/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917", size = 5097185, upload-time = "2025-09-22T04:02:50.746Z" }, + { url = "https://files.pythonhosted.org/packages/21/04/a60b0ff9314736316f28316b694bccbbabe100f8483ad83852d77fc7468e/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f", size = 4745895, upload-time = "2025-09-22T04:02:52.968Z" }, + { url = "https://files.pythonhosted.org/packages/d6/bd/7d54bd1846e5a310d9c715921c5faa71cf5c0853372adf78aee70c8d7aa2/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8", size = 5695246, upload-time = "2025-09-22T04:02:54.798Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/5643d6ab947bc371da21323acb2a6e603cedbe71cb4c99c8254289ab6f4e/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a", size = 5260797, upload-time = "2025-09-22T04:02:57.058Z" }, + { url = "https://files.pythonhosted.org/packages/33/da/34c1ec4cff1eea7d0b4cd44af8411806ed943141804ac9c5d565302afb78/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c", size = 5277404, upload-time = "2025-09-22T04:02:58.966Z" }, + { url = "https://files.pythonhosted.org/packages/82/57/4eca3e31e54dc89e2c3507e1cd411074a17565fa5ffc437c4ae0a00d439e/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b", size = 3670072, upload-time = "2025-09-22T04:03:38.05Z" }, + { url = "https://files.pythonhosted.org/packages/e3/e0/c96cf13eccd20c9421ba910304dae0f619724dcf1702864fd59dd386404d/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed", size = 4080617, upload-time = "2025-09-22T04:03:39.835Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5d/b3f03e22b3d38d6f188ef044900a9b29b2fe0aebb94625ce9fe244011d34/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8", size = 3754930, upload-time = "2025-09-22T04:03:41.565Z" }, + { url = "https://files.pythonhosted.org/packages/5e/5c/42c2c4c03554580708fc738d13414801f340c04c3eff90d8d2d227145275/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d", size = 8910380, upload-time = "2025-09-22T04:03:01.645Z" }, + { url = "https://files.pythonhosted.org/packages/bf/4f/12df843e3e10d18d468a7557058f8d3733e8b6e12401f30b1ef29360740f/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba", size = 4775632, upload-time = "2025-09-22T04:03:03.814Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0c/9dc31e6c2d0d418483cbcb469d1f5a582a1cd00a1f4081953d44051f3c50/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601", size = 4975171, upload-time = "2025-09-22T04:03:05.651Z" }, + { url = "https://files.pythonhosted.org/packages/e7/2b/9b870c6ca24c841bdd887504808f0417aa9d8d564114689266f19ddf29c8/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed", size = 5110109, upload-time = "2025-09-22T04:03:07.452Z" }, + { url = "https://files.pythonhosted.org/packages/bf/0c/4f5f2a4dd319a178912751564471355d9019e220c20d7db3fb8307ed8582/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37", size = 5041061, upload-time = "2025-09-22T04:03:09.297Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/554eed290365267671fe001a20d72d14f468ae4e6acef1e179b039436967/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338", size = 5306233, upload-time = "2025-09-22T04:03:11.651Z" }, + { url = "https://files.pythonhosted.org/packages/7a/31/1d748aa275e71802ad9722df32a7a35034246b42c0ecdd8235412c3396ef/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9", size = 5604739, upload-time = "2025-09-22T04:03:13.592Z" }, + { url = "https://files.pythonhosted.org/packages/8f/41/2c11916bcac09ed561adccacceaedd2bf0e0b25b297ea92aab99fd03d0fa/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd", size = 5225119, upload-time = "2025-09-22T04:03:15.408Z" }, + { url = "https://files.pythonhosted.org/packages/99/05/4e5c2873d8f17aa018e6afde417c80cc5d0c33be4854cce3ef5670c49367/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d", size = 4633665, upload-time = "2025-09-22T04:03:17.262Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c9/dcc2da1bebd6275cdc723b515f93edf548b82f36a5458cca3578bc899332/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9", size = 5234997, upload-time = "2025-09-22T04:03:19.14Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e2/5172e4e7468afca64a37b81dba152fc5d90e30f9c83c7c3213d6a02a5ce4/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e", size = 5090957, upload-time = "2025-09-22T04:03:21.436Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b3/15461fd3e5cd4ddcb7938b87fc20b14ab113b92312fc97afe65cd7c85de1/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d", size = 4764372, upload-time = "2025-09-22T04:03:23.27Z" }, + { url = "https://files.pythonhosted.org/packages/05/33/f310b987c8bf9e61c4dd8e8035c416bd3230098f5e3cfa69fc4232de7059/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec", size = 5634653, upload-time = "2025-09-22T04:03:25.767Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/51c80e75e0bc9382158133bdcf4e339b5886c6ee2418b5199b3f1a61ed6d/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272", size = 5233795, upload-time = "2025-09-22T04:03:27.62Z" }, + { url = "https://files.pythonhosted.org/packages/56/4d/4856e897df0d588789dd844dbed9d91782c4ef0b327f96ce53c807e13128/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f", size = 5257023, upload-time = "2025-09-22T04:03:30.056Z" }, + { url = "https://files.pythonhosted.org/packages/0f/85/86766dfebfa87bea0ab78e9ff7a4b4b45225df4b4d3b8cc3c03c5cd68464/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312", size = 3911420, upload-time = "2025-09-22T04:03:32.198Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1a/b248b355834c8e32614650b8008c69ffeb0ceb149c793961dd8c0b991bb3/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca", size = 4406837, upload-time = "2025-09-22T04:03:34.027Z" }, + { url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205, upload-time = "2025-09-22T04:03:36.249Z" }, + { url = "https://files.pythonhosted.org/packages/0b/11/29d08bc103a62c0eba8016e7ed5aeebbf1e4312e83b0b1648dd203b0e87d/lxml-6.0.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1c06035eafa8404b5cf475bb37a9f6088b0aca288d4ccc9d69389750d5543700", size = 3949829, upload-time = "2025-09-22T04:04:45.608Z" }, + { url = "https://files.pythonhosted.org/packages/12/b3/52ab9a3b31e5ab8238da241baa19eec44d2ab426532441ee607165aebb52/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c7d13103045de1bdd6fe5d61802565f1a3537d70cd3abf596aa0af62761921ee", size = 4226277, upload-time = "2025-09-22T04:04:47.754Z" }, + { url = "https://files.pythonhosted.org/packages/a0/33/1eaf780c1baad88224611df13b1c2a9dfa460b526cacfe769103ff50d845/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a3c150a95fbe5ac91de323aa756219ef9cf7fde5a3f00e2281e30f33fa5fa4f", size = 4330433, upload-time = "2025-09-22T04:04:49.907Z" }, + { url = "https://files.pythonhosted.org/packages/7a/c1/27428a2ff348e994ab4f8777d3a0ad510b6b92d37718e5887d2da99952a2/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60fa43be34f78bebb27812ed90f1925ec99560b0fa1decdb7d12b84d857d31e9", size = 4272119, upload-time = "2025-09-22T04:04:51.801Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d0/3020fa12bcec4ab62f97aab026d57c2f0cfd480a558758d9ca233bb6a79d/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21c73b476d3cfe836be731225ec3421fa2f048d84f6df6a8e70433dff1376d5a", size = 4417314, upload-time = "2025-09-22T04:04:55.024Z" }, + { url = "https://files.pythonhosted.org/packages/6c/77/d7f491cbc05303ac6801651aabeb262d43f319288c1ea96c66b1d2692ff3/lxml-6.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:27220da5be049e936c3aca06f174e8827ca6445a4353a1995584311487fc4e3e", size = 3518768, upload-time = "2025-09-22T04:04:57.097Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "mcp" +version = "1.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mypy" +version = "1.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539, upload-time = "2025-12-15T05:03:44.129Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163, upload-time = "2025-12-15T05:03:37.679Z" }, + { url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629, upload-time = "2025-12-15T05:02:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/2d/46/20f8a7114a56484ab268b0ab372461cb3a8f7deed31ea96b83a4e4cfcfca/mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331", size = 13436933, upload-time = "2025-12-15T05:03:15.606Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f8/33b291ea85050a21f15da910002460f1f445f8007adb29230f0adea279cb/mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925", size = 13661754, upload-time = "2025-12-15T05:02:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a3/47cbd4e85bec4335a9cd80cf67dbc02be21b5d4c9c23ad6b95d6c5196bac/mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042", size = 10055772, upload-time = "2025-12-15T05:03:26.179Z" }, + { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, + { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" }, + { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, + { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, + { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, + { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, + { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, + { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" }, + { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" }, + { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" }, + { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" }, + { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, +] + +[[package]] +name = "mypy-boto3-route53" +version = "1.42.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/43/04c8807291492850d72d14bcd01dde24a168c6a0da0012f3028b959ed99c/mypy_boto3_route53-1.42.6.tar.gz", hash = "sha256:e45905e5d8447d61ade57351a93e241a18527e3c5d58a334d90aa680e3126813", size = 38485, upload-time = "2025-12-09T23:12:52.505Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/07/5c6ebe114036847600a31384c7ca033a59fba5b9c9d4b7853db0479b104f/mypy_boto3_route53-1.42.6-py3-none-any.whl", hash = "sha256:afba8dc333ddcb901882b652cd8bea61aa57367cd45f2e1bdd96fa50aed257e9", size = 44698, upload-time = "2025-12-09T23:12:41.79Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/97/b9/3161be15bb8e3ad01be8be5a968a9237c3027c5be504362ff800fca3e442/opentelemetry_api-1.39.1.tar.gz", hash = "sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c", size = 65767, upload-time = "2025-12-11T13:32:39.182Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/df/d3f1ddf4bb4cb50ed9b1139cc7b1c54c34a1e7ce8fd1b9a37c0d1551a6bd/opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950", size = 66356, upload-time = "2025-12-11T13:32:17.304Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/fb/c76080c9ba07e1e8235d24cdcc4d125ef7aa3edf23eb4e497c2e50889adc/opentelemetry_sdk-1.39.1.tar.gz", hash = "sha256:cf4d4563caf7bff906c9f7967e2be22d0d6b349b908be0d90fb21c8e9c995cc6", size = 171460, upload-time = "2025-12-11T13:32:49.369Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/98/e91cf858f203d86f4eccdf763dcf01cf03f1dae80c3750f7e635bfa206b6/opentelemetry_sdk-1.39.1-py3-none-any.whl", hash = "sha256:4d5482c478513ecb0a5d938dcc61394e647066e0cc2676bee9f3af3f3f45f01c", size = 132565, upload-time = "2025-12-11T13:32:35.069Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.60b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/df/553f93ed38bf22f4b999d9be9c185adb558982214f33eae539d3b5cd0858/opentelemetry_semantic_conventions-0.60b1.tar.gz", hash = "sha256:87c228b5a0669b748c76d76df6c364c369c28f1c465e50f661e39737e84bc953", size = 137935, upload-time = "2025-12-11T13:32:50.487Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/5e/5958555e09635d09b75de3c4f8b9cae7335ca545d77392ffe7331534c402/opentelemetry_semantic_conventions-0.60b1-py3-none-any.whl", hash = "sha256:9fa8c8b0c110da289809292b0591220d3a7b53c1526a23021e977d68597893fb", size = 219982, upload-time = "2025-12-11T13:32:36.955Z" }, +] + +[[package]] +name = "packageurl-python" +version = "0.17.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/d6/3b5a4e3cfaef7a53869a26ceb034d1ff5e5c27c814ce77260a96d50ab7bb/packageurl_python-0.17.6.tar.gz", hash = "sha256:1252ce3a102372ca6f86eb968e16f9014c4ba511c5c37d95a7f023e2ca6e5c25", size = 50618, upload-time = "2025-11-24T15:20:17.998Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/2f/c7277b7615a93f51b5fbc1eacfc1b75e8103370e786fd8ce2abf6e5c04ab/packageurl_python-0.17.6-py3-none-any.whl", hash = "sha256:31a85c2717bc41dd818f3c62908685ff9eebcb68588213745b14a6ee9e7df7c9", size = 36776, upload-time = "2025-11-24T15:20:16.962Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pathspec" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, +] + +[[package]] +name = "pip-requirements-parser" +version = "32.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pyparsing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/2a/63b574101850e7f7b306ddbdb02cb294380d37948140eecd468fae392b54/pip-requirements-parser-32.0.1.tar.gz", hash = "sha256:b4fa3a7a0be38243123cf9d1f3518da10c51bdb165a2b2985566247f9155a7d3", size = 209359, upload-time = "2022-12-21T15:25:22.732Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/d0/d04f1d1e064ac901439699ee097f58688caadea42498ec9c4b4ad2ef84ab/pip_requirements_parser-32.0.1-py3-none-any.whl", hash = "sha256:4659bc2a667783e7a15d190f6fccf8b2486685b6dba4c19c3876314769c57526", size = 35648, upload-time = "2022-12-21T15:25:21.046Z" }, +] + +[[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 = "py-serializable" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "defusedxml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/73/21/d250cfca8ff30c2e5a7447bc13861541126ce9bd4426cd5d0c9f08b5547d/py_serializable-2.1.0.tar.gz", hash = "sha256:9d5db56154a867a9b897c0163b33a793c804c80cee984116d02d49e4578fc103", size = 52368, upload-time = "2025-07-21T09:56:48.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/bf/7595e817906a29453ba4d99394e781b6fabe55d21f3c15d240f85dd06bb1/py_serializable-2.1.0-py3-none-any.whl", hash = "sha256:b56d5d686b5a03ba4f4db5e769dc32336e142fc3bd4d68a8c25579ebb0a67304", size = 23045, upload-time = "2025-07-21T09:56:46.848Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +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/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +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/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, +] + +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151", size = 8697031, upload-time = "2025-07-14T20:13:13.266Z" }, + { url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308, upload-time = "2025-07-14T20:13:15.147Z" }, + { url = "https://files.pythonhosted.org/packages/44/7b/9c2ab54f74a138c491aba1b1cd0795ba61f144c711daea84a88b63dc0f6c/pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2", size = 8703930, upload-time = "2025-07-14T20:13:16.945Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, + { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "rfc3339-validator" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/ea/a9387748e2d111c3c2b275ba970b735e04e15cdb1eb30693b6b5708c4dbd/rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b", size = 5513, upload-time = "2021-05-12T16:37:54.178Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/44/4e421b96b67b2daff264473f7465db72fbdf36a07e05494f50300cc7b0c6/rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa", size = 3490, upload-time = "2021-05-12T16:37:52.536Z" }, +] + +[[package]] +name = "rfc3986-validator" +version = "0.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/88/f270de456dd7d11dcc808abfa291ecdd3f45ff44e3b549ffa01b126464d0/rfc3986_validator-0.1.1.tar.gz", hash = "sha256:3d44bde7921b3b9ec3ae4e3adca370438eccebc676456449b145d533b240d055", size = 6760, upload-time = "2019-10-28T16:00:19.144Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/51/17023c0f8f1869d8806b979a2bffa3f861f26a3f1a66b094288323fba52f/rfc3986_validator-0.1.1-py2.py3-none-any.whl", hash = "sha256:2f235c432ef459970b4306369336b9d5dbdda31b510ca1e327636e01f528bfa9", size = 4242, upload-time = "2019-10-28T16:00:13.976Z" }, +] + +[[package]] +name = "rfc3987-syntax" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lark" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2c/06/37c1a5557acf449e8e406a830a05bf885ac47d33270aec454ef78675008d/rfc3987_syntax-1.1.0.tar.gz", hash = "sha256:717a62cbf33cffdd16dfa3a497d81ce48a660ea691b1ddd7be710c22f00b4a0d", size = 14239, upload-time = "2025-07-18T01:05:05.015Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/71/44ce230e1b7fadd372515a97e32a83011f906ddded8d03e3c6aafbdedbb7/rfc3987_syntax-1.1.0-py3-none-any.whl", hash = "sha256:6c3d97604e4c5ce9f714898e05401a0445a641cfa276432b0a648c80856f6a3f", size = 8046, upload-time = "2025-07-18T01:05:03.843Z" }, +] + +[[package]] +name = "rich" +version = "14.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/99/a4cab2acbb884f80e558b0771e97e21e939c5dfb460f488d19df485e8298/rich-14.3.2.tar.gz", hash = "sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8", size = 230143, upload-time = "2026-02-01T16:20:47.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/45/615f5babd880b4bd7d405cc0dc348234c5ffb6ed1ea33e152ede08b2072d/rich-14.3.2-py3-none-any.whl", hash = "sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69", size = 309963, upload-time = "2026-02-01T16:20:46.078Z" }, +] + +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, + { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, + { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, + { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, + { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, + { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, + { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, + { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, + { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, + { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, + { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, + { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, + { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, + { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, + { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, + { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, + { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c8/39/5cee96809fbca590abea6b46c6d1c586b49663d1d2830a751cc8fc42c666/ruff-0.15.0.tar.gz", hash = "sha256:6bdea47cdbea30d40f8f8d7d69c0854ba7c15420ec75a26f463290949d7f7e9a", size = 4524893, upload-time = "2026-02-03T17:53:35.357Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/88/3fd1b0aa4b6330d6aaa63a285bc96c9f71970351579152d231ed90914586/ruff-0.15.0-py3-none-linux_armv6l.whl", hash = "sha256:aac4ebaa612a82b23d45964586f24ae9bc23ca101919f5590bdb368d74ad5455", size = 10354332, upload-time = "2026-02-03T17:52:54.892Z" }, + { url = "https://files.pythonhosted.org/packages/72/f6/62e173fbb7eb75cc29fe2576a1e20f0a46f671a2587b5f604bfb0eaf5f6f/ruff-0.15.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dcd4be7cc75cfbbca24a98d04d0b9b36a270d0833241f776b788d59f4142b14d", size = 10767189, upload-time = "2026-02-03T17:53:19.778Z" }, + { url = "https://files.pythonhosted.org/packages/99/e4/968ae17b676d1d2ff101d56dc69cf333e3a4c985e1ec23803df84fc7bf9e/ruff-0.15.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d747e3319b2bce179c7c1eaad3d884dc0a199b5f4d5187620530adf9105268ce", size = 10075384, upload-time = "2026-02-03T17:53:29.241Z" }, + { url = "https://files.pythonhosted.org/packages/a2/bf/9843c6044ab9e20af879c751487e61333ca79a2c8c3058b15722386b8cae/ruff-0.15.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:650bd9c56ae03102c51a5e4b554d74d825ff3abe4db22b90fd32d816c2e90621", size = 10481363, upload-time = "2026-02-03T17:52:43.332Z" }, + { url = "https://files.pythonhosted.org/packages/55/d9/4ada5ccf4cd1f532db1c8d44b6f664f2208d3d93acbeec18f82315e15193/ruff-0.15.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6664b7eac559e3048223a2da77769c2f92b43a6dfd4720cef42654299a599c9", size = 10187736, upload-time = "2026-02-03T17:53:00.522Z" }, + { url = "https://files.pythonhosted.org/packages/86/e2/f25eaecd446af7bb132af0a1d5b135a62971a41f5366ff41d06d25e77a91/ruff-0.15.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f811f97b0f092b35320d1556f3353bf238763420ade5d9e62ebd2b73f2ff179", size = 10968415, upload-time = "2026-02-03T17:53:15.705Z" }, + { url = "https://files.pythonhosted.org/packages/e7/dc/f06a8558d06333bf79b497d29a50c3a673d9251214e0d7ec78f90b30aa79/ruff-0.15.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:761ec0a66680fab6454236635a39abaf14198818c8cdf691e036f4bc0f406b2d", size = 11809643, upload-time = "2026-02-03T17:53:23.031Z" }, + { url = "https://files.pythonhosted.org/packages/dd/45/0ece8db2c474ad7df13af3a6d50f76e22a09d078af63078f005057ca59eb/ruff-0.15.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:940f11c2604d317e797b289f4f9f3fa5555ffe4fb574b55ed006c3d9b6f0eb78", size = 11234787, upload-time = "2026-02-03T17:52:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/8a/d9/0e3a81467a120fd265658d127db648e4d3acfe3e4f6f5d4ea79fac47e587/ruff-0.15.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bcbca3d40558789126da91d7ef9a7c87772ee107033db7191edefa34e2c7f1b4", size = 11112797, upload-time = "2026-02-03T17:52:49.274Z" }, + { url = "https://files.pythonhosted.org/packages/b2/cb/8c0b3b0c692683f8ff31351dfb6241047fa873a4481a76df4335a8bff716/ruff-0.15.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9a121a96db1d75fa3eb39c4539e607f628920dd72ff1f7c5ee4f1b768ac62d6e", size = 11033133, upload-time = "2026-02-03T17:53:33.105Z" }, + { url = "https://files.pythonhosted.org/packages/f8/5e/23b87370cf0f9081a8c89a753e69a4e8778805b8802ccfe175cc410e50b9/ruff-0.15.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5298d518e493061f2eabd4abd067c7e4fb89e2f63291c94332e35631c07c3662", size = 10442646, upload-time = "2026-02-03T17:53:06.278Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9a/3c94de5ce642830167e6d00b5c75aacd73e6347b4c7fc6828699b150a5ee/ruff-0.15.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:afb6e603d6375ff0d6b0cee563fa21ab570fd15e65c852cb24922cef25050cf1", size = 10195750, upload-time = "2026-02-03T17:53:26.084Z" }, + { url = "https://files.pythonhosted.org/packages/30/15/e396325080d600b436acc970848d69df9c13977942fb62bb8722d729bee8/ruff-0.15.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:77e515f6b15f828b94dc17d2b4ace334c9ddb7d9468c54b2f9ed2b9c1593ef16", size = 10676120, upload-time = "2026-02-03T17:53:09.363Z" }, + { url = "https://files.pythonhosted.org/packages/8d/c9/229a23d52a2983de1ad0fb0ee37d36e0257e6f28bfd6b498ee2c76361874/ruff-0.15.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6f6e80850a01eb13b3e42ee0ebdf6e4497151b48c35051aab51c101266d187a3", size = 11201636, upload-time = "2026-02-03T17:52:57.281Z" }, + { url = "https://files.pythonhosted.org/packages/6f/b0/69adf22f4e24f3677208adb715c578266842e6e6a3cc77483f48dd999ede/ruff-0.15.0-py3-none-win32.whl", hash = "sha256:238a717ef803e501b6d51e0bdd0d2c6e8513fe9eec14002445134d3907cd46c3", size = 10465945, upload-time = "2026-02-03T17:53:12.591Z" }, + { url = "https://files.pythonhosted.org/packages/51/ad/f813b6e2c97e9b4598be25e94a9147b9af7e60523b0cb5d94d307c15229d/ruff-0.15.0-py3-none-win_amd64.whl", hash = "sha256:dd5e4d3301dc01de614da3cdffc33d4b1b96fb89e45721f1598e5532ccf78b18", size = 11564657, upload-time = "2026-02-03T17:52:51.893Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b0/2d823f6e77ebe560f4e397d078487e8d52c1516b331e3521bc75db4272ca/ruff-0.15.0-py3-none-win_arm64.whl", hash = "sha256:c480d632cc0ca3f0727acac8b7d053542d9e114a462a145d0b00e7cd658c515a", size = 10865753, upload-time = "2026-02-03T17:53:03.014Z" }, +] + +[[package]] +name = "s3transfer" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/04/74127fc843314818edfa81b5540e26dd537353b123a4edc563109d8f17dd/s3transfer-0.16.0.tar.gz", hash = "sha256:8e990f13268025792229cd52fa10cb7163744bf56e719e0b9cb925ab79abf920", size = 153827, upload-time = "2025-12-01T02:30:59.114Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe", size = 86830, upload-time = "2025-12-01T02:30:57.729Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + +[[package]] +name = "sse-starlette" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/8d/00d280c03ffd39aaee0e86ec81e2d3b9253036a0f93f51d10503adef0e65/sse_starlette-3.2.0.tar.gz", hash = "sha256:8127594edfb51abe44eac9c49e59b0b01f1039d0c7461c6fd91d4e03b70da422", size = 27253, upload-time = "2026-01-17T13:11:05.62Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/7f/832f015020844a8b8f7a9cbc103dd76ba8e3875004c41e08440ea3a2b41a/sse_starlette-3.2.0-py3-none-any.whl", hash = "sha256:5876954bd51920fc2cd51baee47a080eb88a37b5b784e615abb0b283f801cdbf", size = 12763, upload-time = "2026-01-17T13:11:03.775Z" }, +] + +[[package]] +name = "starlette" +version = "0.52.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, +] + +[[package]] +name = "structlog" +version = "25.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/52/9ba0f43b686e7f3ddfeaa78ac3af750292662284b3661e91ad5494f21dbc/structlog-25.5.0.tar.gz", hash = "sha256:098522a3bebed9153d4570c6d0288abf80a031dfdb2048d59a49e9dc2190fc98", size = 1460830, upload-time = "2025-10-27T08:28:23.028Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/45/a132b9074aa18e799b891b91ad72133c98d8042c70f6240e4c5f9dabee2f/structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f", size = 72510, upload-time = "2025-10-27T08:28:21.535Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + +[[package]] +name = "typer" +version = "0.23.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/e6/44e073787aa57cd71c151f44855232feb0f748428fd5242d7366e3c4ae8b/typer-0.23.0.tar.gz", hash = "sha256:d8378833e47ada5d3d093fa20c4c63427cc4e27127f6b349a6c359463087d8cc", size = 120181, upload-time = "2026-02-11T15:22:18.637Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/ed/d6fca788b51d0d4640c4bc82d0e85bad4b49809bca36bf4af01b4dcb66a7/typer-0.23.0-py3-none-any.whl", hash = "sha256:79f4bc262b6c37872091072a3cb7cb6d7d79ee98c0c658b4364bdcde3c42c913", size = 56668, upload-time = "2026-02-11T15:22:21.075Z" }, +] + +[[package]] +name = "types-awscrt" +version = "0.31.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/97/be/589b7bba42b5681a72bac4d714287afef4e1bb84d07c859610ff631d449e/types_awscrt-0.31.1.tar.gz", hash = "sha256:08b13494f93f45c1a92eb264755fce50ed0d1dc75059abb5e31670feb9a09724", size = 17839, upload-time = "2026-01-16T02:01:23.394Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/fd/ddca80617f230bd833f99b4fb959abebffd8651f520493cae2e96276b1bd/types_awscrt-0.31.1-py3-none-any.whl", hash = "sha256:7e4364ac635f72bd57f52b093883640b1448a6eded0ecbac6e900bf4b1e4777b", size = 42516, upload-time = "2026-01-16T02:01:21.637Z" }, +] + +[[package]] +name = "types-s3transfer" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/64/42689150509eb3e6e82b33ee3d89045de1592488842ddf23c56957786d05/types_s3transfer-0.16.0.tar.gz", hash = "sha256:b4636472024c5e2b62278c5b759661efeb52a81851cde5f092f24100b1ecb443", size = 13557, upload-time = "2025-12-08T08:13:09.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/27/e88220fe6274eccd3bdf95d9382918716d312f6f6cef6a46332d1ee2feff/types_s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:1c0cd111ecf6e21437cb410f5cddb631bfb2263b77ad973e79b9c6d0cb24e0ef", size = 19247, upload-time = "2025-12-08T08:13:08.426Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2025.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, +] + +[[package]] +name = "uri-template" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/31/c7/0336f2bd0bcbada6ccef7aaa25e443c118a704f828a0620c6fa0207c1b64/uri-template-1.3.0.tar.gz", hash = "sha256:0e00f8eb65e18c7de20d595a14336e9f337ead580c70934141624b6d1ffdacc7", size = 21678, upload-time = "2023-06-21T01:49:05.374Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/00/3fca040d7cf8a32776d3d81a00c8ee7457e00f80c649f1e4a863c8321ae9/uri_template-1.3.0-py3-none-any.whl", hash = "sha256:a44a133ea12d44a0c0f06d7d42a52d71282e77e2f937d8abd5655b8d56fc1363", size = 11140, upload-time = "2023-06-21T01:49:03.467Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.40.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" }, +] + +[[package]] +name = "webcolors" +version = "25.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/7a/eb316761ec35664ea5174709a68bbd3389de60d4a1ebab8808bfc264ed67/webcolors-25.10.0.tar.gz", hash = "sha256:62abae86504f66d0f6364c2a8520de4a0c47b80c03fc3a5f1815fedbef7c19bf", size = 53491, upload-time = "2025-10-31T07:51:03.977Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/cc/e097523dd85c9cf5d354f78310927f1656c422bd7b2613b2db3e3f9a0f2c/webcolors-25.10.0-py3-none-any.whl", hash = "sha256:032c727334856fc0b968f63daa252a1ac93d33db2f5267756623c210e57a4f1d", size = 14905, upload-time = "2025-10-31T07:51:01.778Z" }, +] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, +] From c282133c8dc52b189a0d06df95ac1bd34cbe27e5 Mon Sep 17 00:00:00 2001 From: Igor Racic Date: Thu, 12 Feb 2026 13:36:39 +0100 Subject: [PATCH 2/3] fix: Route53/Cloudflare SVCB custom param demotion to TXT records MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route53 and Cloudflare reject private-use SvcParamKeys (key65001–key65006). Both backends now split standard SVCB params from custom BANDAID params (cap, cap-sha256, bap, policy, realm, sig), keeping standard params in SVCB and demoting custom params to TXT records with bandaid_ prefix. - Route53 backend: publish_agent() override with param demotion - Cloudflare backend: same demotion pattern applied - Correct version labels across docs (remove non-existent v0.7.0 refs) - Mark Kubernetes Controller as Planned in all documentation - 577 tests passing, 3 new param demotion tests for Route53 Signed-off-by: Igor Racic --- CHANGELOG.md | 22 +++++ README.md | 26 +++--- docs/api-reference.md | 9 ++- docs/architecture.md | 15 ++-- docs/demo-guide.md | 13 +-- docs/getting-started.md | 11 ++- src/dns_aid/backends/cloudflare.py | 73 ++++++++++++++++- src/dns_aid/backends/route53.py | 73 +++++++++++++++++ src/dns_aid/cli/main.py | 2 +- tests/unit/test_route53_backend.py | 126 +++++++++++++++++++++++++++++ 10 files changed, 341 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7c5ec4..a9f4c32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,28 @@ All notable changes to DNS-AID will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.6.0] - 2026-02-12 + +### Added +- **DNSSEC Enforcement** — `discover(require_dnssec=True)` checks the AD flag and raises `DNSSECError` if the response is unsigned +- **DANE Full Certificate Matching** — `verify(verify_dane_cert=True)` connects via TLS and compares the peer certificate against TLSA record data (SHA-256/SHA-512, full cert or SPKI selector) +- **Sigstore Release Signing** — Wheels, tarballs, and SBOMs are signed with Sigstore cosign (keyless OIDC) in the release workflow; `.sig` and `.pem` attestation files attached to GitHub Releases +- **Environment Variables Reference** — Documented all env vars (core, SDK, backend-specific) in `docs/getting-started.md` +- **Experimental Models Documentation** — Marked `agent_metadata` and `capability_model` modules as experimental with status docstrings + +### Fixed +- **Route53 SVCB custom params** — Route53 rejects private-use SvcParamKeys (`key65001`–`key65006`). The Route53 backend now demotes custom BANDAID params to TXT records with `bandaid_` prefix, keeping the publish working without data loss +- **Cloudflare SVCB custom params** — Same demotion applied to the Cloudflare backend +- **CLI `--backend` help text** — Now lists all five backends (route53, cloudflare, infoblox, ddns, mock) instead of just "route53, mock" +- **SECURITY.md contact** — Updated from placeholder LF mailing list to interim maintainer email +- **Bandit config** — Migrated from `.bandit` INI to `pyproject.toml` `[tool.bandit]` for newer bandit compatibility +- **CLI ANSI escape codes** — Stripped Rich/Typer ANSI codes in test assertions for Python 3.13 compatibility + +### Notes +- BIND/DDNS backends natively support custom SVCB params (`key65001`–`key65006`) — no demotion needed +- DNSSEC enforcement defaults to `False` (backwards compatible) +- DANE cert matching defaults to `False` (advisory TLSA existence check remains the default) + ## [0.5.1] - 2026-02-05 ### Fixed diff --git a/README.md b/README.md index 1842a67..3255b56 100644 --- a/README.md +++ b/README.md @@ -277,6 +277,11 @@ _booking._mcp._agents.example.com. SVCB 1 mcp.example.com. alpn="mcp" port=443 \ | `policy` | URI to agent policy document | | `realm` | Multi-tenant scope identifier | +> **Note:** Route 53 and Cloudflare do not support private-use SVCB SvcParamKeys (`key65001`–`key65006`). +> DNS-AID automatically demotes these parameters to TXT records with a `bandaid_` prefix (e.g., +> `bandaid_realm=production`), preserving all metadata without data loss. BIND/DDNS (RFC 2136) +> backends natively support custom SVCB params — no demotion needed. + This allows any DNS client to discover agents without proprietary protocols or central registries. ### Discovery Flow (BANDAID Draft Aligned) @@ -558,14 +563,17 @@ Infoblox UDDI (Universal DDI) is Infoblox's cloud-native DDI platform. DNS-AID s > The draft requires ServiceMode SVCB records (priority > 0) with mandatory `alpn` and `port` > parameters. Infoblox UDDI's limitation is a platform constraint, not a DNS-AID limitation. -| BANDAID Requirement | Route 53 | Infoblox UDDI | -|---------------------|----------|---------------| -| ServiceMode (priority > 0) | ✅ | ❌ | -| `alpn` parameter | ✅ | ❌ | -| `port` parameter | ✅ | ❌ | -| `mandatory` key | ✅ | ❌ | +| BANDAID Requirement | Route 53 | Cloudflare | DDNS (BIND) | Infoblox UDDI | +|---------------------|----------|------------|-------------|---------------| +| ServiceMode (priority > 0) | ✅ | ✅ | ✅ | ❌ | +| `alpn` parameter | ✅ | ✅ | ✅ | ❌ | +| `port` parameter | ✅ | ✅ | ✅ | ❌ | +| `mandatory` key | ✅ | ✅ | ✅ | ❌ | +| Custom SVCB params (`cap`, `realm`, etc.) | ⚠️ TXT | ⚠️ TXT | ✅ Native | ❌ | + +**⚠️ TXT** = Custom BANDAID params auto-demoted to TXT records with `bandaid_` prefix (no data loss). -**For full BANDAID compliance, use Route 53 or another RFC 9460-compliant DNS provider.** +**For full BANDAID compliance with native custom SVCB params, use DDNS (BIND/RFC 2136). Route 53 and Cloudflare support all standard SVCB params with automatic TXT demotion for custom params.** DNS-AID stores `alpn` and `port` in TXT records as a fallback for Infoblox UDDI, but this is a workaround and not standard-compliant for agent discovery. @@ -645,7 +653,7 @@ DDNS (Dynamic DNS) is a universal backend that works with any DNS server support - **Universal**: Works with BIND, Windows DNS, PowerDNS, Knot, and any RFC 2136 server - **No vendor lock-in**: Standard protocol, no proprietary APIs - **On-premise friendly**: Perfect for enterprise internal DNS -- **Full BANDAID compliance**: Supports ServiceMode SVCB with all parameters +- **Full BANDAID compliance**: Supports ServiceMode SVCB with all standard parameters (custom BANDAID params auto-demoted to TXT) ### Cloudflare Setup @@ -713,7 +721,7 @@ Cloudflare DNS is ideal for demos, workshops, and quick prototyping thanks to it - **SVCB support**: Full RFC 9460 compliance with SVCB Type 64 records - **Global anycast**: Fast DNS resolution worldwide - **Simple API**: Well-documented REST API v4 -- **Full BANDAID compliance**: Supports ServiceMode SVCB with all parameters +- **Full BANDAID compliance**: Supports ServiceMode SVCB with all standard parameters (custom BANDAID params auto-demoted to TXT) ## Why DNS-AID? diff --git a/docs/api-reference.md b/docs/api-reference.md index f5657e1..fc9ed24 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -22,7 +22,7 @@ Complete API documentation for DNS-AID - DNS-based Agent Identification and Disc - [CloudflareBackend](#cloudflarebackend) - [DDNSBackend](#ddnsbackend) - [MockBackend](#mockbackend) -- [Kubernetes Controller](#kubernetes-controller) +- [Kubernetes Controller (Planned)](#kubernetes-controller-planned) - [apply()](#apply) - [DesiredAgentState](#desiredagentstate) - [Annotations](#annotations) @@ -542,9 +542,12 @@ backend = MockBackend(zones={"example.com": {}}) --- -## Kubernetes Controller +## Kubernetes Controller (Planned) -The K8s controller automatically publishes agents based on Service/Ingress annotations. +> **Status: Planned** — The Kubernetes controller is not yet implemented in dns-aid-core. +> The API below documents the intended interface for a future release. + +The K8s controller will automatically publish agents based on Service/Ingress annotations. ### apply() diff --git a/docs/architecture.md b/docs/architecture.md index 5b20f73..6d7db68 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -270,7 +270,7 @@ community-observed reliability and latency, not just cost. ### LangGraph Integration Pattern -v0.6.0 adds a LangGraph Studio example demonstrating competitive agent selection: +The following LangGraph pattern illustrates how competitive agent selection could work (conceptual — no built-in LangGraph integration is shipped with dns-aid-core): ``` ┌──────────┐ ┌────────────┐ ┌────────┐ ┌────────┐ ┌────────┐ @@ -279,13 +279,16 @@ v0.6.0 adds a LangGraph Studio example demonstrating competitive agent selection └──────────┘ └────────────┘ └────────┘ └────────┘ └────────┘ ``` -See `examples/langgraph_studio/` and `Lang-demo.md` for the full implementation. +This pattern can be implemented with any orchestrator (LangGraph, LangChain, custom). --- -## Kubernetes Controller (v0.7.0) +## Kubernetes Controller (Planned) -DNS-AID includes a Python-first Kubernetes controller that automatically publishes +> **Status: Planned** — The Kubernetes controller is not yet implemented in dns-aid-core. +> The design below documents the intended architecture for a future release. + +DNS-AID plans to include a Python-first Kubernetes controller that automatically publishes agents to DNS based on Service/Ingress annotations. ### Design Principle: Python-First @@ -404,7 +407,7 @@ dns-aid-k8s --- -## JWS Signature Verification (v0.7.0) +## JWS Signature Verification (v0.5.0) DNS-AID provides application-layer signature verification as an alternative to DNSSEC for environments where DNSSEC cannot be enabled. @@ -582,7 +585,7 @@ significantly easier to deploy for organizations without DNSSEC capability. --- -## Backend API: get_record() Method (v0.7.0) +## Backend API: get_record() Method (v0.6.0) All DNS backends now implement `get_record()` for direct API-based record lookup: diff --git a/docs/demo-guide.md b/docs/demo-guide.md index e6b5593..cb39cbe 100644 --- a/docs/demo-guide.md +++ b/docs/demo-guide.md @@ -2,7 +2,7 @@ This guide walks through demonstrating DNS-AID's end-to-end agent discovery capabilities. Perfect for conference calls, IETF presentations, and Linux Foundation demos. -> **Version 0.7.0** - Python Kubernetes Controller for auto-publishing agents, JWS signatures for application-layer verification, plus all v0.6.0 features: community rankings, LangGraph Studio integration, and Tier 1 Execution Telemetry SDK. +> **Version 0.6.0** - DNSSEC enforcement, DANE full certificate matching, Sigstore release signing, Route53/Cloudflare SVCB param demotion, JWS signatures for application-layer verification, Tier 1 Execution Telemetry SDK, and community rankings. ## Prerequisites @@ -18,7 +18,7 @@ Run these checks before starting your demo: ```bash # 1. DNS-AID installed? dns-aid --version -# Expected: dns-aid, version 0.7.0 +# Expected: dns-aid, version 0.6.0 # 2. AWS credentials configured? aws sts get-caller-identity @@ -1030,9 +1030,12 @@ Found 1 agent(s): --- -## Demo 6: Kubernetes Controller (v0.7.0+) +## Demo 6: Kubernetes Controller (Planned) -The Python K8s Controller auto-publishes agents based on Service annotations. Uses idempotent reconciliation for GitOps workflows. +> **Status: Planned** — The Kubernetes controller is not yet implemented in dns-aid-core. +> The demo steps below document the intended workflow for a future release. + +The Python K8s Controller will auto-publish agents based on Service annotations, using idempotent reconciliation for GitOps workflows. ### Prerequisites @@ -1157,7 +1160,7 @@ kubectl delete service payment-agent --- -## Demo 7: JWS Signatures (v0.7.0+) +## Demo 7: JWS Signatures (v0.5.0+) JWS (JSON Web Signature) provides application-layer verification when DNSSEC isn't available (~70% of domains). diff --git a/docs/getting-started.md b/docs/getting-started.md index f38bca4..c7f1698 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -2,7 +2,7 @@ This guide will walk you through installing, configuring, and testing DNS-AID. -> **Version 0.7.0** - Adds Python Kubernetes Controller for auto-publishing agents and JWS signatures for application-layer verification when DNSSEC isn't available. Plus v0.6.0 features: `fetch_rankings()` for community-wide telemetry rankings, LangGraph Studio integration, and competitive agent selection based on cost + reliability. +> **Version 0.6.0** - Adds DNSSEC enforcement (`require_dnssec=True`), DANE full certificate matching (`verify_dane_cert=True`), Sigstore release signing, Route53/Cloudflare SVCB custom param demotion to TXT, and environment variable documentation. ## Prerequisites @@ -659,9 +659,12 @@ asyncio.run(main()) ``` -## Kubernetes Controller (v0.7.0+) +## Kubernetes Controller (Planned) -The Python Kubernetes Controller auto-publishes agents based on Service/Ingress annotations. Uses idempotent reconciliation for reliable GitOps workflows. +> **Status: Planned** — The Kubernetes controller is not yet implemented in dns-aid-core. +> The examples below document the intended usage for a future release. + +The Python Kubernetes Controller will auto-publish agents based on Service/Ingress annotations, using idempotent reconciliation for reliable GitOps workflows. ### Quick Start @@ -724,7 +727,7 @@ The controller uses the `apply()` idempotent reconciliation pattern — all life --- -## JWS Signatures (v0.7.0+) +## JWS Signatures (v0.5.0+) JWS (JSON Web Signature) provides application-layer verification when DNSSEC isn't available (~70% of domains). Signatures are embedded in SVCB records and verified against a JWKS published at `.well-known/dns-aid-jwks.json`. diff --git a/src/dns_aid/backends/cloudflare.py b/src/dns_aid/backends/cloudflare.py index e3853ad..a8f5758 100644 --- a/src/dns_aid/backends/cloudflare.py +++ b/src/dns_aid/backends/cloudflare.py @@ -9,15 +9,31 @@ import os from collections.abc import AsyncIterator -from typing import Any +from typing import TYPE_CHECKING, Any import httpx import structlog from dns_aid.backends.base import DNSBackend +if TYPE_CHECKING: + from dns_aid.core.models import AgentRecord + logger = structlog.get_logger(__name__) +# Standard SVCB SvcParamKeys that managed DNS providers accept (RFC 9460). +# Cloudflare rejects private-use keys (key65001–key65534) the same way +# Route53 does. Custom BANDAID params are demoted to TXT automatically. +_CLOUDFLARE_SVCB_KEYS = frozenset({ + "mandatory", + "alpn", + "no-default-alpn", + "port", + "ipv4hint", + "ipv6hint", + "ech", +}) + class CloudflareBackend(DNSBackend): """ @@ -328,6 +344,61 @@ async def create_txt_record( return fqdn + async def publish_agent(self, agent: AgentRecord) -> list[str]: + """ + Publish an agent to DNS, demoting unsupported SVCB params to TXT. + + Cloudflare only accepts standard RFC 9460 SvcParamKeys. Custom BANDAID + params (key65001–key65006) are automatically moved to the TXT record. + """ + records: list[str] = [] + zone = agent.domain + name = f"_{agent.name}._{agent.protocol.value}._agents" + + # Split params: standard → SVCB, custom → TXT fallback + all_params = agent.to_svcb_params() + standard_params: dict[str, str] = {} + custom_params: dict[str, str] = {} + + for key, value in all_params.items(): + if key in _CLOUDFLARE_SVCB_KEYS: + standard_params[key] = value + else: + custom_params[key] = value + + if custom_params: + logger.warning( + "Cloudflare does not support custom SVCB params; demoting to TXT", + demoted_keys=list(custom_params.keys()), + ) + + # Create SVCB record with standard params only + svcb_fqdn = await self.create_svcb_record( + zone=zone, + name=name, + priority=1, + target=agent.svcb_target, + params=standard_params, + ttl=agent.ttl, + ) + records.append(f"SVCB {svcb_fqdn}") + + # Build TXT values: capabilities/metadata + demoted BANDAID params + txt_values = agent.to_txt_values() + for key, value in custom_params.items(): + txt_values.append(f"bandaid_{key}={value}") + + if txt_values: + txt_fqdn = await self.create_txt_record( + zone=zone, + name=name, + values=txt_values, + ttl=agent.ttl, + ) + records.append(f"TXT {txt_fqdn}") + + return records + async def delete_record( self, zone: str, diff --git a/src/dns_aid/backends/route53.py b/src/dns_aid/backends/route53.py index 62d17c5..ae39605 100644 --- a/src/dns_aid/backends/route53.py +++ b/src/dns_aid/backends/route53.py @@ -18,8 +18,25 @@ if TYPE_CHECKING: from mypy_boto3_route53 import Route53Client + from dns_aid.core.models import AgentRecord + logger = structlog.get_logger(__name__) +# Standard SVCB SvcParamKeys that Route53 accepts (RFC 9460). +# Route53 rejects private-use keys (key65001–key65534) with +# "SVCB does not support undefined parameters." +# When publishing, custom BANDAID params are automatically demoted +# to TXT records so the publish succeeds. +_ROUTE53_SVCB_KEYS = frozenset({ + "mandatory", + "alpn", + "no-default-alpn", + "port", + "ipv4hint", + "ipv6hint", + "ech", +}) + class Route53Backend(DNSBackend): """ @@ -270,6 +287,62 @@ async def create_txt_record( return fqdn.rstrip(".") + async def publish_agent(self, agent: AgentRecord) -> list[str]: + """ + Publish an agent to DNS, demoting unsupported SVCB params to TXT. + + Route53 only accepts standard RFC 9460 SvcParamKeys. Custom BANDAID + params (key65001–key65006) are automatically moved to the TXT record + so the publish succeeds without data loss. + """ + records: list[str] = [] + zone = agent.domain + name = f"_{agent.name}._{agent.protocol.value}._agents" + + # Split params: standard → SVCB, custom → TXT fallback + all_params = agent.to_svcb_params() + standard_params: dict[str, str] = {} + custom_params: dict[str, str] = {} + + for key, value in all_params.items(): + if key in _ROUTE53_SVCB_KEYS: + standard_params[key] = value + else: + custom_params[key] = value + + if custom_params: + logger.warning( + "Route53 does not support custom SVCB params; demoting to TXT", + demoted_keys=list(custom_params.keys()), + ) + + # Create SVCB record with standard params only + svcb_fqdn = await self.create_svcb_record( + zone=zone, + name=name, + priority=1, + target=agent.svcb_target, + params=standard_params, + ttl=agent.ttl, + ) + records.append(f"SVCB {svcb_fqdn}") + + # Build TXT values: capabilities/metadata + demoted BANDAID params + txt_values = agent.to_txt_values() + for key, value in custom_params.items(): + txt_values.append(f"bandaid_{key}={value}") + + if txt_values: + txt_fqdn = await self.create_txt_record( + zone=zone, + name=name, + values=txt_values, + ttl=agent.ttl, + ) + records.append(f"TXT {txt_fqdn}") + + return records + async def delete_record( self, zone: str, diff --git a/src/dns_aid/cli/main.py b/src/dns_aid/cli/main.py index 081abe0..197cd3b 100644 --- a/src/dns_aid/cli/main.py +++ b/src/dns_aid/cli/main.py @@ -83,7 +83,7 @@ def publish( ] = None, ttl: Annotated[int, typer.Option("--ttl", help="DNS TTL in seconds")] = 3600, backend: Annotated[ - str, typer.Option("--backend", "-b", help="DNS backend: route53, mock") + str, typer.Option("--backend", "-b", help="DNS backend: route53, cloudflare, infoblox, ddns, mock") ] = "route53", cap_uri: Annotated[ str | None, diff --git a/tests/unit/test_route53_backend.py b/tests/unit/test_route53_backend.py index 30e3c78..65f60c8 100644 --- a/tests/unit/test_route53_backend.py +++ b/tests/unit/test_route53_backend.py @@ -558,3 +558,129 @@ async def test_wait_for_change_timeout(self): ): result = await backend.wait_for_change("/change/C123", max_wait=2) assert result is False + + +class TestRoute53PublishAgentParamDemotion: + """Tests for custom SVCB param demotion to TXT on Route53.""" + + @pytest.mark.asyncio + async def test_publish_strips_custom_svcb_params(self): + """Custom BANDAID params (key65001+) must not appear in SVCB record.""" + from dns_aid.core.models import AgentRecord, Protocol + + agent = AgentRecord( + name="lf-test", + domain="example.com", + protocol=Protocol.MCP, + target_host="lf-test.example.com", + port=443, + capabilities=["testing"], + realm="demo", + ) + + backend = Route53Backend(zone_id="Z123") + mock_client = MagicMock() + mock_client.change_resource_record_sets.return_value = { + "ChangeInfo": {"Id": "/change/C1"} + } + + with patch.object(backend, "_get_client", return_value=mock_client): + records = await backend.publish_agent(agent) + + # Should create both SVCB and TXT + assert len(records) == 2 + assert records[0].startswith("SVCB") + assert records[1].startswith("TXT") + + # Inspect the SVCB call — must NOT contain key65005 + svcb_call = mock_client.change_resource_record_sets.call_args_list[0] + svcb_value = svcb_call[1]["ChangeBatch"]["Changes"][0]["ResourceRecordSet"][ + "ResourceRecords" + ][0]["Value"] + assert "key65005" not in svcb_value + assert "alpn" in svcb_value + assert "port" in svcb_value + + # Inspect the TXT call — must contain the demoted realm + txt_call = mock_client.change_resource_record_sets.call_args_list[1] + txt_values = txt_call[1]["ChangeBatch"]["Changes"][0]["ResourceRecordSet"][ + "ResourceRecords" + ] + txt_strings = [v["Value"] for v in txt_values] + assert any("bandaid_key65005=demo" in s for s in txt_strings) + + @pytest.mark.asyncio + async def test_publish_no_custom_params_unchanged(self): + """When no custom params, behavior matches base class.""" + from dns_aid.core.models import AgentRecord, Protocol + + agent = AgentRecord( + name="simple", + domain="example.com", + protocol=Protocol.A2A, + target_host="simple.example.com", + port=443, + capabilities=["chat"], + ) + + backend = Route53Backend(zone_id="Z123") + mock_client = MagicMock() + mock_client.change_resource_record_sets.return_value = { + "ChangeInfo": {"Id": "/change/C2"} + } + + with patch.object(backend, "_get_client", return_value=mock_client): + records = await backend.publish_agent(agent) + + assert len(records) == 2 + # No bandaid_ entries in TXT + txt_call = mock_client.change_resource_record_sets.call_args_list[1] + txt_values = txt_call[1]["ChangeBatch"]["Changes"][0]["ResourceRecordSet"][ + "ResourceRecords" + ] + txt_strings = [v["Value"] for v in txt_values] + assert not any("bandaid_" in s for s in txt_strings) + + @pytest.mark.asyncio + async def test_publish_demotes_multiple_custom_params(self): + """All custom BANDAID params get demoted to TXT.""" + from dns_aid.core.models import AgentRecord, Protocol + + agent = AgentRecord( + name="full", + domain="example.com", + protocol=Protocol.MCP, + target_host="full.example.com", + port=443, + capabilities=["dns"], + realm="production", + policy_uri="urn:policy:strict", + bap=["mcp/1", "a2a/1"], + ) + + backend = Route53Backend(zone_id="Z123") + mock_client = MagicMock() + mock_client.change_resource_record_sets.return_value = { + "ChangeInfo": {"Id": "/change/C3"} + } + + with patch.object(backend, "_get_client", return_value=mock_client): + await backend.publish_agent(agent) + + # SVCB must be clean of all custom keys + svcb_call = mock_client.change_resource_record_sets.call_args_list[0] + svcb_value = svcb_call[1]["ChangeBatch"]["Changes"][0]["ResourceRecordSet"][ + "ResourceRecords" + ][0]["Value"] + for custom_key in ("key65003", "key65004", "key65005"): + assert custom_key not in svcb_value + + # TXT must contain all three demoted params + txt_call = mock_client.change_resource_record_sets.call_args_list[1] + txt_values = txt_call[1]["ChangeBatch"]["Changes"][0]["ResourceRecordSet"][ + "ResourceRecords" + ] + txt_strings = " ".join(v["Value"] for v in txt_values) + assert "bandaid_key65003" in txt_strings # bap + assert "bandaid_key65004" in txt_strings # policy + assert "bandaid_key65005" in txt_strings # realm From 151462c151a6877e3c8038b98b5a91c847910041 Mon Sep 17 00:00:00 2001 From: Igor Racic Date: Thu, 12 Feb 2026 13:37:07 +0100 Subject: [PATCH 3/3] fix: reduce C901 complexity and boost test coverage from 72% to 80% Refactor 5 functions exceeding cognitive complexity threshold of 10 by extracting private helpers with no public API changes. Add 87 new tests covering CLI commands, publisher, SDK client, validator, indexer, Cloudflare backend, OTEL telemetry, and discoverer edge cases. Complexity refactoring: - discoverer.py: extract 7 helpers from discover/zone/http functions - cap_fetcher.py: extract _verify_cap_digest, _extract_string_list - otel.py: extract _parse_signal_fqdn, _build_span_attributes Test coverage additions: - CLI: publish, delete, list, zones, index list/sync, verify commands - Publisher: default backend, sign validation, exception handling - SDK client: async context, rankings fetch, HTTP sync push - Validator: DANE cert match/mismatch, DNSSEC TXT fallback - Indexer: DNS reads, delete/sync exceptions, equality checks - Cloudflare: param demotion, get_record SVCB/TXT/not-found - OTEL: span attributes, FQDN parsing, shutdown idempotency Result: 664 tests passing, 0 C901 violations, 80% coverage, ruff/bandit clean Signed-off-by: Igor Racic --- src/dns_aid/backends/cloudflare.py | 20 +- src/dns_aid/backends/route53.py | 20 +- src/dns_aid/cli/main.py | 5 +- src/dns_aid/core/cap_fetcher.py | 70 ++-- src/dns_aid/core/discoverer.py | 329 +++++++++--------- src/dns_aid/sdk/telemetry/otel.py | 43 ++- tests/unit/sdk/test_otel.py | 153 +++++++++ tests/unit/sdk/test_sdk_client.py | 154 +++++++++ tests/unit/test_cli.py | 464 +++++++++++++++++++++++++- tests/unit/test_cloudflare_backend.py | 220 ++++++++++++ tests/unit/test_discoverer.py | 192 +++++++++++ tests/unit/test_indexer.py | 123 +++++++ tests/unit/test_publisher.py | 156 +++++++++ tests/unit/test_validator.py | 119 +++++++ 14 files changed, 1847 insertions(+), 221 deletions(-) create mode 100644 tests/unit/sdk/test_sdk_client.py diff --git a/src/dns_aid/backends/cloudflare.py b/src/dns_aid/backends/cloudflare.py index a8f5758..65887d6 100644 --- a/src/dns_aid/backends/cloudflare.py +++ b/src/dns_aid/backends/cloudflare.py @@ -24,15 +24,17 @@ # Standard SVCB SvcParamKeys that managed DNS providers accept (RFC 9460). # Cloudflare rejects private-use keys (key65001–key65534) the same way # Route53 does. Custom BANDAID params are demoted to TXT automatically. -_CLOUDFLARE_SVCB_KEYS = frozenset({ - "mandatory", - "alpn", - "no-default-alpn", - "port", - "ipv4hint", - "ipv6hint", - "ech", -}) +_CLOUDFLARE_SVCB_KEYS = frozenset( + { + "mandatory", + "alpn", + "no-default-alpn", + "port", + "ipv4hint", + "ipv6hint", + "ech", + } +) class CloudflareBackend(DNSBackend): diff --git a/src/dns_aid/backends/route53.py b/src/dns_aid/backends/route53.py index ae39605..06eaf6c 100644 --- a/src/dns_aid/backends/route53.py +++ b/src/dns_aid/backends/route53.py @@ -27,15 +27,17 @@ # "SVCB does not support undefined parameters." # When publishing, custom BANDAID params are automatically demoted # to TXT records so the publish succeeds. -_ROUTE53_SVCB_KEYS = frozenset({ - "mandatory", - "alpn", - "no-default-alpn", - "port", - "ipv4hint", - "ipv6hint", - "ech", -}) +_ROUTE53_SVCB_KEYS = frozenset( + { + "mandatory", + "alpn", + "no-default-alpn", + "port", + "ipv4hint", + "ipv6hint", + "ech", + } +) class Route53Backend(DNSBackend): diff --git a/src/dns_aid/cli/main.py b/src/dns_aid/cli/main.py index 197cd3b..3f16d67 100644 --- a/src/dns_aid/cli/main.py +++ b/src/dns_aid/cli/main.py @@ -83,7 +83,10 @@ def publish( ] = None, ttl: Annotated[int, typer.Option("--ttl", help="DNS TTL in seconds")] = 3600, backend: Annotated[ - str, typer.Option("--backend", "-b", help="DNS backend: route53, cloudflare, infoblox, ddns, mock") + str, + typer.Option( + "--backend", "-b", help="DNS backend: route53, cloudflare, infoblox, ddns, mock" + ), ] = "route53", cap_uri: Annotated[ str | None, diff --git a/src/dns_aid/core/cap_fetcher.py b/src/dns_aid/core/cap_fetcher.py index 3895d46..1034f83 100644 --- a/src/dns_aid/core/cap_fetcher.py +++ b/src/dns_aid/core/cap_fetcher.py @@ -40,6 +40,36 @@ class CapabilityDocument: metadata: dict[str, Any] = field(default_factory=dict) +def _verify_cap_digest(content: bytes, expected_sha256: str, cap_uri: str) -> bool: + """Verify SHA-256 digest of capability document content. + + Returns True if digest matches, False otherwise. + """ + import base64 + import hashlib + + actual_digest = ( + base64.urlsafe_b64encode(hashlib.sha256(content).digest()).rstrip(b"=").decode("ascii") + ) + if actual_digest != expected_sha256: + logger.warning( + "Cap document SHA-256 mismatch", + cap_uri=cap_uri, + expected=expected_sha256, + actual=actual_digest, + ) + return False + return True + + +def _extract_string_list(data: dict[str, Any], key: str) -> list[str]: + """Extract and validate a list-of-strings field from capability data.""" + value = data.get(key, []) + if isinstance(value, list): + return [str(item) for item in value if item] + return [] + + async def fetch_cap_document( cap_uri: str, timeout: float = 10.0, @@ -83,24 +113,10 @@ async def fetch_cap_document( ) return None - # Verify cap_sha256 integrity if expected digest is provided - if expected_sha256: - import base64 - import hashlib - - actual_digest = ( - base64.urlsafe_b64encode(hashlib.sha256(response.content).digest()) - .rstrip(b"=") - .decode("ascii") - ) - if actual_digest != expected_sha256: - logger.warning( - "Cap document SHA-256 mismatch", - cap_uri=cap_uri, - expected=expected_sha256, - actual=actual_digest, - ) - return None + if expected_sha256 and not _verify_cap_digest( + response.content, expected_sha256, cap_uri + ): + return None data = response.json() @@ -111,21 +127,9 @@ async def fetch_cap_document( ) return None - # Parse capabilities (required field) - capabilities = data.get("capabilities", []) - if isinstance(capabilities, list): - capabilities = [str(c) for c in capabilities if c] - else: - capabilities = [] - - # Parse optional fields - use_cases = data.get("use_cases", []) - if isinstance(use_cases, list): - use_cases = [str(u) for u in use_cases if u] - else: - use_cases = [] - - # Collect remaining fields as metadata + capabilities = _extract_string_list(data, "capabilities") + use_cases = _extract_string_list(data, "use_cases") + known_keys = {"capabilities", "version", "description", "use_cases"} metadata = {k: v for k, v in data.items() if k not in known_keys} diff --git a/src/dns_aid/core/discoverer.py b/src/dns_aid/core/discoverer.py index 0be38b1..194af03 100644 --- a/src/dns_aid/core/discoverer.py +++ b/src/dns_aid/core/discoverer.py @@ -9,7 +9,7 @@ import asyncio import time -from typing import Literal +from typing import Any, Literal from urllib.parse import urlparse import dns.asyncresolver @@ -25,6 +25,75 @@ logger = structlog.get_logger(__name__) +def _normalize_protocol(protocol: str | Protocol | None) -> Protocol | None: + """Convert string protocol to Protocol enum if needed.""" + if isinstance(protocol, str): + return Protocol(protocol.lower()) + return protocol + + +async def _execute_discovery( + domain: str, + protocol: Protocol | None, + name: str | None, + use_http_index: bool, + query: str, +) -> list[AgentRecord]: + """Execute the appropriate discovery strategy and handle DNS errors.""" + try: + if use_http_index: + return await _discover_via_http_index(domain, protocol, name) + elif name and protocol: + agent = await _query_single_agent(domain, name, protocol) + return [agent] if agent else [] + else: + return await _discover_agents_in_zone(domain, protocol) + except dns.resolver.NXDOMAIN: + logger.debug("No DNS-AID records found", query=query) + except dns.resolver.NoAnswer: + logger.debug("No answer for query", query=query) + except dns.resolver.NoNameservers: + logger.error("No nameservers available", domain=domain) + except Exception as e: + logger.exception("DNS query failed", error=str(e)) + return [] + + +async def _apply_post_discovery( + agents: list[AgentRecord], + require_dnssec: bool, + enrich_endpoints: bool, + verify_signatures: bool, + domain: str, +) -> bool: + """Apply DNSSEC enforcement, endpoint enrichment, and JWS verification. + + Returns whether DNSSEC was validated. + """ + dnssec_validated = False + + if agents and require_dnssec: + from dns_aid.core.validator import _check_dnssec + + dnssec_validated = await _check_dnssec(agents[0].fqdn) + if not dnssec_validated: + raise DNSSECError( + f"DNSSEC validation required but DNS response for " + f"{agents[0].fqdn} is not authenticated (AD flag not set)" + ) + + if enrich_endpoints and agents: + try: + await _enrich_agents_with_endpoint_paths(agents) + except Exception: + logger.debug("Endpoint enrichment failed (non-fatal)", exc_info=True) + + if verify_signatures and agents: + await _verify_agent_signatures(agents, domain, dnssec_validated) + + return dnssec_validated + + async def discover( domain: str, protocol: str | Protocol | None = None, @@ -68,22 +137,16 @@ async def discover( """ start_time = time.perf_counter() - # Normalize protocol - if isinstance(protocol, str): - protocol = Protocol(protocol.lower()) + protocol = _normalize_protocol(protocol) # Build query based on filters if name and protocol: - # Specific agent query = f"_{name}._{protocol.value}._agents.{domain}" elif protocol: - # All agents with specific protocol - query index query = f"_index._{protocol.value}._agents.{domain}" else: - # All agents - query general index query = f"_index._agents.{domain}" - # Adjust query string for HTTP index mode if use_http_index: query = f"https://_index._aiagents.{domain}/index-wellknown" @@ -96,52 +159,10 @@ async def discover( use_http_index=use_http_index, ) - agents: list[AgentRecord] = [] - dnssec_validated = False - - try: - if use_http_index: - # Use HTTP index to discover agents - agents = await _discover_via_http_index(domain, protocol, name) - elif name and protocol: - # First try specific query if name is provided - agent = await _query_single_agent(domain, name, protocol) - if agent: - agents.append(agent) - else: - # Try to discover multiple agents via DNS - agents = await _discover_agents_in_zone(domain, protocol) - - except dns.resolver.NXDOMAIN: - logger.debug("No DNS-AID records found", query=query) - except dns.resolver.NoAnswer: - logger.debug("No answer for query", query=query) - except dns.resolver.NoNameservers: - logger.error("No nameservers available", domain=domain) - except Exception as e: - logger.exception("DNS query failed", error=str(e)) - - # DNSSEC enforcement: check AD flag and raise if required but unsigned - if agents and require_dnssec: - from dns_aid.core.validator import _check_dnssec - - dnssec_validated = await _check_dnssec(agents[0].fqdn) - if not dnssec_validated: - raise DNSSECError( - f"DNSSEC validation required but DNS response for " - f"{agents[0].fqdn} is not authenticated (AD flag not set)" - ) - - # Enrich agents with endpoint paths from .well-known/agent.json - if enrich_endpoints and agents: - try: - await _enrich_agents_with_endpoint_paths(agents) - except Exception: - logger.debug("Endpoint enrichment failed (non-fatal)", exc_info=True) - - # Verify JWS signatures if requested and DNSSEC not available - if verify_signatures and agents: - await _verify_agent_signatures(agents, domain, dnssec_validated) + agents = await _execute_discovery(domain, protocol, name, use_http_index, query) + dnssec_validated = await _apply_post_discovery( + agents, require_dnssec, enrich_endpoints, verify_signatures, domain + ) elapsed_ms = (time.perf_counter() - start_time) * 1000 @@ -332,6 +353,29 @@ async def _query_capabilities(fqdn: str) -> list[str]: return capabilities +def _build_index_tasks( + index_entries: list[Any], + protocol: Protocol | None, + query_fn: Any, +) -> list[Any]: + """Build async tasks from index entries, filtering by protocol.""" + tasks = [] + for entry in index_entries: + try: + entry_protocol = Protocol(entry.protocol.lower()) + except ValueError: + continue + if protocol and entry_protocol != protocol: + continue + tasks.append(query_fn(entry.name, entry_protocol)) + return tasks + + +def _collect_agent_results(results: list[Any]) -> list[AgentRecord]: + """Filter asyncio.gather results for successful AgentRecord instances.""" + return [r for r in results if isinstance(r, AgentRecord)] + + async def _discover_agents_in_zone( domain: str, protocol: Protocol | None = None, @@ -344,12 +388,8 @@ async def _discover_agents_in_zone( """ from dns_aid.core.indexer import read_index_via_dns - agents = [] - - # Try TXT index first (direct DNS query, no backend credentials needed) index_entries = await read_index_via_dns(domain) - # Concurrency limiter: avoid overwhelming the DNS resolver sem = asyncio.Semaphore(20) async def _query_with_sem(name: str, proto: Protocol) -> AgentRecord | None: @@ -362,25 +402,9 @@ async def _query_with_sem(name: str, proto: Protocol) -> AgentRecord | None: domain=domain, entry_count=len(index_entries), ) - - tasks = [] - for entry in index_entries: - try: - entry_protocol = Protocol(entry.protocol.lower()) - except ValueError: - continue - - if protocol and entry_protocol != protocol: - continue - - tasks.append(_query_with_sem(entry.name, entry_protocol)) - + tasks = _build_index_tasks(index_entries, protocol, _query_with_sem) results = await asyncio.gather(*tasks, return_exceptions=True) - for result in results: - if isinstance(result, AgentRecord): - agents.append(result) - - return agents + return _collect_agent_results(results) # Fallback: probe hardcoded common names logger.debug("No TXT index found, falling back to common name probing", domain=domain) @@ -402,15 +426,11 @@ async def _query_with_sem(name: str, proto: Protocol) -> AgentRecord | None: tasks = [] for proto in protocols_to_try: - for name in common_names: - tasks.append(_query_with_sem(name, proto)) + for agent_name in common_names: + tasks.append(_query_with_sem(agent_name, proto)) results = await asyncio.gather(*tasks, return_exceptions=True) - for result in results: - if isinstance(result, AgentRecord): - agents.append(result) - - return agents + return _collect_agent_results(results) def _parse_fqdn(fqdn: str) -> tuple[str | None, str | None]: @@ -438,6 +458,76 @@ def _parse_fqdn(fqdn: str) -> tuple[str | None, str | None]: return name_part[1:], protocol_part[1:] +def _enrich_from_http_index(agent: AgentRecord, http_agent: HttpIndexAgent) -> None: + """Merge HTTP index metadata into a DNS-discovered agent record.""" + if http_agent.description: + agent.description = http_agent.description + if ( + http_agent.capability + and http_agent.capability.modality + and http_agent.capability.modality not in agent.use_cases + ): + agent.use_cases.append(f"modality:{http_agent.capability.modality}") + + if http_agent.endpoint and not agent.endpoint_override: + parsed = urlparse(http_agent.endpoint) + if parsed.path and parsed.path != "/": + agent.endpoint_override = http_agent.endpoint + agent.endpoint_source = "http_index" + logger.debug( + "Merged HTTP index endpoint path", + agent=agent.name, + endpoint=http_agent.endpoint, + ) + + +async def _process_http_agent( + http_agent: HttpIndexAgent, + domain: str, + protocol: Protocol | None, + name: str | None, +) -> AgentRecord | None: + """Process a single HTTP index entry: parse FQDN, filter, resolve via DNS.""" + if name and http_agent.name != name: + return None + + dns_agent_name, fqdn_protocol_str = _parse_fqdn(http_agent.fqdn) + if not dns_agent_name or not fqdn_protocol_str: + logger.debug( + "Cannot parse FQDN from HTTP index entry", + agent=http_agent.name, + fqdn=http_agent.fqdn, + ) + return None + + try: + agent_protocol = Protocol(fqdn_protocol_str.lower()) + except ValueError: + logger.debug( + "Unknown protocol in FQDN", + agent=http_agent.name, + fqdn=http_agent.fqdn, + protocol=fqdn_protocol_str, + ) + return None + + if protocol and agent_protocol != protocol: + return None + + agent = await _query_single_agent(domain, dns_agent_name, agent_protocol) + + if agent: + _enrich_from_http_index(agent, http_agent) + return agent + + logger.debug( + "DNS lookup failed for HTTP index agent, using HTTP data only", + agent=http_agent.name, + fqdn=http_agent.fqdn, + ) + return _http_agent_to_record(http_agent, domain, dns_agent_name, agent_protocol) + + async def _discover_via_http_index( domain: str, protocol: Protocol | None = None, @@ -458,14 +548,11 @@ async def _discover_via_http_index( Returns: List of AgentRecord objects """ - agents: list[AgentRecord] = [] - - # Fetch HTTP index http_agents = await fetch_http_index_or_empty(domain) if not http_agents: logger.debug("No agents found in HTTP index", domain=domain) - return agents + return [] logger.debug( "HTTP index fetched", @@ -473,75 +560,11 @@ async def _discover_via_http_index( agent_count=len(http_agents), ) + agents: list[AgentRecord] = [] for http_agent in http_agents: - # Apply name filter (against HTTP index key) - if name and http_agent.name != name: - continue - - # Extract name and protocol from FQDN (single source of truth) - dns_agent_name, fqdn_protocol_str = _parse_fqdn(http_agent.fqdn) - - if not dns_agent_name or not fqdn_protocol_str: - logger.debug( - "Cannot parse FQDN from HTTP index entry", - agent=http_agent.name, - fqdn=http_agent.fqdn, - ) - continue - - # Resolve protocol from FQDN - try: - agent_protocol = Protocol(fqdn_protocol_str.lower()) - except ValueError: - logger.debug( - "Unknown protocol in FQDN", - agent=http_agent.name, - fqdn=http_agent.fqdn, - protocol=fqdn_protocol_str, - ) - continue - - # Apply protocol filter - if protocol and agent_protocol != protocol: - continue - - # Resolve via DNS SVCB to get authoritative endpoint - agent = await _query_single_agent(domain, dns_agent_name, agent_protocol) - + agent = await _process_http_agent(http_agent, domain, protocol, name) if agent: - # Enhance with HTTP index metadata - if http_agent.description: - agent.description = http_agent.description - if ( - http_agent.capability - and http_agent.capability.modality - and http_agent.capability.modality not in agent.use_cases - ): - agent.use_cases.append(f"modality:{http_agent.capability.modality}") - - # Merge HTTP index endpoint path when it's more specific than DNS - if http_agent.endpoint and not agent.endpoint_override: - parsed = urlparse(http_agent.endpoint) - if parsed.path and parsed.path != "/": - agent.endpoint_override = http_agent.endpoint - agent.endpoint_source = "http_index" - logger.debug( - "Merged HTTP index endpoint path", - agent=agent.name, - endpoint=http_agent.endpoint, - ) - agents.append(agent) - else: - # If DNS lookup fails, create agent from HTTP index data only - logger.debug( - "DNS lookup failed for HTTP index agent, using HTTP data only", - agent=http_agent.name, - fqdn=http_agent.fqdn, - ) - agent = _http_agent_to_record(http_agent, domain, dns_agent_name, agent_protocol) - if agent: - agents.append(agent) return agents diff --git a/src/dns_aid/sdk/telemetry/otel.py b/src/dns_aid/sdk/telemetry/otel.py index d5a3a08..48b43f7 100644 --- a/src/dns_aid/sdk/telemetry/otel.py +++ b/src/dns_aid/sdk/telemetry/otel.py @@ -50,6 +50,16 @@ ATTR_SECURITY_DNSSEC = "dns_aid.security.dnssec" +def _parse_signal_fqdn(fqdn: str) -> tuple[str | None, str | None]: + """Parse agent_name and domain from an FQDN like ``_name._proto._agents.domain``.""" + parts = fqdn.split("._agents.") + if len(parts) == 2: + name_parts = parts[0].lstrip("_").split("._") + agent_name = name_parts[0] if name_parts else None + return agent_name, parts[1] + return None, None + + class TelemetryManager: """ Manages OpenTelemetry TracerProvider and MeterProvider for DNS-AID SDK. @@ -193,12 +203,10 @@ def _initialize(self) -> None: endpoint=self._config.otel_endpoint, ) - def record_signal(self, signal: InvocationSignal) -> None: - """Record a signal as an OTEL span and update metrics.""" - if not self.is_available: - return - - attributes = { + @staticmethod + def _build_span_attributes(signal: InvocationSignal) -> dict[str, Any]: + """Build OTEL span attribute dict from an invocation signal.""" + attributes: dict[str, Any] = { ATTR_AGENT_ENDPOINT: signal.agent_endpoint, ATTR_AGENT_PROTOCOL: signal.protocol, ATTR_INVOCATION_STATUS: signal.status.value, @@ -210,15 +218,21 @@ def record_signal(self, signal: InvocationSignal) -> None: if signal.cost_units is not None: attributes[ATTR_INVOCATION_COST] = signal.cost_units - # Parse FQDN for name/domain - parts = signal.agent_fqdn.split("._agents.") - if len(parts) == 2: - attributes[ATTR_AGENT_DOMAIN] = parts[1] - name_parts = parts[0].lstrip("_").split("._") - if name_parts: - attributes[ATTR_AGENT_NAME] = name_parts[0] + agent_name, agent_domain = _parse_signal_fqdn(signal.agent_fqdn) + if agent_domain: + attributes[ATTR_AGENT_DOMAIN] = agent_domain + if agent_name: + attributes[ATTR_AGENT_NAME] = agent_name + + return attributes + + def record_signal(self, signal: InvocationSignal) -> None: + """Record a signal as an OTEL span and update metrics.""" + if not self.is_available: + return + + attributes = self._build_span_attributes(signal) - # Create span with self._tracer.start_as_current_span( name=f"dns-aid.invoke {signal.agent_fqdn}", attributes=attributes, @@ -226,7 +240,6 @@ def record_signal(self, signal: InvocationSignal) -> None: if signal.status.value != "success": span.set_status(StatusCode.ERROR, signal.error_message or "") - # Record metrics label_attrs = { "protocol": signal.protocol, "status": signal.status.value, diff --git a/tests/unit/sdk/test_otel.py b/tests/unit/sdk/test_otel.py index 93bb3b4..0e8849e 100644 --- a/tests/unit/sdk/test_otel.py +++ b/tests/unit/sdk/test_otel.py @@ -94,3 +94,156 @@ def test_record_error_signal(self) -> None: error_message="Connection timed out", ) mgr.record_signal(signal) + + def test_shutdown_idempotent(self) -> None: + """Shutdown when not initialized should not raise.""" + config = SDKConfig(otel_enabled=False) + mgr = TelemetryManager(config) + mgr.shutdown() # Not initialized + mgr.shutdown() # Double shutdown + + def test_initialize_console_exporter(self) -> None: + """Test _initialize with console export format.""" + from dns_aid.sdk.telemetry.otel import _otel_available + + if not _otel_available: + return + + config = SDKConfig(otel_enabled=True, otel_export_format="console") + mgr = TelemetryManager(config) + mgr._initialize() + assert mgr.is_available is True + assert mgr._tracer is not None + assert mgr._duration_histogram is not None + assert mgr._invocation_counter is not None + assert mgr._error_counter is not None + assert mgr._cost_counter is not None + mgr.shutdown() + + def test_initialize_default_export_format(self) -> None: + """Test _initialize with non-console/non-otlp falls back to console reader.""" + from dns_aid.sdk.telemetry.otel import _otel_available + + if not _otel_available: + return + + config = SDKConfig(otel_enabled=True, otel_export_format="noop") + mgr = TelemetryManager(config) + mgr._initialize() + assert mgr.is_available is True + mgr.shutdown() + + def test_record_signal_with_cost(self) -> None: + """Test that cost_units is recorded in attributes and counters.""" + from dns_aid.sdk.telemetry.otel import _otel_available + + if not _otel_available: + return + + config = SDKConfig(otel_enabled=True, otel_export_format="console") + mgr = TelemetryManager.get_or_create(config) + signal = _make_signal() + mgr.record_signal(signal) + # Doesn't raise; cost counter is exercised + + def test_record_refused_signal(self) -> None: + """Test recording a 'refused' status signal hits error counter.""" + from dns_aid.sdk.telemetry.otel import _otel_available + + if not _otel_available: + return + + config = SDKConfig(otel_enabled=True, otel_export_format="console") + mgr = TelemetryManager.get_or_create(config) + + signal = InvocationSignal( + agent_fqdn="_chat._a2a._agents.example.com", + agent_endpoint="https://chat.example.com", + protocol="a2a", + invocation_latency_ms=10.0, + status=InvocationStatus.REFUSED, + error_message="Access denied", + ) + mgr.record_signal(signal) + + def test_record_signal_no_method_no_cost(self) -> None: + """Signal without method or cost_units skips those attributes.""" + from dns_aid.sdk.telemetry.otel import _otel_available + + if not _otel_available: + return + + config = SDKConfig(otel_enabled=True, otel_export_format="console") + mgr = TelemetryManager.get_or_create(config) + + signal = InvocationSignal( + agent_fqdn="_chat._a2a._agents.example.com", + agent_endpoint="https://chat.example.com", + protocol="a2a", + invocation_latency_ms=10.0, + status=InvocationStatus.SUCCESS, + method=None, + cost_units=None, + ) + mgr.record_signal(signal) + + +class TestParseSignalFqdn: + """Tests for _parse_signal_fqdn helper.""" + + def test_valid_fqdn(self) -> None: + from dns_aid.sdk.telemetry.otel import _parse_signal_fqdn + + name, domain = _parse_signal_fqdn("_network._mcp._agents.example.com") + assert name == "network" + assert domain == "example.com" + + def test_no_agents_separator(self) -> None: + from dns_aid.sdk.telemetry.otel import _parse_signal_fqdn + + name, domain = _parse_signal_fqdn("invalid.fqdn.com") + assert name is None + assert domain is None + + def test_empty_string(self) -> None: + from dns_aid.sdk.telemetry.otel import _parse_signal_fqdn + + name, domain = _parse_signal_fqdn("") + assert name is None + assert domain is None + + +class TestBuildSpanAttributes: + """Tests for TelemetryManager._build_span_attributes.""" + + def test_build_full_attributes(self) -> None: + signal = _make_signal() + config = SDKConfig(otel_enabled=False) + mgr = TelemetryManager(config) + attrs = mgr._build_span_attributes(signal) + + assert attrs["dns_aid.agent.endpoint"] == "https://mcp.example.com:443" + assert attrs["dns_aid.agent.protocol"] == "mcp" + assert attrs["dns_aid.invocation.status"] == "success" + assert attrs["dns_aid.invocation.latency_ms"] == 150.0 + assert attrs["dns_aid.agent.name"] == "network" + assert attrs["dns_aid.agent.domain"] == "example.com" + assert attrs["dns_aid.invocation.method"] == "tools/call" + assert attrs["dns_aid.invocation.cost_units"] == 0.5 + + def test_build_attributes_no_method_no_cost(self) -> None: + signal = InvocationSignal( + agent_fqdn="invalid-no-agents", + agent_endpoint="https://test.com", + protocol="mcp", + invocation_latency_ms=10.0, + status=InvocationStatus.SUCCESS, + ) + config = SDKConfig(otel_enabled=False) + mgr = TelemetryManager(config) + attrs = mgr._build_span_attributes(signal) + + assert "dns_aid.invocation.method" not in attrs + assert "dns_aid.invocation.cost_units" not in attrs + assert "dns_aid.agent.name" not in attrs + assert "dns_aid.agent.domain" not in attrs diff --git a/tests/unit/sdk/test_sdk_client.py b/tests/unit/sdk/test_sdk_client.py new file mode 100644 index 0000000..1e5a029 --- /dev/null +++ b/tests/unit/sdk/test_sdk_client.py @@ -0,0 +1,154 @@ +"""Tests for dns_aid.sdk.client module — uncovered paths.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest + +from dns_aid.sdk._config import SDKConfig +from dns_aid.sdk.client import AgentClient +from dns_aid.sdk.models import InvocationSignal, InvocationStatus + + +@pytest.fixture +def _config() -> SDKConfig: + return SDKConfig( + timeout_seconds=5.0, + caller_id="test", + telemetry_api_url="https://api.test.io", + ) + + +@pytest.fixture +def _signal() -> InvocationSignal: + return InvocationSignal( + agent_fqdn="_chat._a2a._agents.example.com", + agent_endpoint="https://chat.example.com", + protocol="a2a", + invocation_latency_ms=42.0, + status=InvocationStatus.SUCCESS, + ) + + +class TestAgentClientAsyncContext: + """Tests for __aenter__ / __aexit__.""" + + @pytest.mark.asyncio + async def test_aexit_cleanup_no_client(self, _config: SDKConfig): + """__aexit__ when _http_client is already None should not raise.""" + client = AgentClient(config=_config) + # Never entered context → _http_client is None + await client.__aexit__(None, None, None) + # Should not raise + + @pytest.mark.asyncio + async def test_aexit_closes_client(self, _config: SDKConfig): + """__aexit__ should close the http client and set it to None.""" + async with AgentClient(config=_config) as client: + assert client._http_client is not None + assert client._http_client is None + + +class TestFetchRankings: + """Tests for fetch_rankings method.""" + + @pytest.mark.asyncio + async def test_fetch_rankings_success(self, _config: SDKConfig): + """Happy path: returns rankings from API.""" + response_data = { + "rankings": [ + {"agent_fqdn": "_chat._a2a._agents.example.com", "composite_score": 95}, + {"agent_fqdn": "_other._mcp._agents.example.com", "composite_score": 80}, + ] + } + mock_resp = MagicMock() + mock_resp.json.return_value = response_data + mock_resp.raise_for_status = MagicMock() + + async with AgentClient(config=_config) as client: + client._http_client.get = AsyncMock(return_value=mock_resp) + rankings = await client.fetch_rankings() + + assert len(rankings) == 2 + assert rankings[0]["composite_score"] == 95 + + @pytest.mark.asyncio + async def test_fetch_rankings_no_telemetry_url(self): + """No telemetry_api_url → returns empty list.""" + config = SDKConfig(timeout_seconds=5.0, telemetry_api_url=None) + async with AgentClient(config=config) as client: + rankings = await client.fetch_rankings() + assert rankings == [] + + @pytest.mark.asyncio + async def test_fetch_rankings_http_error(self, _config: SDKConfig): + """HTTP 500 → returns empty list.""" + mock_resp = MagicMock() + mock_resp.status_code = 500 + mock_resp.text = "Internal Server Error" + mock_resp.raise_for_status.side_effect = httpx.HTTPStatusError( + "error", request=MagicMock(), response=mock_resp + ) + mock_resp.json.return_value = {} + + async with AgentClient(config=_config) as client: + client._http_client.get = AsyncMock(return_value=mock_resp) + rankings = await client.fetch_rankings() + + assert rankings == [] + + @pytest.mark.asyncio + async def test_fetch_rankings_with_fqdn_filter(self, _config: SDKConfig): + """Rankings are filtered by provided FQDNs.""" + response_data = { + "rankings": [ + {"agent_fqdn": "_chat._a2a._agents.example.com", "composite_score": 95}, + {"agent_fqdn": "_other._mcp._agents.example.com", "composite_score": 80}, + ] + } + mock_resp = MagicMock() + mock_resp.json.return_value = response_data + mock_resp.raise_for_status = MagicMock() + + async with AgentClient(config=_config) as client: + client._http_client.get = AsyncMock(return_value=mock_resp) + rankings = await client.fetch_rankings( + fqdns=["_chat._a2a._agents.example.com"] + ) + + assert len(rankings) == 1 + assert rankings[0]["agent_fqdn"] == "_chat._a2a._agents.example.com" + + +class TestPushSignalHttpSync: + """Tests for _push_signal_http_sync static method.""" + + def test_push_signal_http_sync_success(self, _signal: InvocationSignal): + """Successful push (status 200) logs ok.""" + mock_resp = MagicMock() + mock_resp.status_code = 200 + + with patch("dns_aid.sdk.client.httpx.post", return_value=mock_resp) as mock_post: + AgentClient._push_signal_http_sync(_signal, "https://api.test.io/signals") + mock_post.assert_called_once() + + def test_push_signal_http_sync_rejected(self, _signal: InvocationSignal): + """Status 400 logs a warning but does not raise.""" + mock_resp = MagicMock() + mock_resp.status_code = 400 + mock_resp.text = "Bad Request" + + with patch("dns_aid.sdk.client.httpx.post", return_value=mock_resp): + # Should not raise + AgentClient._push_signal_http_sync(_signal, "https://api.test.io/signals") + + def test_push_signal_http_sync_exception(self, _signal: InvocationSignal): + """Network error is silently caught.""" + with patch( + "dns_aid.sdk.client.httpx.post", + side_effect=httpx.ConnectError("refused"), + ): + # Should not raise + AgentClient._push_signal_http_sync(_signal, "https://api.test.io/signals") diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 29c0800..717bfeb 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -1,7 +1,8 @@ """Unit tests for CLI commands.""" import re -from unittest.mock import patch +from dataclasses import dataclass +from unittest.mock import MagicMock, patch from typer.testing import CliRunner @@ -212,3 +213,464 @@ async def simple(): return 42 assert run_async(simple()) == 42 + + +# ============================================================================ +# PUBLISH COMMAND TESTS +# ============================================================================ + + +def _make_agent(): + """Create a minimal AgentRecord for mocking publish results.""" + from dns_aid.core.models import AgentRecord, Protocol + + return AgentRecord( + name="chat", + domain="example.com", + protocol=Protocol.MCP, + target_host="mcp.example.com", + endpoint_override="https://mcp.example.com", + port=443, + ) + + +def _make_publish_result(success=True, message=None): + from dns_aid.core.models import PublishResult + + return PublishResult( + agent=_make_agent(), + records_created=["SVCB _chat._mcp._agents.example.com", "TXT _chat._mcp._agents.example.com"], + zone="example.com", + backend="mock", + success=success, + message=message, + ) + + +def _make_index_result(success=True, created=False, message="Index updated"): + from dns_aid.core.indexer import IndexEntry, IndexResult + + return IndexResult( + domain="example.com", + entries=[IndexEntry(name="chat", protocol="mcp")], + success=success, + message=message, + created=created, + ) + + +class TestPublishCommand: + """Test publish CLI command.""" + + @patch("dns_aid.cli.main.run_async") + def test_publish_success_with_index(self, mock_run_async): + """Publish success → index update success.""" + mock_run_async.side_effect = [ + _make_publish_result(), + _make_index_result(created=True), + ] + result = runner.invoke( + app, + [ + "publish", + "--name", "chat", + "--domain", "example.com", + "--backend", "mock", + ], + ) + assert result.exit_code == 0 + plain = _strip_ansi(result.output) + assert "published successfully" in plain + assert "SVCB" in plain or "Records created" in plain or "records created" in plain.lower() + + @patch("dns_aid.cli.main.run_async") + def test_publish_success_index_failed(self, mock_run_async): + """Publish success but index update fails.""" + mock_run_async.side_effect = [ + _make_publish_result(), + _make_index_result(success=False, message="Backend error"), + ] + result = runner.invoke( + app, + [ + "publish", + "--name", "chat", + "--domain", "example.com", + "--backend", "mock", + ], + ) + assert result.exit_code == 0 + plain = _strip_ansi(result.output) + assert "published successfully" in plain + + @patch("dns_aid.cli.main.run_async") + def test_publish_no_update_index(self, mock_run_async): + """Publish with --no-update-index skips index update.""" + mock_run_async.return_value = _make_publish_result() + result = runner.invoke( + app, + [ + "publish", + "--name", "chat", + "--domain", "example.com", + "--backend", "mock", + "--no-update-index", + ], + ) + assert result.exit_code == 0 + assert mock_run_async.call_count == 1 # Only publish, no index + + @patch("dns_aid.cli.main.run_async") + def test_publish_failure(self, mock_run_async): + """Publish failure exits with code 1.""" + mock_run_async.return_value = _make_publish_result( + success=False, message="DNS write failed" + ) + result = runner.invoke( + app, + [ + "publish", + "--name", "chat", + "--domain", "example.com", + "--backend", "mock", + ], + ) + assert result.exit_code == 1 + + def test_publish_sign_without_key(self): + """--sign without --private-key exits with code 1.""" + result = runner.invoke( + app, + [ + "publish", + "--name", "chat", + "--domain", "example.com", + "--backend", "mock", + "--sign", + ], + ) + assert result.exit_code == 1 + + +# ============================================================================ +# DELETE COMMAND TESTS +# ============================================================================ + + +class TestDeleteCommand: + """Test delete CLI command.""" + + @patch("dns_aid.cli.main.run_async") + def test_delete_force_success(self, mock_run_async): + """Delete --force success with index update.""" + mock_run_async.side_effect = [ + True, # unpublish returns True + _make_index_result(), + ] + result = runner.invoke( + app, + [ + "delete", + "--name", "chat", + "--domain", "example.com", + "--backend", "mock", + "--force", + ], + ) + assert result.exit_code == 0 + plain = _strip_ansi(result.output) + assert "deleted successfully" in plain + + @patch("dns_aid.cli.main.run_async") + def test_delete_force_no_records(self, mock_run_async): + """Delete when no records found.""" + mock_run_async.return_value = False # unpublish returns False + result = runner.invoke( + app, + [ + "delete", + "--name", "chat", + "--domain", "example.com", + "--backend", "mock", + "--force", + ], + ) + assert result.exit_code == 0 + plain = _strip_ansi(result.output) + assert "No records found" in plain + + @patch("dns_aid.cli.main.run_async") + def test_delete_force_index_fail(self, mock_run_async): + """Delete success but index update fails.""" + mock_run_async.side_effect = [ + True, + _make_index_result(success=False, message="Failed"), + ] + result = runner.invoke( + app, + [ + "delete", + "--name", "chat", + "--domain", "example.com", + "--backend", "mock", + "--force", + ], + ) + assert result.exit_code == 0 + + @patch("dns_aid.cli.main.run_async") + def test_delete_force_no_update_index(self, mock_run_async): + """Delete with --no-update-index skips index update.""" + mock_run_async.return_value = True + result = runner.invoke( + app, + [ + "delete", + "--name", "chat", + "--domain", "example.com", + "--backend", "mock", + "--force", + "--no-update-index", + ], + ) + assert result.exit_code == 0 + assert mock_run_async.call_count == 1 # Only unpublish + + def test_delete_abort(self): + """Delete without --force, user says no → abort.""" + result = runner.invoke( + app, + [ + "delete", + "--name", "chat", + "--domain", "example.com", + "--backend", "mock", + ], + input="n\n", + ) + # Typer abort is exit code 1 + assert result.exit_code == 1 + + +# ============================================================================ +# LIST COMMAND TESTS +# ============================================================================ + + +class TestListCommand: + """Test list CLI command.""" + + @patch("dns_aid.cli.main.run_async") + def test_list_with_records(self, mock_run_async): + """List records shows table.""" + mock_run_async.return_value = [ + { + "fqdn": "_chat._mcp._agents.example.com", + "type": "SVCB", + "ttl": 3600, + "values": ["1 mcp.example.com. alpn=mcp port=443"], + }, + { + "fqdn": "_chat._mcp._agents.example.com", + "type": "TXT", + "ttl": 3600, + "values": ["capabilities=ipam,dns"], + }, + ] + result = runner.invoke(app, ["list", "example.com", "--backend", "mock"]) + assert result.exit_code == 0 + plain = _strip_ansi(result.output) + assert "2 record(s)" in plain + + @patch("dns_aid.cli.main.run_async") + def test_list_empty(self, mock_run_async): + """List with no records.""" + mock_run_async.return_value = [] + result = runner.invoke(app, ["list", "example.com", "--backend", "mock"]) + assert result.exit_code == 0 + plain = _strip_ansi(result.output) + assert "No DNS-AID records found" in plain + + @patch("dns_aid.cli.main.run_async") + def test_list_long_value_truncated(self, mock_run_async): + """List truncates long record values.""" + mock_run_async.return_value = [ + { + "fqdn": "_chat._mcp._agents.example.com", + "type": "TXT", + "ttl": 3600, + "values": ["x" * 100], + }, + ] + result = runner.invoke(app, ["list", "example.com", "--backend", "mock"]) + assert result.exit_code == 0 + plain = _strip_ansi(result.output) + assert "1 record(s)" in plain + + +# ============================================================================ +# ZONES COMMAND TESTS +# ============================================================================ + + +class TestZonesCommand: + """Test zones CLI command.""" + + def test_zones_non_route53_error(self): + """Zones for non-route53 backend exits with error.""" + result = runner.invoke(app, ["zones", "--backend", "mock"]) + assert result.exit_code == 1 + + @patch("dns_aid.cli.main.run_async") + def test_zones_route53_success(self, mock_run_async): + """Zones lists Route53 zones.""" + mock_run_async.return_value = [ + { + "name": "example.com.", + "id": "Z12345", + "record_count": 10, + "private": False, + }, + ] + result = runner.invoke(app, ["zones", "--backend", "route53"]) + assert result.exit_code == 0 + plain = _strip_ansi(result.output) + assert "example.com" in plain + + +# ============================================================================ +# INDEX LIST COMMAND TESTS +# ============================================================================ + + +class TestIndexListCommand: + """Test index list CLI command.""" + + @patch("dns_aid.cli.main.run_async") + def test_index_list_with_entries(self, mock_run_async): + """Index list shows table of entries.""" + from dns_aid.core.indexer import IndexEntry + + mock_run_async.return_value = [ + IndexEntry(name="chat", protocol="mcp"), + IndexEntry(name="network", protocol="a2a"), + ] + result = runner.invoke( + app, ["index", "list", "example.com", "--backend", "mock"] + ) + assert result.exit_code == 0 + plain = _strip_ansi(result.output) + assert "2 agent(s)" in plain + + @patch("dns_aid.cli.main.run_async") + def test_index_list_empty_both(self, mock_run_async): + """Index list with no entries from backend or DNS.""" + mock_run_async.return_value = [] + result = runner.invoke( + app, ["index", "list", "example.com", "--backend", "mock"] + ) + assert result.exit_code == 0 + plain = _strip_ansi(result.output) + assert "No index record found" in plain + + +# ============================================================================ +# INDEX SYNC COMMAND TESTS +# ============================================================================ + + +class TestIndexSyncCommand: + """Test index sync CLI command.""" + + @patch("dns_aid.cli.main.run_async") + def test_index_sync_success_with_entries(self, mock_run_async): + """Sync success with agents found.""" + mock_run_async.return_value = _make_index_result( + success=True, created=True, message="Synced 1 agent(s)" + ) + result = runner.invoke( + app, ["index", "sync", "example.com", "--backend", "mock"] + ) + assert result.exit_code == 0 + plain = _strip_ansi(result.output) + assert "Synced" in plain or "agent" in plain.lower() + + @patch("dns_aid.cli.main.run_async") + def test_index_sync_success_empty(self, mock_run_async): + """Sync success but no agents found.""" + from dns_aid.core.indexer import IndexResult + + mock_run_async.return_value = IndexResult( + domain="example.com", + entries=[], + success=True, + message="No agents", + ) + result = runner.invoke( + app, ["index", "sync", "example.com", "--backend", "mock"] + ) + assert result.exit_code == 0 + plain = _strip_ansi(result.output) + assert "No agents found" in plain + + @patch("dns_aid.cli.main.run_async") + def test_index_sync_failure(self, mock_run_async): + """Sync failure exits with code 1.""" + from dns_aid.core.indexer import IndexResult + + mock_run_async.return_value = IndexResult( + domain="example.com", + entries=[], + success=False, + message="Backend unreachable", + ) + result = runner.invoke( + app, ["index", "sync", "example.com", "--backend", "mock"] + ) + assert result.exit_code == 1 + + +# ============================================================================ +# VERIFY COMMAND EXTENDED +# ============================================================================ + + +class TestVerifyCommandExtended: + """Extended verify CLI tests.""" + + @patch("dns_aid.cli.main.run_async") + def test_verify_no_latency(self, mock_run_async): + """Verify with no endpoint_latency_ms omits latency line.""" + from dns_aid.core.validator import VerifyResult + + mock_run_async.return_value = VerifyResult( + fqdn="_chat._a2a._agents.example.com", + record_exists=True, + svcb_valid=True, + dnssec_valid=True, + dane_valid=None, + endpoint_reachable=False, + endpoint_latency_ms=None, + ) + result = runner.invoke(app, ["verify", "_chat._a2a._agents.example.com"]) + assert result.exit_code == 0 + assert "Security Score" in result.output + + @patch("dns_aid.cli.main.run_async") + def test_verify_all_pass(self, mock_run_async): + """Verify with all checks passing.""" + from dns_aid.core.validator import VerifyResult + + mock_run_async.return_value = VerifyResult( + fqdn="_chat._a2a._agents.example.com", + record_exists=True, + svcb_valid=True, + dnssec_valid=True, + dane_valid=True, + endpoint_reachable=True, + endpoint_latency_ms=25.0, + ) + result = runner.invoke(app, ["verify", "_chat._a2a._agents.example.com"]) + assert result.exit_code == 0 + plain = _strip_ansi(result.output) + assert "25ms" in plain or "Latency" in plain diff --git a/tests/unit/test_cloudflare_backend.py b/tests/unit/test_cloudflare_backend.py index d885b92..ba5b457 100644 --- a/tests/unit/test_cloudflare_backend.py +++ b/tests/unit/test_cloudflare_backend.py @@ -655,3 +655,223 @@ async def test_close(self): # Close it await backend.close() assert backend._client is None + + +# ============================================================================= +# Param demotion & get_record coverage +# ============================================================================= + + +class TestCloudflarePublishAgentParamDemotion: + """Tests for custom SVCB param demotion to TXT on Cloudflare.""" + + @pytest.mark.asyncio + async def test_publish_strips_custom_svcb_params(self): + """Custom BANDAID params (key65001+) must be demoted to TXT.""" + from dns_aid.core.models import AgentRecord, Protocol + + agent = AgentRecord( + name="lf-test", + domain="example.com", + protocol=Protocol.MCP, + target_host="lf-test.example.com", + port=443, + capabilities=["testing"], + realm="demo", + ) + + backend = CloudflareBackend(api_token="token", zone_id="Z123") + + svcb_calls: list[dict] = [] + txt_calls: list[dict] = [] + + async def _mock_create_svcb(**kwargs): + svcb_calls.append(kwargs) + return f"SVCB _lf-test._mcp._agents.example.com" + + async def _mock_create_txt(**kwargs): + txt_calls.append(kwargs) + return f"TXT _lf-test._mcp._agents.example.com" + + with ( + patch.object(backend, "create_svcb_record", side_effect=_mock_create_svcb), + patch.object(backend, "create_txt_record", side_effect=_mock_create_txt), + ): + records = await backend.publish_agent(agent) + + assert len(records) == 2 + assert records[0].startswith("SVCB") + assert records[1].startswith("TXT") + + # SVCB params should NOT contain custom keys + svcb_params = svcb_calls[0]["params"] + for key in svcb_params: + assert key in {"mandatory", "alpn", "no-default-alpn", "port", "ipv4hint", "ipv6hint", "ech"} + + # TXT should contain demoted bandaid params + txt_values = txt_calls[0]["values"] + bandaid_txt = [v for v in txt_values if v.startswith("bandaid_")] + assert len(bandaid_txt) > 0 + + @pytest.mark.asyncio + async def test_publish_no_custom_params_unchanged(self): + """No demotion when agent has no custom params.""" + from dns_aid.core.models import AgentRecord, Protocol + + agent = AgentRecord( + name="basic", + domain="example.com", + protocol=Protocol.A2A, + target_host="basic.example.com", + port=443, + capabilities=["chat"], + ) + + backend = CloudflareBackend(api_token="token", zone_id="Z123") + + svcb_calls: list[dict] = [] + txt_calls: list[dict] = [] + + async def _mock_create_svcb(**kwargs): + svcb_calls.append(kwargs) + return "SVCB fqdn" + + async def _mock_create_txt(**kwargs): + txt_calls.append(kwargs) + return "TXT fqdn" + + with ( + patch.object(backend, "create_svcb_record", side_effect=_mock_create_svcb), + patch.object(backend, "create_txt_record", side_effect=_mock_create_txt), + ): + records = await backend.publish_agent(agent) + + # No bandaid_ entries in TXT + if txt_calls: + txt_values = txt_calls[0]["values"] + bandaid_txt = [v for v in txt_values if v.startswith("bandaid_")] + assert len(bandaid_txt) == 0 + + @pytest.mark.asyncio + async def test_publish_demotes_multiple_params(self): + """Multiple custom params are all demoted to TXT.""" + from dns_aid.core.models import AgentRecord, Protocol + + agent = AgentRecord( + name="multi", + domain="example.com", + protocol=Protocol.MCP, + target_host="multi.example.com", + port=443, + capabilities=["all"], + cap_uri="https://multi.example.com/cap.json", + cap_sha256="abc123", + bap=["mcp", "a2a"], + policy_uri="https://example.com/policy", + realm="production", + ) + + backend = CloudflareBackend(api_token="token", zone_id="Z123") + + svcb_calls: list[dict] = [] + txt_calls: list[dict] = [] + + async def _mock_create_svcb(**kwargs): + svcb_calls.append(kwargs) + return "SVCB fqdn" + + async def _mock_create_txt(**kwargs): + txt_calls.append(kwargs) + return "TXT fqdn" + + with ( + patch.object(backend, "create_svcb_record", side_effect=_mock_create_svcb), + patch.object(backend, "create_txt_record", side_effect=_mock_create_txt), + ): + await backend.publish_agent(agent) + + # All custom keys should be in TXT as bandaid_ prefixed + txt_values = txt_calls[0]["values"] + bandaid_txt = [v for v in txt_values if v.startswith("bandaid_")] + assert len(bandaid_txt) >= 4 # cap, cap-sha256, bap, policy, realm + + +class TestCloudflareGetRecord: + """Tests for get_record method.""" + + @pytest.mark.asyncio + async def test_get_record_svcb(self): + """get_record returns SVCB record data.""" + backend = CloudflareBackend(api_token="token", zone_id="Z123") + + mock_response = MagicMock() + mock_response.json.return_value = { + "success": True, + "result": [ + { + "id": "rec1", + "name": "_chat._a2a._agents.example.com", + "type": "SVCB", + "ttl": 3600, + "data": {"priority": 1, "target": "chat.example.com.", "value": 'alpn="a2a"'}, + } + ], + } + mock_response.raise_for_status = MagicMock() + + mock_client = AsyncMock() + mock_client.get = AsyncMock(return_value=mock_response) + + with patch.object(backend, "_get_client", return_value=mock_client): + record = await backend.get_record("example.com", "_chat._a2a._agents", "SVCB") + + assert record is not None + assert record["type"] == "SVCB" + assert "1 chat.example.com." in record["values"][0] + + @pytest.mark.asyncio + async def test_get_record_txt(self): + """get_record returns TXT record data.""" + backend = CloudflareBackend(api_token="token", zone_id="Z123") + + mock_response = MagicMock() + mock_response.json.return_value = { + "success": True, + "result": [ + { + "id": "rec2", + "name": "_chat._a2a._agents.example.com", + "type": "TXT", + "ttl": 3600, + "content": "capabilities=chat", + } + ], + } + mock_response.raise_for_status = MagicMock() + + mock_client = AsyncMock() + mock_client.get = AsyncMock(return_value=mock_response) + + with patch.object(backend, "_get_client", return_value=mock_client): + record = await backend.get_record("example.com", "_chat._a2a._agents", "TXT") + + assert record is not None + assert record["type"] == "TXT" + assert record["values"] == ["capabilities=chat"] + + @pytest.mark.asyncio + async def test_get_record_not_found(self): + """get_record returns None when no record exists.""" + backend = CloudflareBackend(api_token="token", zone_id="Z123") + + mock_response = MagicMock() + mock_response.json.return_value = {"success": True, "result": []} + mock_response.raise_for_status = MagicMock() + + mock_client = AsyncMock() + mock_client.get = AsyncMock(return_value=mock_response) + + with patch.object(backend, "_get_client", return_value=mock_client): + record = await backend.get_record("example.com", "_missing._agents", "SVCB") + + assert record is None diff --git a/tests/unit/test_discoverer.py b/tests/unit/test_discoverer.py index 1c8da5c..bbf3081 100644 --- a/tests/unit/test_discoverer.py +++ b/tests/unit/test_discoverer.py @@ -7,10 +7,15 @@ from dns_aid.core.cap_fetcher import CapabilityDocument from dns_aid.core.discoverer import ( + _build_index_tasks, + _collect_agent_results, _discover_via_http_index, + _enrich_from_http_index, _http_agent_to_record, + _normalize_protocol, _parse_fqdn, _parse_svcb_custom_params, + _process_http_agent, _query_capabilities, discover, discover_at_fqdn, @@ -732,3 +737,190 @@ async def test_discovery_extracts_bap_and_policy(self): assert agent.bap == ["mcp", "a2a"] assert agent.policy_uri == "https://example.com/policy" assert agent.realm == "staging" + + +# ============================================================================= +# Tests for refactored helpers +# ============================================================================= + + +class TestNormalizeProtocol: + """Tests for _normalize_protocol helper.""" + + def test_string_normalized(self): + assert _normalize_protocol("MCP") == Protocol.MCP + + def test_enum_passthrough(self): + assert _normalize_protocol(Protocol.A2A) == Protocol.A2A + + def test_none_passthrough(self): + assert _normalize_protocol(None) is None + + +class TestBuildIndexTasks: + """Tests for _build_index_tasks helper.""" + + def test_builds_tasks_for_valid_entries(self): + from dns_aid.core.indexer import IndexEntry + + entries = [ + IndexEntry(name="chat", protocol="mcp"), + IndexEntry(name="billing", protocol="a2a"), + ] + calls = [] + + async def fake_query(name, proto): + calls.append((name, proto)) + + tasks = _build_index_tasks(entries, None, fake_query) + assert len(tasks) == 2 + + def test_filters_by_protocol(self): + from dns_aid.core.indexer import IndexEntry + + entries = [ + IndexEntry(name="chat", protocol="mcp"), + IndexEntry(name="billing", protocol="a2a"), + ] + + async def fake_query(name, proto): + pass + + tasks = _build_index_tasks(entries, Protocol.MCP, fake_query) + assert len(tasks) == 1 + + def test_skips_invalid_protocol(self): + from dns_aid.core.indexer import IndexEntry + + entries = [IndexEntry(name="chat", protocol="unknown_proto")] + + async def fake_query(name, proto): + pass + + tasks = _build_index_tasks(entries, None, fake_query) + assert len(tasks) == 0 + + +class TestCollectAgentResults: + """Tests for _collect_agent_results helper.""" + + def test_filters_agent_records(self): + from dns_aid.core.models import AgentRecord, Protocol + + agent = AgentRecord( + name="test", + domain="example.com", + protocol=Protocol.MCP, + target_host="test.example.com", + port=443, + ) + results = [agent, Exception("error"), None, agent] + collected = _collect_agent_results(results) + assert len(collected) == 2 + + def test_empty_results(self): + assert _collect_agent_results([]) == [] + + def test_all_exceptions(self): + results = [Exception("a"), Exception("b")] + assert _collect_agent_results(results) == [] + + +class TestEnrichFromHttpIndex: + """Tests for _enrich_from_http_index helper.""" + + def test_enriches_description(self): + from dns_aid.core.models import AgentRecord, Protocol + + agent = AgentRecord( + name="chat", + domain="example.com", + protocol=Protocol.MCP, + target_host="chat.example.com", + port=443, + ) + http_agent = HttpIndexAgent( + name="chat", + fqdn="_chat._mcp._agents.example.com", + description="A chat agent", + ) + _enrich_from_http_index(agent, http_agent) + assert agent.description == "A chat agent" + + def test_enriches_endpoint_override(self): + from dns_aid.core.models import AgentRecord, Protocol + + agent = AgentRecord( + name="chat", + domain="example.com", + protocol=Protocol.MCP, + target_host="chat.example.com", + port=443, + ) + http_agent = HttpIndexAgent( + name="chat", + fqdn="_chat._mcp._agents.example.com", + endpoint="https://chat.example.com/mcp", + ) + _enrich_from_http_index(agent, http_agent) + assert agent.endpoint_override == "https://chat.example.com/mcp" + assert agent.endpoint_source == "http_index" + + def test_does_not_override_existing_endpoint(self): + from dns_aid.core.models import AgentRecord, Protocol + + agent = AgentRecord( + name="chat", + domain="example.com", + protocol=Protocol.MCP, + target_host="chat.example.com", + port=443, + endpoint_override="https://original.com/mcp", + ) + http_agent = HttpIndexAgent( + name="chat", + fqdn="_chat._mcp._agents.example.com", + endpoint="https://chat.example.com/new-mcp", + ) + _enrich_from_http_index(agent, http_agent) + assert agent.endpoint_override == "https://original.com/mcp" + + +class TestProcessHttpAgent: + """Tests for _process_http_agent helper.""" + + @pytest.mark.asyncio + async def test_skips_name_mismatch(self): + http_agent = HttpIndexAgent( + name="billing", + fqdn="_billing._mcp._agents.example.com", + ) + result = await _process_http_agent(http_agent, "example.com", None, "chat") + assert result is None + + @pytest.mark.asyncio + async def test_skips_unparseable_fqdn(self): + http_agent = HttpIndexAgent( + name="bad", + fqdn="no-underscores.example.com", + ) + result = await _process_http_agent(http_agent, "example.com", None, None) + assert result is None + + @pytest.mark.asyncio + async def test_skips_unknown_protocol(self): + http_agent = HttpIndexAgent( + name="weird", + fqdn="_weird._unknown._agents.example.com", + ) + result = await _process_http_agent(http_agent, "example.com", None, None) + assert result is None + + @pytest.mark.asyncio + async def test_skips_protocol_filter_mismatch(self): + http_agent = HttpIndexAgent( + name="chat", + fqdn="_chat._a2a._agents.example.com", + ) + result = await _process_http_agent(http_agent, "example.com", Protocol.MCP, None) + assert result is None diff --git a/tests/unit/test_indexer.py b/tests/unit/test_indexer.py index 0aebd94..862fc3a 100644 --- a/tests/unit/test_indexer.py +++ b/tests/unit/test_indexer.py @@ -494,3 +494,126 @@ def test_result_created_default(self): ) assert result.created is False + + +# ============================================================================= +# Additional coverage tests +# ============================================================================= + + +class TestIndexEntryEq: + """Tests for IndexEntry.__eq__ with non-IndexEntry objects.""" + + def test_eq_non_indexentry(self): + """Comparing IndexEntry to non-IndexEntry returns NotImplemented.""" + entry = IndexEntry(name="chat", protocol="mcp") + result = entry.__eq__("not-an-entry") + assert result is NotImplemented + + def test_eq_none(self): + """Comparing IndexEntry to None returns NotImplemented.""" + entry = IndexEntry(name="chat", protocol="mcp") + result = entry.__eq__(None) + assert result is NotImplemented + + +class TestReadIndexViaDns: + """Tests for read_index_via_dns function.""" + + @pytest.mark.asyncio + async def test_read_index_via_dns_success(self): + """Successful DNS TXT query returns parsed entries.""" + from unittest.mock import AsyncMock, MagicMock, patch + + from dns_aid.core.indexer import read_index_via_dns + + mock_rdata = MagicMock() + mock_rdata.strings = [b"agents=chat:mcp,billing:a2a"] + + mock_answers = MagicMock() + mock_answers.__iter__ = MagicMock(return_value=iter([mock_rdata])) + + with patch("dns_aid.core.indexer.dns.asyncresolver.Resolver") as mock_resolver: + resolver_instance = MagicMock() + resolver_instance.resolve = AsyncMock(return_value=mock_answers) + mock_resolver.return_value = resolver_instance + + entries = await read_index_via_dns("example.com") + + assert len(entries) == 2 + assert IndexEntry(name="chat", protocol="mcp") in entries + assert IndexEntry(name="billing", protocol="a2a") in entries + + @pytest.mark.asyncio + async def test_read_index_via_dns_nxdomain(self): + """NXDOMAIN returns empty list.""" + from unittest.mock import AsyncMock, MagicMock, patch + + import dns.resolver + + from dns_aid.core.indexer import read_index_via_dns + + with patch("dns_aid.core.indexer.dns.asyncresolver.Resolver") as mock_resolver: + resolver_instance = MagicMock() + resolver_instance.resolve = AsyncMock(side_effect=dns.resolver.NXDOMAIN()) + mock_resolver.return_value = resolver_instance + + entries = await read_index_via_dns("nonexistent.com") + + assert entries == [] + + @pytest.mark.asyncio + async def test_read_index_via_dns_generic_error(self): + """Generic exception returns empty list.""" + from unittest.mock import AsyncMock, MagicMock, patch + + from dns_aid.core.indexer import read_index_via_dns + + with patch("dns_aid.core.indexer.dns.asyncresolver.Resolver") as mock_resolver: + resolver_instance = MagicMock() + resolver_instance.resolve = AsyncMock(side_effect=Exception("DNS failure")) + mock_resolver.return_value = resolver_instance + + entries = await read_index_via_dns("example.com") + + assert entries == [] + + +class TestDeleteIndexException: + """Tests for delete_index exception path.""" + + @pytest.mark.asyncio + async def test_delete_index_exception(self): + """Backend raises during delete → returns False.""" + from unittest.mock import AsyncMock, MagicMock + + from dns_aid.core.indexer import delete_index + + mock_backend = MagicMock() + mock_backend.delete_record = AsyncMock(side_effect=RuntimeError("boom")) + + result = await delete_index("example.com", mock_backend) + assert result is False + + +class TestSyncIndexScanException: + """Tests for sync_index when list_records raises.""" + + @pytest.mark.asyncio + async def test_sync_index_scan_exception(self): + """list_records raises during scan → returns failure.""" + from unittest.mock import AsyncMock, MagicMock + + from dns_aid.core.indexer import sync_index + + async def _failing_list(*args, **kwargs): + raise RuntimeError("scan boom") + # Make it an async generator that raises + yield # pragma: no cover + + mock_backend = MagicMock() + mock_backend.list_records = _failing_list + + result = await sync_index("example.com", mock_backend) + assert result.success is False + assert "scan boom" in result.message diff --git a/tests/unit/test_publisher.py b/tests/unit/test_publisher.py index 67a100a..995fcb2 100644 --- a/tests/unit/test_publisher.py +++ b/tests/unit/test_publisher.py @@ -236,3 +236,159 @@ async def test_unpublish_nonexistent(self, mock_backend: MockBackend): ) assert result is False + + @pytest.mark.asyncio + async def test_unpublish_protocol_string(self, mock_backend: MockBackend): + """Test unpublish accepts a string protocol and normalizes it.""" + await publish( + name="agent", + domain="example.com", + protocol="mcp", + endpoint="mcp.example.com", + backend=mock_backend, + ) + result = await unpublish( + name="agent", + domain="example.com", + protocol="MCP", # uppercase string + backend=mock_backend, + ) + assert result is True + + +class TestDefaultBackend: + """Tests for default backend management.""" + + def setup_method(self): + """Reset global state before each test.""" + from dns_aid.core.publisher import reset_default_backend + + reset_default_backend() + + def teardown_method(self): + """Reset global state after each test.""" + from dns_aid.core.publisher import reset_default_backend + + reset_default_backend() + + def test_set_default_backend(self): + """Test set_default_backend stores the backend.""" + from dns_aid.core.publisher import get_default_backend, set_default_backend + + backend = MockBackend() + set_default_backend(backend) + assert get_default_backend() is backend + + def test_reset_default_backend(self): + """Test reset_default_backend clears the stored backend.""" + from dns_aid.core.publisher import ( + get_default_backend, + reset_default_backend, + set_default_backend, + ) + + set_default_backend(MockBackend()) + reset_default_backend() + # After reset, calling get_default_backend without env var should raise + with pytest.raises(ValueError, match="DNS_AID_BACKEND must be set"): + get_default_backend() + + def test_get_default_backend_mock(self): + """Test get_default_backend with DNS_AID_BACKEND=mock.""" + from unittest.mock import patch + + from dns_aid.core.publisher import get_default_backend + + with patch.dict("os.environ", {"DNS_AID_BACKEND": "mock"}): + backend = get_default_backend() + assert backend.name == "mock" + + def test_get_default_backend_route53(self): + """Test get_default_backend with DNS_AID_BACKEND=route53.""" + from unittest.mock import patch + + from dns_aid.core.publisher import get_default_backend + + with patch.dict("os.environ", {"DNS_AID_BACKEND": "route53"}): + backend = get_default_backend() + assert backend.name == "route53" + + def test_get_default_backend_cloudflare(self): + """Test get_default_backend with DNS_AID_BACKEND=cloudflare.""" + from unittest.mock import patch + + from dns_aid.core.publisher import get_default_backend + + with patch.dict("os.environ", {"DNS_AID_BACKEND": "cloudflare"}): + backend = get_default_backend() + assert backend.name == "cloudflare" + + def test_get_default_backend_no_env_raises(self): + """Test get_default_backend raises when DNS_AID_BACKEND is not set.""" + from unittest.mock import patch + + from dns_aid.core.publisher import get_default_backend + + with ( + patch.dict("os.environ", {}, clear=True), + pytest.raises(ValueError, match="DNS_AID_BACKEND must be set"), + ): + get_default_backend() + + def test_get_default_backend_unknown_raises(self): + """Test get_default_backend raises for unknown backend type.""" + from unittest.mock import patch + + from dns_aid.core.publisher import get_default_backend + + with ( + patch.dict("os.environ", {"DNS_AID_BACKEND": "bogus"}), + pytest.raises(ValueError, match="Unknown DNS_AID_BACKEND"), + ): + get_default_backend() + + +class TestPublishEdgeCases: + """Tests for edge cases in publish function.""" + + @pytest.mark.asyncio + async def test_publish_sign_no_key_raises(self, mock_backend: MockBackend): + """Test publish with sign=True but no key path raises ValueError.""" + with pytest.raises(ValueError, match="private_key_path is required"): + await publish( + name="agent", + domain="example.com", + protocol="mcp", + endpoint="mcp.example.com", + sign=True, + private_key_path=None, + backend=mock_backend, + ) + + @pytest.mark.asyncio + async def test_publish_exception_returns_failure(self): + """Test publish returns success=False when backend raises.""" + from unittest.mock import AsyncMock, patch + + from dns_aid.backends.mock import MockBackend + + backend = MockBackend() + # Make zone_exists return True but publish_agent raise + with ( + patch.object(backend, "zone_exists", new_callable=AsyncMock, return_value=True), + patch.object( + backend, + "publish_agent", + new_callable=AsyncMock, + side_effect=RuntimeError("boom"), + ), + ): + result = await publish( + name="agent", + domain="example.com", + protocol="mcp", + endpoint="mcp.example.com", + backend=backend, + ) + assert result.success is False + assert "boom" in result.message diff --git a/tests/unit/test_validator.py b/tests/unit/test_validator.py index 3c81063..56251ed 100644 --- a/tests/unit/test_validator.py +++ b/tests/unit/test_validator.py @@ -506,3 +506,122 @@ async def test_verify_endpoint_unreachable(self): assert result.endpoint_reachable is False assert result.endpoint_latency_ms is None + + +# ============================================================================= +# Additional coverage tests +# ============================================================================= + + +class TestCheckDaneVerifyCert: + """Tests for _check_dane with verify_cert=True paths.""" + + @pytest.mark.asyncio + async def test_dane_verify_cert_match(self, mock_tlsa_rdata): + """DANE with verify_cert=True + cert match → True.""" + mock_answers = MagicMock() + mock_answers.__iter__ = MagicMock(return_value=iter([mock_tlsa_rdata])) + + with ( + patch("dns_aid.core.validator.dns.asyncresolver.Resolver") as mock_resolver, + patch( + "dns_aid.core.validator._match_dane_cert", + new_callable=AsyncMock, + return_value=True, + ), + ): + resolver_instance = MagicMock() + resolver_instance.resolve = AsyncMock(return_value=mock_answers) + mock_resolver.return_value = resolver_instance + + result = await _check_dane("agent.example.com", 443, verify_cert=True) + assert result is True + + @pytest.mark.asyncio + async def test_dane_verify_cert_mismatch(self, mock_tlsa_rdata): + """DANE with verify_cert=True + cert mismatch → False.""" + mock_answers = MagicMock() + mock_answers.__iter__ = MagicMock(return_value=iter([mock_tlsa_rdata])) + + with ( + patch("dns_aid.core.validator.dns.asyncresolver.Resolver") as mock_resolver, + patch( + "dns_aid.core.validator._match_dane_cert", + new_callable=AsyncMock, + return_value=False, + ), + ): + resolver_instance = MagicMock() + resolver_instance.resolve = AsyncMock(return_value=mock_answers) + mock_resolver.return_value = resolver_instance + + result = await _check_dane("agent.example.com", 443, verify_cert=True) + assert result is False + + @pytest.mark.asyncio + async def test_dane_query_failed(self): + """Generic exception during TLSA query → None.""" + with patch("dns_aid.core.validator.dns.asyncresolver.Resolver") as mock_resolver: + resolver_instance = MagicMock() + resolver_instance.resolve = AsyncMock(side_effect=Exception("network error")) + mock_resolver.return_value = resolver_instance + + result = await _check_dane("agent.example.com", 443) + assert result is None + + +class TestCheckDnssecTxtFallback: + """Tests for DNSSEC TXT fallback paths.""" + + @pytest.mark.asyncio + async def test_dnssec_txt_fallback_no_ad(self): + """TXT fallback where AD flag is NOT set → False.""" + mock_response = MagicMock() + mock_response.flags = 0 # No AD flag + + mock_answer = MagicMock() + mock_answer.response = mock_response + + with patch("dns_aid.core.validator.dns.asyncresolver.Resolver") as mock_resolver: + resolver_instance = MagicMock() + resolver_instance.use_edns = MagicMock() + # SVCB → NoAnswer, TXT → answer without AD + resolver_instance.resolve = AsyncMock( + side_effect=[dns.resolver.NoAnswer(), mock_answer] + ) + mock_resolver.return_value = resolver_instance + + result = await _check_dnssec("_agent._mcp._agents.example.com") + assert result is False + + @pytest.mark.asyncio + async def test_dnssec_txt_fallback_exception(self): + """TXT fallback raises exception → False.""" + with patch("dns_aid.core.validator.dns.asyncresolver.Resolver") as mock_resolver: + resolver_instance = MagicMock() + resolver_instance.use_edns = MagicMock() + # SVCB → NoAnswer, TXT → Exception + resolver_instance.resolve = AsyncMock( + side_effect=[dns.resolver.NoAnswer(), Exception("TXT fail")] + ) + mock_resolver.return_value = resolver_instance + + result = await _check_dnssec("_agent._mcp._agents.example.com") + assert result is False + + +class TestCheckEndpointErrors: + """Tests for _check_endpoint error paths.""" + + @pytest.mark.asyncio + async def test_endpoint_generic_error(self): + """Generic Exception during endpoint check → not reachable.""" + with patch("dns_aid.core.validator.httpx.AsyncClient") as mock_client: + mock_instance = AsyncMock() + mock_instance.get = AsyncMock(side_effect=Exception("unexpected")) + mock_instance.__aenter__ = AsyncMock(return_value=mock_instance) + mock_instance.__aexit__ = AsyncMock(return_value=None) + mock_client.return_value = mock_instance + + result = await _check_endpoint("agent.example.com", 443) + assert result["reachable"] is False