diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c2715d2..47d458a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + python-version: ["3.12", "3.13", "3.14"] steps: - name: Check out repository diff --git a/CHANGELOG.md b/CHANGELOG.md index 40bd0d2..c8c2823 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,83 @@ # Changelog -> * Pre-v1.00, 11.2020 -- Initial release - Marcel Zehnder -> * v1.00, 11.2020 -- Adjustments for OpenSource - Andreas Graber -> * v1.3, 06.2021 -- Snapshot creation added - Dario Kaelin -> * v1.5, 01.2023 -- Dependency Update -> * v1.6, 06.2024 -- Improvement for subscription - Dario Kaelin -> * v1.7, 12.2025 -- Dependency Update - Dario Kaelin \ No newline at end of file + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [2.0.0] - 2026-10-26 + +### Added + +- Typed, thread-safe `AciClient` for direct APIC and Nexus Dashboard gateway + access, with password and certificate authentication. +- Structured queries, APIC filter helpers, generic response models, explicit + pagination, and connection-pool limits. +- Subscription handles, snapshot job monitoring, and `session_token` for + external WebSocket integrations. +- Injectable HTTP transports and deterministic pytest helpers, with expanded + examples and offline/live APIC testing guides. +- A runnable README example testing an automation function with a reusable + pytest fixture. + +### Changed + +- Requires Python 3.12 or newer. +- Modern clients verify TLS by default, use `cryptography` for certificate + signing, and exclude secure fields from snapshots by default. +- Authentication sessions and response decoding now live in dedicated internal + modules, keeping the public client focused on HTTP routing and operations. +- Snapshot waits and pagination deadlines include authentication and HTTP + requests, cap phase timeouts, and reject late results. + +### Fixed + +- Require patched certifi, urllib3, and cryptography releases in installed + distributions, with a compatible pyOpenSSL minimum. +- Redact APIC shared keys and nested credential fields in test request history, + including credential queries and Nexus Dashboard proxy paths. +- Raise `ResponseDecodeError` for malformed managed objects, authentication, + login-domain, and subscription responses. +- Include guides and examples in source distributions and use repository links + for documentation rendered on PyPI. + +### Deprecated + +- `ACI` and `ACICert` emit `LegacyAPIWarning`; their existing interfaces remain + compatible in 2.0. + +## [1.7] - 2025-12 + +### Changed + +- Updated project dependencies. + +## [1.6] - 2024-06 + +### Changed + +- Improved subscription support. + +## [1.5] - 2023-01 + +### Changed + +- Updated project dependencies. + +## [1.3] - 2021-06 + +### Added + +- Added snapshot creation. + +## [1.0.0] - 2020-11 + +### Changed + +- Prepared the project for open-source distribution. + +## [0.x] - 2020-11 + +### Added + +- Initial release. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6a0a124..9fb62ae 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,7 +37,7 @@ Releases are done from the upstream repository: 2. The upstream CI workflow runs tests and lint checks for the pull request. 3. After review, the pull request is merged into `netcloud/aciClient`. 4. When maintainers want to publish a release, they update the version in `pyproject.toml` if needed. -5. Maintainers create a version tag such as `v1.9.0` in `netcloud/aciClient`. +5. Maintainers create a version tag such as `v2.0.0` in `netcloud/aciClient`. 6. The upstream publish workflow builds the package with `uv build` and publishes it to PyPI. Publishing requires PyPI trusted publishing to be configured for `netcloud/aciClient`. @@ -54,6 +54,11 @@ Publishing requires PyPI trusted publishing to be configured for `netcloud/aciCl ## Local Development +Tests in `test/` are grouped by behavior: client configuration, authentication, +gateway routing, responses, pagination, snapshots, subscriptions, and consumer +test helpers. Shared factories live in `test/helpers.py`; `test/test_aci.py` +covers the legacy `ACI` interface. Add tests to the relevant behavior module. + ```bash uv sync --group dev uv run pytest diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..cb8a879 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,3 @@ +include CHANGELOG.md CONTRIBUTING.md TESTING.md +recursive-include examples *.md *.py +recursive-include test *.py diff --git a/README.md b/README.md index 21038d9..f609cc0 100644 --- a/README.md +++ b/README.md @@ -1,144 +1,105 @@ -# aciClient +# aciClient 2.0 -![PyPi](https://img.shields.io/pypi/v/aciClient) +A lightweight, typed, thread-safe Python 3.12+ client for Cisco ACI automation +and pytest. Connect directly to APIC or through the Nexus Dashboard ACI gateway. -A python wrapper to the Cisco ACI REST-API. - -## Python Version - -We support Python 3.10 and up. +[Examples](https://github.com/netcloud/aciClient/blob/master/examples/README.md) · [Testing](https://github.com/netcloud/aciClient/blob/master/TESTING.md) · [Migration](https://github.com/netcloud/aciClient/blob/master/examples/README.md#legacy-migration) · [Contributing](https://github.com/netcloud/aciClient/blob/master/CONTRIBUTING.md) ## Installation -With `pip`: ```bash pip install aciClient ``` -With `uv`: - -```bash -uv add aciClient -``` - -## Installation for Developing -```bash -git clone https://github.com/netcloud/aciClient.git -uv sync --group dev -``` - -## Usage +## Quick start -### Initialisation +The examples use Cisco's shared +[Always-On ACI Simulator](https://developer.cisco.com/docs/aci/sandbox/) and +tenant `aciclient`. +Run the [create example](https://github.com/netcloud/aciClient/blob/master/examples/README.md#create-a-bridge-domain-in-a-tenant) +first, then read the tenant using the public sandbox credentials: -### Username/password ```python -import aciClient -import logging - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - -proxies = { - 'http': 'socks5://127.0.0.1:1080', - 'https': 'socks5://127.0.0.1:1080' -} - -aciclient = aciClient.ACI(apic_hostname, apic_username, apic_password, refresh=False, proxies=proxies) -try: - aciclient.login() - - aciclient.getJson(uri) - aciclient.postJson(config) - aciclient.deleteMo(dn) - - aciclient.logout() -except Exception as e: - logger.exception("Stack Trace") -``` +from aciClient import AciClient, PasswordAuth -For automatic authentication token refresh you can set variable ```refresh``` to True - -```python -aciclient = aciClient.ACI(apic_hostname, apic_username, apic_password, refresh=True) +with AciClient( + "sandboxapicdc.cisco.com", PasswordAuth("admin", "!v3G@!4@Y") +) as client: + tenant = client.get_mo("uni/tn-aciclient").one() + print(tenant.attributes["name"]) ``` +Construction performs no network traffic. Login is lazy and coordinated +across threads; the context manager closes the client. `one()` raises +`NoResultError` when the tenant does not exist. -### Certificate/signature -```python -import aciClient -import logging - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) +## Examples -aciclient = aciClient.ACICert(apic_hostname, path_to_privatekey_file, certificate_dn) +| Task | Where to start | +| --- | --- | +| Create, read, update, or delete bridge domains | [Tenant management](https://github.com/netcloud/aciClient/blob/master/examples/README.md#tenant-management) | +| Work with child objects and APIC filter operators | [Children and filtering](https://github.com/netcloud/aciClient/blob/master/examples/README.md#read-a-tenants-child-bridge-domains) | +| Integrate a shared client into an application or FastAPI | [Client lifecycle](https://github.com/netcloud/aciClient/blob/master/examples/README.md#client-lifecycle) | +| Receive APIC notifications with an external WebSocket | [Subscription workflow](https://github.com/netcloud/aciClient/blob/master/examples/README.md#subscriptions) | +| Test automation with an injected HTTP transport | [Pytest guide](https://github.com/netcloud/aciClient/blob/master/TESTING.md) | -try: - aciclient.getJson(uri) - aciclient.postJson(config) - aciclient.deleteMo(dn) -except Exception as e: - logger.exception("Stack Trace") -``` +## Nexus Dashboard gateway -## Examples +Nexus Dashboard provides an +[APIC API Proxy](https://developer.cisco.com/docs/nexus-dashboard/latest/get-apic-data/). +For ordinary APIC requests, change only client creation: use the Nexus +Dashboard IP or hostname as `endpoint` and the ACI cluster name shown in +Nexus Dashboard as `nexus_dashboard_cluster_name`. Request methods, DNs, +payloads, filters, responses, and pagination stay the same. -### get config ```python -tenants = aciclient.getJson('class/fvTenant.json?order-by=fvTenant.dn|asc') +from aciClient import AciClient, NexusDashboardPasswordAuth -for mo in tenants: - print(f'tenant DN: {mo["fvTenant"]["attributes"]["dn"]}') -``` - -### post config -```python -config = { - "fvTenant": { - "attributes": { - "dn": "uni/tn-XYZ" - } - } -} - -aciclient.postJson(config) +client = AciClient( + endpoint="nd.example.test", + auth=NexusDashboardPasswordAuth("admin", "secret"), + nexus_dashboard_cluster_name="production-fabric", +) ``` -### delete MOs -```python -aciclient.deleteMo('uni/tn-XYZ') -``` +Gateway access requires an enabled APIC Proxy Mode and a Super-Admin account. +Only `NexusDashboardPasswordAuth` is supported in this mode; subscriptions +are unavailable. See [gateway setup and limitations](https://github.com/netcloud/aciClient/blob/master/examples/README.md#nexus-dashboard-gateway) +for authentication, per-cluster clients, and session lifecycle details. -### create snapshot -You can specify a tenant in variable ```target_dn``` or not provide any to do a fabric-wide snapshot. -```python -aci.snapshot(description='test', target_dn='/uni/tn-test') -``` +## Client behavior -### Subscriptions -You can subscribe to an ACI object with websocket and get near-instant updates on-change. -To use the subscriptions you have to: -- Login to ACI -- Open websocket to ACI -- Subscribe to an ACI object via aciClient.subscribe -- Refresh subscription to an ACI object via aciClient.subscription_refresh -- Handle messages sent from ACI through websocket +| Area | What to expect | +| --- | --- | +| Authentication | Direct APIC supports `PasswordAuth` and `CertificateAuth`. See [domains and certificate keys](https://github.com/netcloud/aciClient/blob/master/examples/README.md#authentication-and-tls). | +| TLS | Verification is enabled by default. CA paths and `SSLContext` are supported; `verify=False` emits `InsecureTLSWarning`. | +| Results | Methods return `AciResponse` with immutable generic `ManagedObject` objects. Unknown classes and attributes are preserved; APIC attribute values remain strings. | +| Writes | Build explicit payloads: read objects may contain blank sensitive attributes. | +| Pagination | Requests do not paginate automatically. Use `iter_pages`, `iter_objects`, `collect_pages`, or `collect_objects` with `PaginationPolicy`; effective page size is capped at 2,000. [Limits and consistency](https://github.com/netcloud/aciClient/blob/master/examples/README.md#responses-and-pagination). | +| Connections | Share one client per connection identity across threads and create separate clients per process. Applications own shutdown and registries. [Lifecycle details](https://github.com/netcloud/aciClient/blob/master/examples/README.md#client-lifecycle). | +| Retries | Each request is sent once. Applications own retries, queuing, and metrics. | +| Snapshots | `create_snapshot()` returns a job; `job.wait()` waits for completion. Secure fields are excluded by default. [Snapshot example](https://github.com/netcloud/aciClient/blob/master/examples/README.md#snapshots). | -You can find example code here: examples/subscription.py +## Testing with pytest -## Contributing +Test your automation offline using scripted APIC responses. Start with the +[runnable pytest example](https://github.com/netcloud/aciClient/blob/master/examples/test_automation.py), or see the +[testing guide](https://github.com/netcloud/aciClient/blob/master/TESTING.md) for fixtures and error handling. -Please read [CONTRIBUTING.md](https://github.com/netcloud/aciClient/blob/master/CONTRIBUTING.md) for details on our code -of conduct, and the process for submitting pull requests to this project. +## Legacy migration -## Authors +`ACI` and `ACICert` remain available in 2.0 and emit `LegacyAPIWarning`. +Version 2.1, targeted for October 2027, will require explicit +`allow_legacy=True`. See the [migration guide](https://github.com/netcloud/aciClient/blob/master/examples/README.md#legacy-migration) +for compatibility details, method replacements, and the removal timeline. -* **Marcel Zehnder** - *Initial work* -* **Andreas Graber** - *Migration to open source* -* **Richard Strnad** - *Paginagtion for large requests, various small stuff* -* **Dario Kaelin** - *Added snapshot creation* +## Development -## License +```bash +uv sync --group dev +uv run pytest +uv run ruff check . +``` -This project is licensed under MIT - see the [LICENSE.md](https://github.com/netcloud/aciClient/blob/master/LICENSE.md) file for details. +Repository tests use mocked transports and require no APIC or Nexus Dashboard. +See [CONTRIBUTING.md](https://github.com/netcloud/aciClient/blob/master/CONTRIBUTING.md) for contribution and release procedures. diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..ccb79ed --- /dev/null +++ b/TESTING.md @@ -0,0 +1,165 @@ +# Testing with pytest + +`AciClient` accepts an `httpx` transport, so tests can exercise the real client +without contacting an APIC. The helpers in `aciClient.testing` provide scripted +requests and common APIC response bodies. + +These examples use the same public Cisco sandbox endpoint and credentials as +the [README](README.md#quick-start). Injected transports keep all requests +offline; running these tests does not access or change the shared sandbox. + +## A reusable fixture + +```python +import pytest + +from aciClient import AciClient, PasswordAuth +from aciClient.testing import ( + ExpectedRequest, + ScriptedTransport, + imdata_response, + login_response, + managed_object, +) + + +@pytest.fixture +def tenant_client(): + transport = ScriptedTransport( + [ + ExpectedRequest( + "POST", + "/api/aaaLogin.json", + json={ + "aaaUser": { + "attributes": {"name": "admin", "pwd": "!v3G@!4@Y"} + } + }, + response=login_response(), + ), + ExpectedRequest( + "GET", + "/api/mo/uni/tn-aciclient.json", + response=imdata_response( + managed_object( + "fvTenant", {"dn": "uni/tn-aciclient", "name": "aciclient"} + ) + ), + ), + ] + ) + + with AciClient( + "sandboxapicdc.cisco.com", + PasswordAuth("admin", "!v3G@!4@Y"), + transport=transport, + ) as client: + yield client, transport + + transport.assert_all_consumed() + + +def test_reads_tenant(tenant_client): + client, transport = tenant_client + + tenant = client.get_mo("uni/tn-aciclient").one() + + assert tenant.dn == "uni/tn-aciclient" + assert tenant.attributes["name"] == "aciclient" + assert transport.history[-1].path == "/api/mo/uni/tn-aciclient.json" +``` + +`ScriptedTransport` checks the method, path, query parameters, selected headers, +and JSON body. Calls are matched in order by default. An unexpected request +raises `AssertionError` instead of reaching the network, and +`assert_all_consumed()` detects expected calls that never happened. + +`ExpectedRequest.query` accepts the same `AciQuery`, mapping, or sequence of +pairs as the client's `params` argument. Boolean values and repeated query +parameters use the same serialization in requests and expectations. + +Recorded authorization headers, cookies, passwords, tokens, and APIC shared +keys are redacted by credential field name, including nested JSON attributes +and named query parameters. Filters mentioning credential fields are masked, +as are credential queries embedded in Nexus Dashboard proxy paths. Matching +still uses the original request. Secrets placed in free text or unrelated +fields are not detected automatically; avoid including those in assertion +messages. Closing `AciClient` also closes the injected transport. + +## Testing API errors + +Use `api_error_response()` to build the error shape returned by APIC: + +```python +import pytest + +from aciClient import AciApiError, AciClient, PasswordAuth +from aciClient.testing import ( + ExpectedRequest, + ScriptedTransport, + api_error_response, + login_response, +) + + +def test_create_tenant_reports_apic_error(): + transport = ScriptedTransport( + [ + ExpectedRequest( + "POST", + "/api/aaaLogin.json", + response=login_response(), + ), + ExpectedRequest( + "POST", + "/api/mo.json", + response=api_error_response("103", "invalid tenant"), + ), + ] + ) + + with AciClient( + "sandboxapicdc.cisco.com", + PasswordAuth("admin", "!v3G@!4@Y"), + transport=transport, + ) as client, pytest.raises(AciApiError, match="invalid tenant"): + client.post_mo( + {"fvTenant": {"attributes": {"dn": "uni/tn-aciclient", "name": "aciclient"}}} + ) +``` + +An `ExpectedRequest` can return an exception such as `httpx.ConnectError` to +test transport failures. Set `repeat` when the same request is expected more +than once, or create `ScriptedTransport(..., ordered=False)` when call order is +irrelevant. + +## Dynamic responses + +For behavior that is easier to express in code, pass an `httpx.MockTransport`: + +```python +import httpx + +from aciClient import AciClient, PasswordAuth +from aciClient.testing import imdata_response, login_response + + +def handler(request: httpx.Request) -> httpx.Response: + payload = ( + login_response() + if request.url.path.endswith("/aaaLogin.json") + else imdata_response() + ) + return httpx.Response(200, json=payload, request=request) + + +client = AciClient( + "sandboxapicdc.cisco.com", + PasswordAuth("admin", "!v3G@!4@Y"), + transport=httpx.MockTransport(handler), +) +``` + +Use `ScriptedTransport` for most tests because its expected request sequence is +short and explicit. `MockTransport` is useful for stateful or highly dynamic +test behavior. diff --git a/aciClient/__init__.py b/aciClient/__init__.py index 41a10a4..30b9b78 100644 --- a/aciClient/__init__.py +++ b/aciClient/__init__.py @@ -1,4 +1,134 @@ -from aciClient.aci import ACI -from aciClient.aciCertClient import ACICert +"""Typed Cisco APIC clients and preserved legacy adapters.""" -__all__ = ["ACI", "ACICert"] +from .aci import ACI +from .aciCertClient import ACICert +from .auth import ( + CertificateAuth, + NexusDashboardPasswordAuth, + PasswordAuth, +) +from .client import AciClient, InsecureTLSWarning +from .deprecations import LegacyAPIWarning +from .exceptions import ( + AciApiError, + AciClientError, + AciJobError, + AciTimeoutError, + AuthenticationError, + AuthorizationError, + ConfigurationError, + JobFailedError, + JobTimeoutError, + ManagedObjectError, + MultipleResultsError, + NoResultError, + QueryParameterError, + ResponseDecodeError, + TransportError, +) +from .models import ( + AciResponse, + ApicVersion, + ConnectionIdentity, + ConnectionInfo, + HttpConnectionLimits, + JobState, + LoginDomain, + LoginDomains, + ManagedObject, + PaginationPolicy, + SessionInfo, + SnapshotJobInfo, + SnapshotResult, + SubscriptionInitialResponse, +) +from .query import ( + AciQuery, + QueryParams, + all_bits, + always_false, + always_true, + and_, + any_bit, + begins_with, + between, + eq, + ge, + gt, + le, + lt, + ne, + not_, + or_, + passive_holder, + property_holder, + raw_filter, + wildcard, + xor_, +) +from .snapshots import SnapshotJob +from .subscriptions import Subscription + +__all__ = [ + "ACI", + "ACICert", + "AciApiError", + "AciClient", + "AciClientError", + "AciJobError", + "AciQuery", + "AciResponse", + "AciTimeoutError", + "ApicVersion", + "AuthenticationError", + "AuthorizationError", + "CertificateAuth", + "ConfigurationError", + "ConnectionIdentity", + "ConnectionInfo", + "HttpConnectionLimits", + "InsecureTLSWarning", + "JobFailedError", + "JobState", + "JobTimeoutError", + "LegacyAPIWarning", + "LoginDomain", + "LoginDomains", + "ManagedObject", + "ManagedObjectError", + "MultipleResultsError", + "NexusDashboardPasswordAuth", + "NoResultError", + "PaginationPolicy", + "PasswordAuth", + "QueryParameterError", + "QueryParams", + "ResponseDecodeError", + "SessionInfo", + "SnapshotJob", + "SnapshotJobInfo", + "SnapshotResult", + "Subscription", + "SubscriptionInitialResponse", + "TransportError", + "all_bits", + "always_false", + "always_true", + "and_", + "any_bit", + "begins_with", + "between", + "eq", + "ge", + "gt", + "le", + "lt", + "ne", + "not_", + "or_", + "passive_holder", + "property_holder", + "raw_filter", + "wildcard", + "xor_", +] diff --git a/aciClient/_diagnostics.py b/aciClient/_diagnostics.py new file mode 100644 index 0000000..5d28609 --- /dev/null +++ b/aciClient/_diagnostics.py @@ -0,0 +1,36 @@ +"""Shared redaction for diagnostics and recorded test requests.""" + +from collections.abc import Mapping +from typing import Any + + +def is_sensitive(name: str) -> bool: + """Recognize credential fields, including APIC shared keys and passwords.""" + normalized = name.lower().replace("-", "").replace("_", "") + return normalized in {"authorization", "proxyauthorization", "userpass"} or any( + word in normalized + for word in ( + "password", + "passwd", + "pwd", + "token", + "secret", + "key", + "signature", + "cookie", + ) + ) + + +def redact(value: Any) -> Any: + """Copy structured diagnostic data while masking credential fields.""" + if isinstance(value, Mapping): + return { + name: "********" if is_sensitive(str(name)) else redact(item) + for name, item in value.items() + } + if isinstance(value, list): + return [redact(item) for item in value] + if isinstance(value, tuple): + return tuple(redact(item) for item in value) + return value diff --git a/aciClient/_responses.py b/aciClient/_responses.py new file mode 100644 index 0000000..39483ae --- /dev/null +++ b/aciClient/_responses.py @@ -0,0 +1,233 @@ +"""Decode APIC and Nexus Dashboard responses into public value objects.""" + +from __future__ import annotations + +import re +from collections.abc import Mapping +from typing import Any + +import httpx + +from .exceptions import ( + AciApiError, + AuthenticationError, + AuthorizationError, + ResponseDecodeError, +) +from .models import AciResponse, ApicVersion, LoginDomain, LoginDomains, ManagedObject + +_VERSION_RE = re.compile( + r"(?P\d+)\.(?P\d+)(?:\((?P\d+)(?P[^)]*)\))?" +) + + +def parse_version(raw: str) -> ApicVersion: + match = _VERSION_RE.search(raw or "") + if not match: + raise ResponseDecodeError(f"unable to parse APIC version {raw!r}") + patch = int(match.group("patch")) if match.group("patch") else None + build = (match.group("build") or "").lstrip(".,- ") or None + return ApicVersion( + int(match.group("major")), int(match.group("minor")), patch, build + ) + + +def _json_object( + response: httpx.Response, *, allow_empty: bool = False +) -> Mapping[str, Any]: + if allow_empty and not response.content: + return {} + try: + raw = response.json() + except ValueError as exc: + raise ResponseDecodeError("response is not valid JSON") from exc + if not isinstance(raw, Mapping): + raise ResponseDecodeError("response must be an object") + return raw + + +def _response_error(raw: Mapping[str, Any]) -> tuple[str | None, str | None]: + for item in imdata_records(raw): + if not isinstance(item, Mapping): + continue + if "error" in item: + error = item["error"] + if not isinstance(error, Mapping): + raise ResponseDecodeError("APIC error must be an object") + body = error.get("attributes", {}) + if not isinstance(body, Mapping): + raise ResponseDecodeError("APIC error attributes must be an object") + return ( + str(body.get("code") or "") or None, + str(body.get("text") or "APIC request failed"), + ) + code = raw.get("code", raw.get("statusCode")) + error = raw.get("error") + if (isinstance(error, str) and error) or ( + str(code).isdigit() and int(str(code)) >= 400 + ): + return str(code) if code is not None else None, str( + raw.get("message") or error or raw.get("description") or "request failed" + ) + return None, None + + +def _raise_api_error( + response: httpx.Response, + method: str, + path: str, + raw: Mapping[str, Any], + error_code: str | None, + error_text: str | None, +) -> None: + if not 200 <= response.status_code < 300 or error_code or error_text: + if response.status_code == 401: + raise AuthenticationError(error_text or "authentication failed") + if response.status_code == 403: + raise AuthorizationError(error_text or "authorization failed") + error_text = error_text or raw.get("message") or raw.get("description") + message = error_text or f"APIC request failed with HTTP {response.status_code}" + raise AciApiError( + message, + status_code=response.status_code, + error_code=error_code, + error_text=error_text, + method=method, + request_url=path, + raw=raw, + ) + + +def decode_response(response: httpx.Response, method: str, path: str) -> AciResponse: + raw = checked_json(response, method, path) + imdata = imdata_records(raw) + objects = tuple(ManagedObject.from_imdata(item) for item in imdata) + total = raw.get("totalCount") + try: + total_count = int(total) if total is not None else None + except (TypeError, ValueError, OverflowError) as exc: + raise ResponseDecodeError("totalCount must be an integer") from exc + return AciResponse( + objects, + tuple(dict(x) for x in imdata), + total_count, + response.status_code, + dict(response.headers), + dict(raw), + ) + + +def checked_json(response: httpx.Response, method: str, path: str) -> Mapping[str, Any]: + try: + raw = _json_object(response, allow_empty=True) + code, message = _response_error(raw) + except ResponseDecodeError: + _raise_api_error(response, method, path, {}, None, None) + raise + _raise_api_error(response, method, path, raw, code, message) + return raw + + +def imdata_records(raw: Mapping[str, Any]) -> list[Any]: + records = raw.get("imdata", []) + if not isinstance(records, list): + raise ResponseDecodeError("imdata must be a list") + return records + + +def decode_subscription_id(raw: Mapping[str, Any]) -> str: + """Require a usable subscription identifier instead of leaking KeyError.""" + value = raw.get("subscriptionId") + if ( + isinstance(value, bool) + or not isinstance(value, (str, int)) + or not str(value).strip() + ): + raise ResponseDecodeError("subscription response must contain a subscriptionId") + return str(value) + + +def decode_login( + raw: Mapping[str, Any], gateway: bool +) -> tuple[Mapping[str, Any], str | None, str | None]: + """Validate the platform-specific login envelope and credential types.""" + values: Mapping[str, Any] = raw if gateway else {} + if not gateway: + for entry in imdata_records(raw): + if not isinstance(entry, Mapping): + raise ResponseDecodeError("login record must be an object") + if "aaaLogin" not in entry: + continue + login = entry["aaaLogin"] + if not isinstance(login, Mapping): + raise ResponseDecodeError("aaaLogin must be an object") + values = login.get("attributes", {}) + if not isinstance(values, Mapping): + raise ResponseDecodeError("login attributes must be an object") + if values: + break + token = ( + (values.get("jwttoken") or values.get("token")) + if gateway + else values.get("token") + ) + version = None if gateway else values.get("version") or values.get("apicVersion") + if token is not None and not isinstance(token, str): + raise ResponseDecodeError("authentication token must be a string") + if version is not None and not isinstance(version, str): + raise ResponseDecodeError("APIC version must be a string") + return values, token, version + + +def decode_domains(raw: Mapping[str, Any], gateway: bool) -> LoginDomains: + result = [] + default = str(raw["defaultDomain"]) if raw.get("defaultDomain") else None + if gateway: + entries = raw.get("domains") or raw.get("items") or raw.get("data") or [] + if isinstance(entries, Mapping): + entries = entries.get("items", []) + if not isinstance(entries, list): + raise ResponseDecodeError("login domains must be a list") + for item in entries: + if not isinstance(item, Mapping): + raise ResponseDecodeError("login domain must be an object") + name = str(item.get("name") or item.get("id") or "") + is_default = name == default or str( + item.get("isDefault") or item.get("default") or "" + ).lower() in {"yes", "true", "1"} + if is_default: + default = name + result.append( + LoginDomain( + name, item.get("displayName"), item.get("realm"), is_default, item + ) + ) + else: + for entry in imdata_records(raw): + if not isinstance(entry, Mapping): + raise ResponseDecodeError("login domain must be an object") + if "name" in entry or "domain" in entry: + attrs = entry + else: + body = next(iter(entry.values()), {}) + if not isinstance(body, Mapping): + raise ResponseDecodeError("invalid login domain record") + attrs = body.get("attributes", {}) + if not isinstance(attrs, Mapping): + raise ResponseDecodeError( + "login domain attributes must be an object" + ) + name = str(attrs.get("name") or attrs.get("domain") or "") + is_default = str(attrs.get("isDefault", "")).lower() in {"yes", "true", "1"} + if is_default: + default = name + result.append( + LoginDomain( + name, + attrs.get("guiBanner") or attrs.get("displayName"), + attrs.get("realm"), + is_default, + entry, + ) + ) + return LoginDomains(tuple(result), default, raw) diff --git a/aciClient/_session.py b/aciClient/_session.py new file mode 100644 index 0000000..835f5b9 --- /dev/null +++ b/aciClient/_session.py @@ -0,0 +1,329 @@ +"""Authentication sessions, token renewal, and certificate request signing.""" + +from __future__ import annotations + +import base64 +import json +import math +import threading +from collections.abc import Callable, Mapping +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any + +import httpx + +from ._responses import checked_json, decode_login, parse_version +from ._timeouts import remaining_request_time +from .auth import Auth, CertificateAuth, NexusDashboardPasswordAuth, PasswordAuth +from .exceptions import AuthenticationError, ConfigurationError, ResponseDecodeError +from .models import ConnectionInfo, SessionInfo +from .runtime import SyncRuntime + + +def _qualified_user(auth: PasswordAuth) -> str: + already = auth.username.startswith("apic:") and "\\" in auth.username + if auth.domain and already: + raise ConfigurationError( + "username is already APIC-qualified; do not also supply domain" + ) + return f"apic:{auth.domain}\\{auth.username}" if auth.domain else auth.username + + +def _jwt_lifetime(token: str, now: datetime) -> float | None: + """Read expiry as a refresh hint; authentication is verified by ND.""" + try: + payload = token.split(".")[1] + claims = json.loads( + base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4)) + ) + if not isinstance(claims, Mapping): + return None + expiry = float(claims["exp"]) + if math.isfinite(expiry): + return max(0.0, expiry - now.timestamp()) + except (ValueError, TypeError, KeyError, IndexError, UnicodeError): + pass + return None + + +def validate_auth(auth: Auth, gateway: bool) -> None: + if gateway and not isinstance(auth, NexusDashboardPasswordAuth): + raise ConfigurationError("gateway mode requires NexusDashboardPasswordAuth") + if not gateway and not isinstance(auth, (PasswordAuth, CertificateAuth)): + raise ConfigurationError( + "direct APIC mode requires PasswordAuth or CertificateAuth" + ) + + +class AuthSession: + """Coordinate authentication independently of HTTP routing and public methods. + + The client supplies request sending and cookie cleanup. This object owns + session state and serializes login, refresh, and logout across threads. + """ + + def __init__( + self, + auth: Auth, + endpoint: str, + cluster: str | None, + principal: str, + runtime: SyncRuntime, + send: Callable[..., httpx.Response], + clear_cookies: Callable[[], None], + ) -> None: + self.auth = auth + self._endpoint = endpoint + self._cluster = cluster + self._principal = principal + self._runtime = runtime + self._send = send + self._clear_cookies = clear_cookies + self.connection_info: ConnectionInfo | None = None + self._token: str | None = None + self._authenticated_at: datetime | None = None + self._last_refresh_at: datetime | None = None + self._refresh_deadline: datetime | None = None + self._refresh_at_monotonic: float | None = None + self._maximum_deadline: datetime | None = None + self._maximum_at_monotonic: float | None = None + self._auth_condition = threading.Condition(threading.RLock()) + self._auth_in_progress = False + self._auth_error: BaseException | None = None + + @property + def token(self) -> str | None: + return self._token + + @property + def is_authenticated(self) -> bool: + return isinstance(self.auth, CertificateAuth) or ( + self._token is not None and not self._session_expired() + ) + + @property + def info(self) -> SessionInfo: + return SessionInfo( + self.is_authenticated, + self._principal, + self._authenticated_at, + self._refresh_deadline, + self._maximum_deadline, + self._last_refresh_at, + ) + + def invalidate(self, request: httpx.Request) -> None: + """Clear a rejected token only if this request used the current session.""" + with self._auth_condition: + cookie = request.headers.get("Cookie", "") + if self._token and cookie.endswith("=" + self._token): + self._clear_session() + + def headers(self, method: str, wire_path: str, body: bytes) -> dict[str, str]: + headers = {} + if isinstance(self.auth, CertificateAuth): + try: + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import padding + + key = self.auth.private_key + if isinstance(key, Path): + key = key.read_bytes() + if isinstance(key, bytes): + password = self.auth.private_key_password + if isinstance(password, str): + password = password.encode() + key = serialization.load_pem_private_key(key, password=password) + signature = key.sign( + method.upper().encode() + wire_path.encode() + body, + padding.PKCS1v15(), + hashes.SHA256(), + ) + except Exception as exc: + raise ConfigurationError( + "unable to load or use certificate private key" + ) from exc + cookie = { + "APIC-Certificate-Fingerprint": self.auth.certificate_name, + "APIC-Certificate-Algorithm": "v1.0", + "APIC-Certificate-DN": self.auth.certificate_dn, + "APIC-Request-Signature": base64.b64encode(signature).decode(), + } + headers["Cookie"] = "; ".join(f"{k}={v}" for k, v in cookie.items()) + elif self._token: + cookie_name = "AuthCookie" if self._cluster else "APIC-cookie" + headers["Cookie"] = f"{cookie_name}={self._token}" + return headers + + def _set_login(self, raw: Mapping[str, Any]) -> None: + values, token, version = decode_login(raw, bool(self._cluster)) + if not token: + raise AuthenticationError("authentication response did not contain a token") + now = datetime.now(timezone.utc) + self._token = str(token) + self._authenticated_at = self._authenticated_at or now + self._last_refresh_at = now + refresh = values.get("refreshTimeoutSeconds") + maximum = values.get("maximumLifetimeSeconds") + if self._cluster: + jwt_lifetime = _jwt_lifetime(self._token, now) + if jwt_lifetime is not None: + refresh = jwt_lifetime + self._refresh_deadline = None + self._refresh_at_monotonic = None + if refresh is not None: + duration = self._session_seconds(refresh) + self._refresh_deadline = now + timedelta(seconds=duration) + self._refresh_at_monotonic = self._runtime.monotonic() + duration + if maximum is not None and self._maximum_deadline is None: + duration = self._session_seconds(maximum) + self._maximum_deadline = self._authenticated_at + timedelta( + seconds=duration + ) + self._maximum_at_monotonic = self._runtime.monotonic() + duration + if version: + self.connection_info = ConnectionInfo( + parse_version(str(version)), + str(version), + bool(self._cluster), + self._cluster, + ) + + @staticmethod + def _session_seconds(value: Any) -> float: + try: + seconds = float(value) + if not math.isfinite(seconds) or seconds < 0: + raise ValueError + return seconds + except (ValueError, TypeError) as exc: + raise ResponseDecodeError("invalid authentication lifetime") from exc + + def _session_expired(self) -> bool: + now = self._runtime.monotonic() + return any( + deadline is not None and now >= deadline + for deadline in ( + self._refresh_at_monotonic, + self._maximum_at_monotonic, + ) + ) + + def _clear_session(self) -> None: + self._token = None + self._authenticated_at = None + self._last_refresh_at = None + self._refresh_deadline = None + self._refresh_at_monotonic = None + self._maximum_deadline = None + self._maximum_at_monotonic = None + self._clear_cookies() + + def authenticate(self, *, refresh: bool) -> SessionInfo: + if isinstance(self.auth, CertificateAuth): + return self.info + with self._auth_condition: + if self._auth_in_progress: + while self._auth_in_progress: + self._auth_condition.wait(timeout=remaining_request_time()) + remaining_request_time() + if self._auth_error is not None: + raise self._auth_error + if self._token and self._session_expired(): + self._clear_session() + renew = ( + bool(self._token) + and refresh + and self._refresh_at_monotonic is not None + and self._runtime.monotonic() >= self._refresh_at_monotonic - 30 + ) + if self._token and not renew: + return self.info + self._auth_in_progress = True + self._auth_error = None + try: + if renew: + response = ( + self._send( + "POST", + authenticate=False, + url=self._endpoint + "/api/v1/infra/refresh", + ) + if self._cluster + else self._send("GET", "aaaRefresh.json", authenticate=False) + ) + elif isinstance(self.auth, PasswordAuth): + data = { + "aaaUser": { + "attributes": { + "name": _qualified_user(self.auth), + "pwd": self.auth.password, + } + } + } + response = self._send( + "POST", + "aaaLogin.json", + data=data, + authenticate=False, + include_auth_headers=False, + ) + else: + data = { + "domain": self.auth.domain, + "userName": self.auth.username, + "userPasswd": self.auth.password, + } + response = self._send( + "POST", + data=data, + authenticate=False, + include_auth_headers=False, + url=self._endpoint + "/api/v1/infra/login", + ) + parsed = checked_json( + response, response.request.method, response.request.url.path + ) + self._set_login(parsed) + result = self.info + except Exception as exc: + with self._auth_condition: + self._clear_session() + self._auth_error = exc + raise + finally: + with self._auth_condition: + self._auth_in_progress = False + self._auth_condition.notify_all() + return result + + def logout(self) -> None: + with self._auth_condition: + while self._auth_in_progress: + self._auth_condition.wait() + if self._token: + try: + response = ( + self._send( + "POST", + authenticate=False, + url=self._endpoint + "/api/v1/infra/logout", + ) + if self._cluster + else self._send( + "POST", + "aaaLogout.json", + data={ + "aaaUser": { + "attributes": {"name": _qualified_user(self.auth)} + } + }, + authenticate=False, + ) + ) + checked_json(response, "POST", response.request.url.path) + finally: + self._clear_session() + else: + self._clear_session() diff --git a/aciClient/_timeouts.py b/aciClient/_timeouts.py new file mode 100644 index 0000000..eafbc3e --- /dev/null +++ b/aciClient/_timeouts.py @@ -0,0 +1,63 @@ +"""Carry an operation's remaining time through nested synchronous requests.""" + +from __future__ import annotations + +from collections.abc import Iterator +from contextlib import contextmanager +from contextvars import ContextVar + +import httpx + +from .exceptions import AciTimeoutError +from .runtime import SyncRuntime + +_active_deadline: ContextVar[Deadline | None] = ContextVar( + "aci_request_deadline", default=None +) + + +class Deadline: + """A monotonic operation budget, local to the calling thread or context.""" + + def __init__(self, runtime: SyncRuntime, timeout: float): + self._runtime = runtime + self._expires_at = runtime.monotonic() + timeout + + @property + def remaining(self) -> float: + return max(0.0, self._expires_at - self._runtime.monotonic()) + + @contextmanager + def activate(self) -> Iterator[None]: + outer = _active_deadline.get() + selected = ( + outer if outer is not None and outer.remaining < self.remaining else self + ) + token = _active_deadline.set(selected) + try: + yield + finally: + _active_deadline.reset(token) + + +def remaining_request_time() -> float | None: + deadline = _active_deadline.get() + if deadline is None: + return None + remaining = deadline.remaining + if remaining <= 0: + raise AciTimeoutError("operation deadline exceeded") + return remaining + + +def bounded_timeout(timeout: httpx.Timeout) -> httpx.Timeout: + """Cap each HTTP phase, including pool acquisition, at the remaining budget.""" + remaining = remaining_request_time() + if remaining is None: + return timeout + return httpx.Timeout( + **{ + phase: remaining if value is None else min(value, remaining) + for phase, value in timeout.as_dict().items() + } + ) diff --git a/aciClient/aci.py b/aciClient/aci.py index 12bfdde..e297d7d 100644 --- a/aciClient/aci.py +++ b/aciClient/aci.py @@ -7,22 +7,17 @@ AciClient for doing Username/Password based RestCalls to the APIC """ -import logging + import json -import requests +import logging import threading +from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse -import urllib3 +import requests from requests.adapters import HTTPAdapter +from requests.packages import urllib3 -# The modules are named different in python2/python3... -try: - from urlparse import urlparse, urlunparse, parse_qsl - from urllib import urlencode -except ImportError: - from urllib.parse import urlparse, urlunparse, urlencode, parse_qsl - -requests.packages.urllib3.disable_warnings() +from .deprecations import warn_legacy_api class ACI: @@ -31,7 +26,25 @@ class ACI: # ============================================================================== # constructor # ============================================================================== - def __init__(self, apicIp, apicUser, apicPasword, refresh=False, proxies=None): + def __init__( + self, + apicIp, + apicUser, + apicPasword, + refresh=False, + proxies=None, + *, + allow_legacy=True, + ): + # !! Important !! + # ACI will start showing a deprecation warning starting with version 2.0.0 + # With version 2.1.0, targeted release 10/2027, default of allow_legacy will be changed to False + # Creating a Instance of ACI will then throw an Exception except you pass allow_legacy=True + # With version 3.0.0 ACI will be completly removed + if allow_legacy: + warn_legacy_api(old="ACI", replacement="AciClient with PasswordAuth") + else: + raise RuntimeError("legacy API use is disabled") self.__logger.debug("Constructor called") self.apicIp = apicIp self.apicUser = apicUser diff --git a/aciClient/aciCertClient.py b/aciClient/aciCertClient.py index a39c265..05230cb 100644 --- a/aciClient/aciCertClient.py +++ b/aciClient/aciCertClient.py @@ -10,11 +10,15 @@ NOTES: openssl req -new -newkey rsa:2048 -days 36500 -nodes -x509 -keyout apicUser.key -out apicUser.crt """ -import logging -from OpenSSL import crypto + import base64 -import requests import json +import logging + +import requests +from OpenSSL import crypto + +from .deprecations import warn_legacy_api class ACICert: @@ -23,7 +27,16 @@ class ACICert: # ============================================================================== # constructor # ============================================================================== - def __init__(self, apicIp, pkPath, certDn): + def __init__(self, apicIp, pkPath, certDn, *, allow_legacy=True): + # !! Important !! + # ACICert will start showing a deprecation warning starting with version 2.0.0 + # With version 2.1.0, targeted release 10/2027, default of allow_legacy will be changed to False + # Creating a Instance of ACICert will then throw an Exception except you pass allow_legacy=True + # With version 3.0.0 ACICert will be completly removed + if allow_legacy: + warn_legacy_api(old="ACICert", replacement="AciClient with CertificateAuth") + else: + raise RuntimeError("legacy API use is disabled") self.__logger.debug(f"Constructor called {apicIp} {pkPath} {certDn}") self.apicIp = apicIp self.baseUrl = "https://" + self.apicIp + "/api/" diff --git a/aciClient/auth.py b/aciClient/auth.py new file mode 100644 index 0000000..c0ae2ec --- /dev/null +++ b/aciClient/auth.py @@ -0,0 +1,32 @@ +"""Authentication configuration objects with redacted credential fields.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + + +@dataclass(frozen=True) +class PasswordAuth: + username: str + password: str = field(repr=False) + domain: str | None = None + + +@dataclass(frozen=True) +class CertificateAuth: + certificate_name: str + private_key: Path | bytes | Any = field(repr=False) + certificate_dn: str + private_key_password: str | bytes | None = field(default=None, repr=False) + + +@dataclass(frozen=True) +class NexusDashboardPasswordAuth: + username: str + password: str = field(repr=False) + domain: str = "local" + + +Auth = PasswordAuth | CertificateAuth | NexusDashboardPasswordAuth diff --git a/aciClient/client.py b/aciClient/client.py new file mode 100644 index 0000000..077ff74 --- /dev/null +++ b/aciClient/client.py @@ -0,0 +1,689 @@ +"""Modern synchronous Cisco APIC client.""" + +from __future__ import annotations + +import json +import re +import ssl +import warnings +from collections.abc import Iterator, Mapping, Sequence +from contextlib import nullcontext +from dataclasses import replace +from datetime import datetime, timezone +from pathlib import Path +from typing import Any +from urllib.parse import quote, urlsplit +from uuid import uuid4 + +import httpx + +from ._responses import ( + checked_json, + decode_domains, + decode_response, + decode_subscription_id, + parse_version, +) +from ._session import AuthSession, validate_auth +from ._timeouts import Deadline, bounded_timeout, remaining_request_time +from .auth import Auth, CertificateAuth +from .exceptions import ( + AciTimeoutError, + ConfigurationError, + ResponseDecodeError, + TransportError, +) +from .models import ( + AciResponse, + ConnectionIdentity, + ConnectionInfo, + HttpConnectionLimits, + JobState, + LoginDomains, + ManagedObject, + PaginationPolicy, + SessionInfo, + SnapshotJobInfo, + SubscriptionInitialResponse, +) +from .query import AciQuery, QueryParams, QueryValue, normalize_params +from .runtime import SyncRuntime, SystemSyncRuntime +from .snapshots import SnapshotJob, build_job_info, discover_jobs, pending_job +from .subscriptions import ( + Subscription, + validate_subscription_auth, +) + +Params = ( + AciQuery + | QueryParams + | Mapping[str, QueryValue] + | Sequence[tuple[str, QueryValue]] + | None +) +DEFAULT_TIMEOUT = 100.0 +_DN_SAFE = "/[](),=:\"'" + + +class InsecureTLSWarning(UserWarning): + pass + + +def _endpoint(value: str) -> str: + value = value.strip() + if "://" not in value: + value = "https://" + ( + f"[{value}]" + if value.count(":") > 1 and not value.startswith("[") + else value + ) + parsed = urlsplit(value) + if parsed.scheme.lower() != "https": + raise ConfigurationError("modern clients require an HTTPS endpoint") + if ( + not parsed.hostname + or parsed.username + or parsed.password + or parsed.query + or parsed.fragment + ): + raise ConfigurationError( + "endpoint must contain only an HTTPS host and optional port" + ) + path = parsed.path.rstrip("/") + if path not in ("", "/api"): + raise ConfigurationError("endpoint must not contain an application path") + host = f"[{parsed.hostname}]" if ":" in parsed.hostname else parsed.hostname + return f"https://{host}{':' + str(parsed.port) if parsed.port else ''}" + + +def _proxy_profile(value: str | None) -> str | None: + if value is None: + return None + parsed = urlsplit(value) + if not parsed.hostname: + raise ConfigurationError("proxy must include a host") + host = parsed.hostname + port = f":{parsed.port}" if parsed.port else "" + return f"{parsed.scheme or 'proxy'}://{host}{port}" + + +def _path(value: str) -> str: + if not isinstance(value, str) or not value: + raise ConfigurationError("request path must be a non-empty relative APIC path") + if urlsplit(value).scheme or value.startswith("//") or "?" in value or "#" in value: + raise ConfigurationError( + "request path must not contain a host, query string, or fragment" + ) + value = value.lstrip("/") + if value == "api": + return "" + if value.startswith("api/"): + value = value[4:] + if any(part == ".." for part in value.split("/")): + raise ConfigurationError("path traversal is not allowed") + return value + + +class AciClient: + """Thread-safe synchronous APIC client. Construction performs no I/O.""" + + def __init__( + self, + endpoint: str, + auth: Auth, + nexus_dashboard_cluster_name: str | None = None, + verify: bool | str | Path | ssl.SSLContext = True, + connection_limits: HttpConnectionLimits = HttpConnectionLimits(), + *, + timeout: float = DEFAULT_TIMEOUT, + proxy: str | None = None, + transport: httpx.BaseTransport | None = None, + runtime: SyncRuntime | None = None, + ): + self._endpoint = _endpoint(endpoint) + self.auth = auth + self._cluster = nexus_dashboard_cluster_name + validate_auth(auth, bool(self._cluster)) + if verify is False: + warnings.warn( + "TLS certificate verification is disabled", + InsecureTLSWarning, + stacklevel=3, + ) + principal = ( + auth.certificate_dn if isinstance(auth, CertificateAuth) else auth.username + ) + domain = getattr(auth, "domain", None) + self.connection_identity = ConnectionIdentity( + endpoint=self._endpoint, + principal=principal, + authentication_type=type(auth).__name__, + domain=domain, + nexus_dashboard_cluster_name=self._cluster, + tls_profile="insecure" + if verify is False + else str(verify) + if verify is not True + else "default", + proxy_profile=_proxy_profile(proxy), + ) + self._connection_info: ConnectionInfo | None = None + self._closed = False + self._runtime = runtime or SystemSyncRuntime() + self._timeout = httpx.Timeout( + timeout, pool=connection_limits.pool_timeout or timeout + ) + if transport is not None and proxy is not None: + raise ConfigurationError("proxy cannot be combined with a custom transport") + limits = httpx.Limits( + max_connections=connection_limits.max_connections, + max_keepalive_connections=connection_limits.max_keepalive_connections, + keepalive_expiry=connection_limits.keepalive_expiry, + ) + self._http = httpx.Client( + verify=ssl.create_default_context(cafile=str(verify)) + if isinstance(verify, Path) + else verify, + proxy=proxy, + transport=transport, + limits=limits, + timeout=self._timeout, + ) + + self._session = AuthSession( + auth, + self._endpoint, + self._cluster, + principal, + self._runtime, + self._raw, + self._http.cookies.clear, + ) + + @property + def connection_info(self) -> ConnectionInfo | None: + return self._session.connection_info or self._connection_info + + @property + def is_authenticated(self) -> bool: + return not self._closed and self._session.is_authenticated + + @property + def is_closed(self) -> bool: + return self._closed + + @property + def session_token(self) -> str | None: + """Return the current session token without I/O; treat it as a secret.""" + return self._session.token + + @property + def session_info(self) -> SessionInfo: + info = self._session.info + return replace(info, authenticated=self.is_authenticated) + + def __copy__(self): + raise TypeError( + "clients cannot be copied; share this client or construct another" + ) + + def __deepcopy__(self, memo): + raise TypeError( + "clients cannot be deep-copied; share this client or construct another" + ) + + def _url(self, path: str) -> str: + clean = _path(path) + apic_path = "api/" + clean + route = ( + f"api/v1/oneManage/apics/{quote(self._cluster or '', safe='')}/{apic_path}" + if self._cluster + else apic_path + ) + return f"{self._endpoint}/{route}" + + def __enter__(self): + return self + + def __exit__(self, *args): + self.close() + + def _raw( + self, + method: str, + path: str | None = None, + *, + params: Params = None, + data: Any = None, + timeout: float | None = None, + authenticate: bool = True, + include_auth_headers: bool = True, + url: str | None = None, + ) -> httpx.Response: + if self._closed: + raise TransportError("client is closed") + remaining_request_time() + pairs = normalize_params(params) + if url is None: + if path is None: + raise ConfigurationError("request path is required") + url = self._url(path) + if self._cluster and pairs: + # OneManage forwards apicPath, not the proxy URL's query. Keep + # the APIC query inside that path; preserve its encoded values + # and literal separators while encoding its '?' delimiter. + url += "%3F" + str(httpx.QueryParams(list(pairs))) + pairs = () + body = ( + b"" + if data is None + else json.dumps(data, ensure_ascii=False).encode("utf-8") + ) + + if authenticate: + self.ensure_authenticated() + headers = {"Accept": "application/json"} + if body: + headers["Content-Type"] = "application/json" + request = self._http.build_request( + method, + url, + params=list(pairs), + content=body or None, + headers=headers, + timeout=bounded_timeout( + self._timeout + if timeout is None + else httpx.Timeout(timeout, pool=self._timeout.pool) + ), + ) + if include_auth_headers: + request.headers.update( + self._session.headers( + request.method, + request.url.raw_path.decode("ascii"), + request.content, + ) + ) + else: + # An httpx cookie jar must not authenticate a public/login request. + request.headers.pop("Cookie", None) + try: + response = self._http.send(request) + except httpx.TimeoutException as exc: + raise AciTimeoutError(str(exc)) from exc + except httpx.TransportError as exc: + raise TransportError(str(exc)) from exc + if include_auth_headers and response.status_code == 401: + self._session.invalidate(request) + remaining_request_time() + return response + + def login(self) -> SessionInfo: + """Log in explicitly; ordinary requests authenticate lazily.""" + if self._closed: + raise TransportError("client is closed") + return self._session.authenticate(refresh=False) + + def ensure_authenticated(self) -> SessionInfo: + """Log in or renew a session when needed, coordinating concurrent callers.""" + if self._closed: + raise TransportError("client is closed") + return self._session.authenticate(refresh=True) + + def logout(self) -> None: + """End the server session and clear local credentials, even on failure.""" + self._session.logout() + + def _request( + self, + method: str, + path: str, + *, + params: Params = None, + data: Any = None, + timeout: float | None = None, + ) -> AciResponse: + return decode_response( + self._raw(method, path, params=params, data=data, timeout=timeout), + method, + _path(path), + ) + + def get( + self, + path: str, + *, + params: Params = None, + timeout: float | None = None, + ) -> AciResponse: + return self._request("GET", path, params=params, timeout=timeout) + + def post( + self, + path: str, + *, + data: Any = None, + params: Params = None, + timeout: float | None = None, + ) -> AciResponse: + return self._request("POST", path, data=data, params=params, timeout=timeout) + + def delete( + self, + path: str, + *, + params: Params = None, + timeout: float | None = None, + ) -> AciResponse: + return self._request("DELETE", path, params=params, timeout=timeout) + + def get_class( + self, + class_name: str, + *, + params: Params = None, + timeout: float | None = None, + ) -> AciResponse: + return self.get( + f"class/{quote(class_name, safe='')}.json", + params=params, + timeout=timeout, + ) + + def get_mo( + self, dn: str, *, params: Params = None, timeout: float | None = None + ) -> AciResponse: + return self.get( + f"mo/{quote(dn.lstrip('/'), safe=_DN_SAFE)}.json", + params=params, + timeout=timeout, + ) + + def get_imdata( + self, path: str, *, params: Params = None, timeout: float | None = None + ) -> list[dict[str, Any]]: + return [ + dict(x) for x in self.get(path, params=params, timeout=timeout).raw_imdata + ] + + def post_mo( + self, + data: Any, + *, + dn: str | None = None, + params: Params = None, + timeout: float | None = None, + ) -> AciResponse: + if isinstance(data, ManagedObject): + data = data.to_imdata() + path = ( + "mo.json" + if dn is None + else f"mo/{quote(dn.lstrip('/'), safe=_DN_SAFE)}.json" + ) + return self.post(path, data=data, params=params, timeout=timeout) + + def delete_mo( + self, dn: str, *, params: Params = None, timeout: float | None = None + ) -> AciResponse: + return self.delete( + f"mo/{quote(dn.lstrip('/'), safe=_DN_SAFE)}.json", + params=params, + timeout=timeout, + ) + + def iter_pages( + self, + path: str, + *, + params: Params = None, + policy: PaginationPolicy = PaginationPolicy(), + timeout: float | None = None, + ) -> Iterator[AciResponse]: + """Yield pages until exhausted or a policy limit is reached. + + A deadline includes authentication and requests; pages arriving after it + are discarded. HTTP phase timeouts are capped at the remaining budget. + """ + base = normalize_params(params) + page_size = min(policy.page_size, 2000) + deadline = ( + Deadline(self._runtime, policy.deadline) + if policy.deadline is not None + else None + ) + count = 0 + page = 0 + while policy.max_pages is None or page < policy.max_pages: + if deadline is not None and deadline.remaining <= 0: + break + try: + with deadline.activate() if deadline is not None else nullcontext(): + response = self.get( + path, + params=base.replace(page=page, page_size=page_size), + timeout=timeout, + ) + except AciTimeoutError: + if deadline is not None and deadline.remaining <= 0: + break + raise + if response.is_empty: + break + if ( + policy.max_items is not None + and count + len(response.objects) > policy.max_items + ): + n = policy.max_items - count + response = AciResponse( + response.objects[:n], + response.raw_imdata[:n], + response.total_count, + response.status_code, + response.headers, + response.raw, + ) + yield response + count += len(response.objects) + page += 1 + if len(response.objects) < page_size or ( + policy.max_items is not None and count >= policy.max_items + ): + break + + def iter_objects( + self, + path: str, + *, + params: Params = None, + policy: PaginationPolicy = PaginationPolicy(), + timeout: float | None = None, + ) -> Iterator[ManagedObject]: + for page in self.iter_pages( + path, params=params, policy=policy, timeout=timeout + ): + yield from page.objects + + def collect_pages( + self, + path: str, + *, + params: Params = None, + policy: PaginationPolicy = PaginationPolicy(), + timeout: float | None = None, + ) -> list[AciResponse]: + return list( + self.iter_pages(path, params=params, policy=policy, timeout=timeout) + ) + + def collect_objects( + self, + path: str, + *, + params: Params = None, + policy: PaginationPolicy = PaginationPolicy(), + timeout: float | None = None, + ) -> list[ManagedObject]: + return list( + self.iter_objects(path, params=params, policy=policy, timeout=timeout) + ) + + def list_login_domains(self) -> LoginDomains: + path = ( + "/api/v1/infra/logindomains" + if self._cluster + else "/api/aaaListDomains.json" + ) + response = checked_json( + self._raw( + "GET", + authenticate=False, + include_auth_headers=False, + url=self._endpoint + path, + ), + "GET", + path, + ) + return decode_domains(response, bool(self._cluster)) + + def discover_version(self) -> ConnectionInfo: + if self.connection_info: + return self.connection_info + response = self.get_class("firmwareCtrlrRunning") + versions = { + str(obj.attributes["version"]) + for obj in response.objects + if obj.attributes.get("version") + } + if len(versions) != 1: + raise ResponseDecodeError( + "APIC controller firmware version is missing or ambiguous: " + + ", ".join(sorted(versions)) + ) + raw = versions.pop() + self._connection_info = ConnectionInfo( + parse_version(raw), raw, bool(self._cluster), self._cluster + ) + return self._connection_info + + def subscribe( + self, + path: str, + *, + params: Params = None, + refresh_timeout: int | None = None, + timeout: float | None = None, + ) -> Subscription: + validate_subscription_auth(self.auth, self._cluster) + pairs = normalize_params(params).replace(subscription="yes") + if refresh_timeout is not None: + pairs = pairs.replace(refresh_timeout=refresh_timeout) + else: + refresh_timeout = next( + (int(value) for key, value in pairs if key == "refresh-timeout"), + None, + ) + response = self.get(path, params=pairs, timeout=timeout) + sid = decode_subscription_id(response.raw) + initial = SubscriptionInitialResponse( + sid, + response.objects, + response.raw_imdata, + response.total_count, + response.raw, + ) + return Subscription(self, initial, refresh_timeout) + + def subscribe_class( + self, + class_name: str, + *, + params: Params = None, + refresh_timeout: int | None = None, + timeout: float | None = None, + ) -> Subscription: + return self.subscribe( + f"class/{quote(class_name, safe='')}.json", + params=params, + refresh_timeout=refresh_timeout, + timeout=timeout, + ) + + def subscribe_mo( + self, + dn: str, + *, + params: Params = None, + refresh_timeout: int | None = None, + timeout: float | None = None, + ) -> Subscription: + return self.subscribe( + f"mo/{quote(dn.lstrip('/'), safe=_DN_SAFE)}.json", + params=params, + refresh_timeout=refresh_timeout, + timeout=timeout, + ) + + def create_snapshot( + self, + description: str = "snapshot", + target_dn: str | None = None, + name: str | None = None, + include_secure_fields: bool = False, + ) -> SnapshotJob: + name = name or f"aciclient-{uuid4().hex[:16]}" + if not re.fullmatch(r"[A-Za-z0-9_.:-]{1,64}", name): + raise ConfigurationError( + "snapshot policy name must be 1–64 APIC name characters" + ) + now = datetime.now(timezone.utc) + policy_dn = f"uni/fabric/configexp-{name}" + baseline = frozenset(obj.dn for obj in discover_jobs(self, policy_dn)) + attributes = { + "adminSt": "triggered", + "descr": description, + "dn": policy_dn, + "format": "json", + "includeSecureFields": "yes" if include_secure_fields else "no", + "name": name, + "snapshot": "yes", + "targetDn": target_dn or "", + } + data = { + "configExportP": { + "attributes": attributes, + } + } + response = self.post_mo(data) + info = SnapshotJobInfo( + policy_dn=policy_dn, + job_dn=None, + name=name, + description=description, + target_dn=target_dn, + submitted_at=now, + state=JobState.PENDING, + attributes=attributes, + raw=response.raw, + ) + return SnapshotJob(self, info, self._runtime, baseline=baseline) + + def get_snapshot_job(self, job_dn_or_name: str) -> SnapshotJob: + if "/" in job_dn_or_name: + response = self.get_mo(job_dn_or_name) + info = build_job_info(response.one(), response.raw) + else: + policy_dn = f"uni/fabric/configexp-{job_dn_or_name}" + info = pending_job(policy_dn, job_dn_or_name) + job = SnapshotJob(self, info, self._runtime) + if info.job_dn is None: + job.refresh() + return job + + def close(self) -> None: + if self._closed: + return + self._closed = True + self._http.close() diff --git a/aciClient/deprecations.py b/aciClient/deprecations.py new file mode 100644 index 0000000..047b0fe --- /dev/null +++ b/aciClient/deprecations.py @@ -0,0 +1,14 @@ +from warnings import warn + + +class LegacyAPIWarning(FutureWarning): + """A warning for aciClient Class that will be removed in a future release""" + + +def warn_legacy_api(old: str, replacement: str) -> None: + msg = f"{old} is deprecated since aciClient 2.0.0 and will be removed in a future major release" + warn( + message=f"{msg}. Use {replacement} instead.", + category=LegacyAPIWarning, + stacklevel=3, + ) diff --git a/aciClient/exceptions.py b/aciClient/exceptions.py new file mode 100644 index 0000000..d839f4d --- /dev/null +++ b/aciClient/exceptions.py @@ -0,0 +1,101 @@ +"""Public exception hierarchy for :mod:`aciClient`.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from ._diagnostics import redact + + +class AciClientError(Exception): + """Base class for all modern client errors.""" + + +class ConfigurationError(AciClientError): + pass + + +class TransportError(AciClientError): + pass + + +class AciTimeoutError(TransportError): + pass + + +class AuthenticationError(AciClientError): + pass + + +class AuthorizationError(AciClientError): + pass + + +class QueryParameterError(AciClientError, ValueError): + pass + + +class ResponseDecodeError(AciClientError, ValueError): + pass + + +class ManagedObjectError(AciClientError): + pass + + +class NoResultError(ManagedObjectError): + pass + + +class MultipleResultsError(ManagedObjectError): + pass + + +class AciJobError(AciClientError): + pass + + +class JobTimeoutError(AciJobError): + def __init__(self, message: str, *, raw: Mapping[str, Any] | None = None): + super().__init__(message) + self.raw = redact(raw or {}) + + +class AciApiError(AciClientError): + def __init__( + self, + message: str, + *, + status_code: int | None = None, + error_code: str | None = None, + error_text: str | None = None, + method: str | None = None, + request_url: str | None = None, + raw: Mapping[str, Any] | None = None, + ): + super().__init__(message) + self.status_code = status_code + self.error_code = error_code + self.error_text = error_text + self.method = method + self.request_url = request_url + self.raw = redact(raw or {}) + + +class JobFailedError(AciJobError): + def __init__( + self, + message: str, + *, + job_dn: str | None = None, + job_name: str | None = None, + final_state: Any = None, + error_code: str | None = None, + error_text: str | None = None, + raw: Mapping[str, Any] | None = None, + ): + super().__init__(message) + self.job_dn, self.job_name, self.final_state = job_dn, job_name, final_state + self.error_code, self.error_text = error_code, error_text + self.raw = redact(raw or {}) diff --git a/aciClient/models.py b/aciClient/models.py new file mode 100644 index 0000000..b7c75fd --- /dev/null +++ b/aciClient/models.py @@ -0,0 +1,293 @@ +"""Immutable public value objects used by the APIC client.""" + +from __future__ import annotations + +import math +from collections.abc import Mapping +from copy import deepcopy +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum +from functools import total_ordering +from types import MappingProxyType +from typing import Any + +from ._diagnostics import redact +from .exceptions import MultipleResultsError, NoResultError, ResponseDecodeError + + +def _copy_value(value: Any) -> Any: + if isinstance(value, Mapping): + return {key: _copy_value(item) for key, item in value.items()} + if isinstance(value, list): + return [_copy_value(item) for item in value] + if isinstance(value, tuple): + return tuple(_copy_value(item) for item in value) + return deepcopy(value) + + +def _freeze_map(value: Mapping[str, Any] | None) -> Mapping[str, Any]: + return MappingProxyType(_copy_value(value or {})) + + +@total_ordering +@dataclass(frozen=True) +class ApicVersion: + major: int + minor: int + patch: int | None = None + build: str | None = field(default=None, compare=False) + + def __lt__(self, other: object) -> bool: + if not isinstance(other, ApicVersion): + return NotImplemented + return ( + self.major, + self.minor, + -1 if self.patch is None else self.patch, + ) < ( + other.major, + other.minor, + -1 if other.patch is None else other.patch, + ) + + +@dataclass(frozen=True) +class ConnectionInfo: + apic_version: ApicVersion + apic_version_raw: str + via_nexus_dashboard: bool + nexus_dashboard_cluster_name: str | None + + +@dataclass(frozen=True) +class ConnectionIdentity: + endpoint: str + principal: str + authentication_type: str + domain: str | None + nexus_dashboard_cluster_name: str | None + tls_profile: str | None + proxy_profile: str | None + + +@dataclass(frozen=True) +class SessionInfo: + authenticated: bool + principal: str + authenticated_at: datetime | None = None + refresh_deadline: datetime | None = None + maximum_lifetime_deadline: datetime | None = None + last_refresh_at: datetime | None = None + + +@dataclass(frozen=True) +class LoginDomain: + name: str + display_name: str | None + realm: str | None + is_default: bool + raw: Mapping[str, Any] = field(repr=False, compare=False) + + def __post_init__(self): + object.__setattr__(self, "raw", _freeze_map(self.raw)) + + +@dataclass(frozen=True) +class LoginDomains: + domains: tuple[LoginDomain, ...] + default_domain: str | None + raw: Mapping[str, Any] = field(repr=False, compare=False) + + def __post_init__(self): + object.__setattr__(self, "raw", _freeze_map(self.raw)) + + +@dataclass(frozen=True, repr=False) +class ManagedObject: + class_name: str + attributes: Mapping[str, Any] + children: tuple["ManagedObject", ...] = () + raw: Mapping[str, Any] = field(default_factory=dict, repr=False, compare=False) + + def __post_init__(self): + object.__setattr__(self, "attributes", _freeze_map(self.attributes)) + object.__setattr__(self, "children", tuple(self.children)) + object.__setattr__(self, "raw", _freeze_map(self.raw or self.to_imdata())) + + def __repr__(self) -> str: + attrs = redact(self.attributes) + return f"ManagedObject(class_name={self.class_name!r}, attributes={attrs!r}, children={self.children!r})" + + @classmethod + def from_imdata(cls, record: Mapping[str, Any]) -> "ManagedObject": + if not isinstance(record, Mapping) or len(record) != 1: + raise ResponseDecodeError( + "an imdata entry must contain exactly one class key" + ) + class_name, body = next(iter(record.items())) + if not isinstance(body, Mapping): + raise ResponseDecodeError("managed-object body must be an object") + attributes = body.get("attributes", {}) + if not isinstance(attributes, Mapping): + raise ResponseDecodeError("managed-object attributes must be an object") + children_raw = body.get("children", ()) + if not isinstance(children_raw, (list, tuple)): + raise ResponseDecodeError("managed-object children must be a list") + return cls( + str(class_name), + dict(attributes), + tuple(cls.from_imdata(child) for child in children_raw), + dict(record), + ) + + @classmethod + def create( + cls, + class_name: str, + attributes: Mapping[str, Any], + children: tuple["ManagedObject", ...] = (), + ) -> "ManagedObject": + return cls(class_name, dict(attributes), tuple(children)) + + def to_imdata(self) -> dict[str, Any]: + body: dict[str, Any] = {"attributes": dict(self.attributes)} + if self.children: + body["children"] = [child.to_imdata() for child in self.children] + return deepcopy({self.class_name: body}) + + @property + def dn(self) -> str | None: + value = self.attributes.get("dn") + return value if isinstance(value, str) else None + + def children_of(self, class_name: str) -> tuple["ManagedObject", ...]: + return tuple(c for c in self.children if c.class_name == class_name) + + def first_child(self, class_name: str) -> "ManagedObject | None": + return next((c for c in self.children if c.class_name == class_name), None) + + def require_child(self, class_name: str) -> "ManagedObject": + child = self.first_child(class_name) + if child is None: + raise NoResultError(f"no child of class {class_name!r}") + return child + + +@dataclass(frozen=True) +class AciResponse: + objects: tuple[ManagedObject, ...] + raw_imdata: tuple[Mapping[str, Any], ...] = field(repr=False) + total_count: int | None + status_code: int + headers: Mapping[str, str] = field(repr=False) + raw: Mapping[str, Any] = field(repr=False, compare=False) + + def __post_init__(self): + object.__setattr__(self, "objects", tuple(self.objects)) + object.__setattr__( + self, "raw_imdata", tuple(_freeze_map(x) for x in self.raw_imdata) + ) + object.__setattr__(self, "headers", _freeze_map(self.headers)) + object.__setattr__(self, "raw", _freeze_map(self.raw)) + + def first(self) -> ManagedObject | None: + return self.objects[0] if self.objects else None + + def one(self) -> ManagedObject: + if not self.objects: + raise NoResultError("response contains no objects") + if len(self.objects) != 1: + raise MultipleResultsError(f"response contains {len(self.objects)} objects") + return self.objects[0] + + @property + def is_empty(self) -> bool: + return not self.objects + + def objects_of(self, class_name: str) -> tuple[ManagedObject, ...]: + return tuple(x for x in self.objects if x.class_name == class_name) + + def to_dict(self) -> dict[str, Any]: + return _copy_value(self.raw) + + +@dataclass(frozen=True) +class HttpConnectionLimits: + max_connections: int = 20 + max_keepalive_connections: int = 10 + keepalive_expiry: float | None = 30.0 + pool_timeout: float | None = None + + def __post_init__(self): + if self.max_connections <= 0 or self.max_keepalive_connections < 0: + raise ValueError( + "connection limits must be non-negative and max_connections positive" + ) + if self.max_keepalive_connections > self.max_connections: + raise ValueError("keep-alive connections cannot exceed max connections") + if self.keepalive_expiry is not None and self.keepalive_expiry < 0: + raise ValueError("keepalive_expiry cannot be negative") + if self.pool_timeout is not None and self.pool_timeout <= 0: + raise ValueError("pool_timeout must be positive") + + +@dataclass(frozen=True) +class PaginationPolicy: + page_size: int = 1000 + max_pages: int | None = None + max_items: int | None = None + deadline: float | None = None + + def __post_init__(self): + if self.page_size <= 0: + raise ValueError("page_size must be positive") + if self.max_pages is not None and self.max_pages <= 0: + raise ValueError("max_pages must be positive") + if self.max_items is not None and self.max_items <= 0: + raise ValueError("max_items must be positive") + if self.deadline is not None and ( + not math.isfinite(self.deadline) or self.deadline < 0 + ): + raise ValueError("deadline must be finite and non-negative") + + +@dataclass(frozen=True) +class SubscriptionInitialResponse: + subscription_id: str + objects: tuple[ManagedObject, ...] + raw_imdata: tuple[Mapping[str, Any], ...] = field(repr=False) + total_count: int | None + raw: Mapping[str, Any] = field(repr=False) + + +class JobState(Enum): + PENDING = "pending" + RUNNING = "running" + SUCCEEDED = "succeeded" + FAILED = "failed" + UNKNOWN = "unknown" + + +@dataclass(frozen=True) +class SnapshotJobInfo: + policy_dn: str + job_dn: str | None + name: str + description: str + target_dn: str | None + submitted_at: datetime | None + state: JobState + attributes: Mapping[str, Any] = field(repr=False) + raw: Mapping[str, Any] = field(repr=False) + + +@dataclass(frozen=True) +class SnapshotResult: + job: SnapshotJobInfo + snapshot_name: str | None + completed_at: datetime | None + error_code: str | None + error_text: str | None + raw: Mapping[str, Any] = field(repr=False) + warnings: tuple[str, ...] = () diff --git a/aciClient/py.typed b/aciClient/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/aciClient/query.py b/aciClient/query.py new file mode 100644 index 0000000..e1c76e1 --- /dev/null +++ b/aciClient/query.py @@ -0,0 +1,211 @@ +"""Structured APIC query parameters and filter expressions.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from typing import Any + +from .exceptions import QueryParameterError + +type QueryValue = str | int | float | bool | Sequence[str] + + +@dataclass(frozen=True) +class Filter: + expression: str + + def __str__(self): + return self.expression + + +def _quote_value(value: Any) -> str: + return '"' + str(value).replace("\\", "\\\\").replace('"', '\\"') + '"' + + +def _property_filter(name: str, attribute: str, *values: Any) -> Filter: + arguments = ",".join((attribute, *map(_quote_value, values))) + return Filter(f"{name}({arguments})") + + +def eq(attribute: str, value: Any) -> Filter: + return _property_filter("eq", attribute, value) + + +def ne(attribute: str, value: Any) -> Filter: + return _property_filter("ne", attribute, value) + + +def lt(attribute: str, value: Any) -> Filter: + return _property_filter("lt", attribute, value) + + +def le(attribute: str, value: Any) -> Filter: + return _property_filter("le", attribute, value) + + +def gt(attribute: str, value: Any) -> Filter: + return _property_filter("gt", attribute, value) + + +def ge(attribute: str, value: Any) -> Filter: + return _property_filter("ge", attribute, value) + + +def between(attribute: str, lower: Any, upper: Any) -> Filter: + return _property_filter("bw", attribute, lower, upper) + + +def any_bit(attribute: str, value: Any) -> Filter: + return _property_filter("anybit", attribute, value) + + +def all_bits(attribute: str, value: Any) -> Filter: + return _property_filter("allbits", attribute, value) + + +def begins_with(attribute: str, value: Any) -> Filter: + return _property_filter("wcard", attribute, f"^{value}") + + +def wildcard(attribute: str, value: Any) -> Filter: + return _property_filter("wcard", attribute, value) + + +def and_(*filters: Filter) -> Filter: + return Filter("and(" + ",".join(map(str, filters)) + ")") + + +def or_(*filters: Filter) -> Filter: + return Filter("or(" + ",".join(map(str, filters)) + ")") + + +def xor_(*filters: Filter) -> Filter: + return Filter("xor(" + ",".join(map(str, filters)) + ")") + + +def not_(expression: Filter) -> Filter: + return Filter(f"not({expression})") + + +def always_true() -> Filter: + return Filter("true()") + + +def always_false() -> Filter: + return Filter("false()") + + +def property_holder(attribute: str) -> Filter: + return Filter(f"pholder({attribute})") + + +def passive_holder(value: Any) -> Filter: + return Filter(f"passive({_quote_value(value)})") + + +def raw_filter(expression: str) -> Filter: + return Filter(expression) + + +class QueryParams(tuple): + def __new__( + cls, + value: Mapping[str, QueryValue] + | Sequence[tuple[str, QueryValue]] + | None = None, + ): + pairs = value.items() if isinstance(value, Mapping) else (value or ()) + expanded: list[tuple[str, str]] = [] + for key, item in pairs: + values = item if isinstance(item, (list, tuple)) else (item,) + expanded.extend( + (str(key), "true" if v is True else "false" if v is False else str(v)) + for v in values + ) + return super().__new__(cls, expanded) + + def replace(self, **values: Any) -> QueryParams: + names = {k.replace("_", "-") for k in values} + pairs = [(k, v) for k, v in self if k not in names] + pairs.extend( + (k.replace("_", "-"), v) for k, v in values.items() if v is not None + ) + return QueryParams(pairs) + + +@dataclass(frozen=True) +class AciQuery: + query_target: str | None = None + target_subtree_class: tuple[str, ...] | None = None + query_target_filter: Filter | str | None = None + rsp_subtree: str | None = None + rsp_subtree_class: tuple[str, ...] | None = None + rsp_subtree_filter: Filter | str | None = None + rsp_subtree_include: str | None = None + order_by: str | None = None + page: int | None = None + page_size: int | None = None + time_range: str | None = None + subscription: bool | None = None + refresh_timeout: int | None = None + + def pairs(self) -> QueryParams: + mapping = { + "query-target": self.query_target, + "target-subtree-class": self.target_subtree_class, + "query-target-filter": self.query_target_filter, + "rsp-subtree": self.rsp_subtree, + "rsp-subtree-class": self.rsp_subtree_class, + "rsp-subtree-filter": self.rsp_subtree_filter, + "rsp-subtree-include": self.rsp_subtree_include, + "order-by": self.order_by, + "page": self.page, + "page-size": self.page_size, + "time-range": self.time_range, + "subscription": self.subscription, + "refresh-timeout": self.refresh_timeout, + } + pairs = [] + for key, value in mapping.items(): + if value is None: + continue + if isinstance(value, tuple): + value = ",".join(value) + pairs.append( + ( + key, + str(value) + if not isinstance(value, bool) + else ("yes" if value else "no"), + ) + ) + return QueryParams(pairs) + + +_SINGLETONS = { + "page", + "page-size", + "query-target", + "query-target-filter", + "rsp-subtree", + "rsp-subtree-filter", + "subscription", + "refresh-timeout", +} + + +def normalize_params( + params: AciQuery + | Mapping[str, QueryValue] + | Sequence[tuple[str, QueryValue]] + | QueryParams + | None, +) -> QueryParams: + result = params.pairs() if isinstance(params, AciQuery) else QueryParams(params) + seen = set() + for key, _ in result: + if key in _SINGLETONS and key in seen: + raise QueryParameterError(f"duplicate singleton query parameter: {key}") + seen.add(key) + return result diff --git a/aciClient/runtime.py b/aciClient/runtime.py new file mode 100644 index 0000000..ce3181a --- /dev/null +++ b/aciClient/runtime.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +import time +from typing import Protocol + + +class SyncRuntime(Protocol): + def monotonic(self) -> float: ... + def sleep(self, seconds: float) -> None: ... + + +class SystemSyncRuntime: + monotonic = staticmethod(time.monotonic) + sleep = staticmethod(time.sleep) diff --git a/aciClient/snapshots.py b/aciClient/snapshots.py new file mode 100644 index 0000000..b715670 --- /dev/null +++ b/aciClient/snapshots.py @@ -0,0 +1,240 @@ +"""Snapshot run discovery, state handling, and bounded completion polling.""" + +from __future__ import annotations + +import math +import re +from collections.abc import Mapping +from dataclasses import replace +from datetime import datetime, timezone +from typing import TYPE_CHECKING, Any + +from ._timeouts import Deadline, remaining_request_time +from .exceptions import ( + AciTimeoutError, + JobFailedError, + JobTimeoutError, + ResponseDecodeError, +) +from .models import JobState, SnapshotJobInfo, SnapshotResult +from .query import eq + +if TYPE_CHECKING: + from .client import AciClient + from .models import ManagedObject + from .runtime import SyncRuntime + + +def _state(value: Any) -> JobState: + normalized = str(value or "").lower() + if normalized in { + "completed", + "complete", + "success", + "succeeded", + "success-with-warnings", + }: + return JobState.SUCCEEDED + if normalized in {"failed", "failure", "error", "fail-no-data"}: + return JobState.FAILED + if normalized in {"running", "inprogress", "processing"}: + return JobState.RUNNING + if normalized in {"pending", "queued", "created"}: + return JobState.PENDING + return JobState.UNKNOWN + + +def _timestamp(value: Any) -> datetime | None: + if not value or value == "never": + return None + try: + parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00")) + return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc) + except ValueError: + return None + + +def pending_job(policy_dn: str, name: str) -> SnapshotJobInfo: + return SnapshotJobInfo( + policy_dn=policy_dn, + job_dn=None, + name=name, + description="", + target_dn=None, + submitted_at=None, + state=JobState.PENDING, + attributes={}, + raw={}, + ) + + +def discover_jobs(client: AciClient, policy_dn: str) -> tuple[ManagedObject, ...]: + """Read returned runs through their policy-associated container, not a guessed run DN.""" + response = client.get_class( + "configJobCont", + params={ + "query-target-filter": str(eq("configJobCont.pDn", policy_dn)), + "rsp-subtree": "full", + }, + ) + jobs = [] + for container in response.objects_of("configJobCont"): + if container.attributes.get("pDn") != policy_dn: + continue + for job in container.children_of("configJob"): + if not job.dn: + rn = job.attributes.get("rn") + if not container.dn or not isinstance(rn, str) or not rn: + raise ResponseDecodeError( + "snapshot child requires a returned parent DN and RN" + ) + job = replace( + job, attributes={**job.attributes, "dn": container.dn + "/" + rn} + ) + jobs.append(job) + return tuple(jobs) + + +def build_job_info( + obj: ManagedObject, raw: Mapping[str, Any], context: SnapshotJobInfo | None = None +) -> SnapshotJobInfo: + if obj.class_name != "configJob" or not obj.dn: + raise ResponseDecodeError( + "snapshot response must contain a configJob with a DN" + ) + attributes = obj.attributes + match = re.search(r"/jobs-\[(.*)\]/", obj.dn) + policy_dn = str( + attributes.get("policyDn") + or (context.policy_dn if context else "") + or (match.group(1) if match else "") + ) + if not policy_dn: + raise ResponseDecodeError("snapshot run does not identify its export policy") + return SnapshotJobInfo( + policy_dn=policy_dn, + job_dn=obj.dn, + name=str(attributes.get("name") or obj.dn.rsplit("/", 1)[-1]), + description=context.description + if context + else str(attributes.get("descr") or ""), + target_dn=context.target_dn if context else attributes.get("targetDn"), + submitted_at=context.submitted_at + if context and context.submitted_at + else _timestamp(attributes.get("executeTime")), + state=_state(attributes.get("operSt")), + attributes=attributes, + raw=raw, + ) + + +def _result(info: SnapshotJobInfo) -> SnapshotResult: + warnings = () + if str(info.attributes.get("operSt", "")).lower() == "success-with-warnings": + warnings = ( + str( + info.attributes.get("details") or "APIC export completed with warnings" + ), + ) + failed = info.state == JobState.FAILED + return SnapshotResult( + job=info, + snapshot_name=info.attributes.get("snapshotName") + or info.attributes.get("fileName"), + completed_at=_timestamp(info.attributes.get("lastStepTime")) + if info.state in {JobState.SUCCEEDED, JobState.FAILED} + else None, + error_code=info.attributes.get("errorCode") if failed else None, + error_text=(info.attributes.get("errorText") or info.attributes.get("details")) + if failed + else None, + raw=info.raw, + warnings=warnings, + ) + + +class SnapshotJob: + def __init__( + self, + client: AciClient, + info: SnapshotJobInfo, + runtime: SyncRuntime, + *, + baseline: frozenset[str | None] = frozenset(), + ) -> None: + self._client = client + self._runtime = runtime + self._baseline = baseline + self.info = info + + @property + def done(self) -> bool: + return self.state in {JobState.SUCCEEDED, JobState.FAILED} + + @property + def state(self) -> JobState: + return self.info.state + + def refresh(self) -> SnapshotJobInfo: + if self.info.job_dn: + response = self._client.get_mo(self.info.job_dn) + obj = response.first() + if obj: + self.info = build_job_info(obj, response.raw, self.info) + else: + jobs = [ + obj + for obj in discover_jobs(self._client, self.info.policy_dn) + if obj.dn not in self._baseline + ] + if jobs: + obj = max( + jobs, + key=lambda x: str( + x.attributes.get("executeTime") + or x.attributes.get("name") + or "" + ), + ) + self.info = build_job_info(obj, {"imdata": [dict(obj.raw)]}, self.info) + return self.info + + def wait(self, timeout: float = 120, poll_interval: float = 2) -> SnapshotResult: + """Poll until the export succeeds, fails, or reaches the timeout.""" + if ( + not math.isfinite(timeout) + or timeout < 0 + or not math.isfinite(poll_interval) + or poll_interval <= 0 + ): + raise ValueError( + "timeout must be finite and non-negative; poll_interval finite and positive" + ) + deadline = Deadline(self._runtime, timeout) + try: + with deadline.activate(): + while True: + remaining_request_time() + self.refresh() + if self.done: + result = _result(self.info) + if self.state == JobState.SUCCEEDED: + return result + raise JobFailedError( + result.error_text or "snapshot job failed", + job_dn=self.info.job_dn, + job_name=self.info.name, + final_state=self.state, + error_code=result.error_code, + error_text=result.error_text, + raw=result.raw, + ) + self._runtime.sleep(min(poll_interval, deadline.remaining)) + except AciTimeoutError as exc: + if deadline.remaining > 0: + raise + raise JobTimeoutError( + f"snapshot {self.info.policy_dn!r} did not complete within {timeout}s " + f"(last state: {self.state.value})", + raw=self.info.raw, + ) from exc diff --git a/aciClient/subscriptions.py b/aciClient/subscriptions.py new file mode 100644 index 0000000..ecae5c0 --- /dev/null +++ b/aciClient/subscriptions.py @@ -0,0 +1,38 @@ +"""APIC subscription handles.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from .auth import Auth, PasswordAuth +from .exceptions import ConfigurationError +from .models import AciResponse, SubscriptionInitialResponse + +if TYPE_CHECKING: + from .client import AciClient + + +def validate_subscription_auth(auth: Auth, cluster_name: str | None) -> None: + if cluster_name or not isinstance(auth, PasswordAuth): + raise ConfigurationError("subscriptions require direct APIC PasswordAuth") + + +class Subscription: + def __init__( + self, + client: AciClient, + initial_response: SubscriptionInitialResponse, + refresh_timeout: int | None, + ) -> None: + self._client = client + self.initial_response = initial_response + self.refresh_timeout = refresh_timeout + + @property + def subscription_id(self) -> str: + return self.initial_response.subscription_id + + def refresh(self) -> AciResponse: + return self._client.get( + "subscriptionRefresh.json", params={"id": self.subscription_id} + ) diff --git a/aciClient/testing.py b/aciClient/testing.py new file mode 100644 index 0000000..389d7f0 --- /dev/null +++ b/aciClient/testing.py @@ -0,0 +1,279 @@ +"""Lightweight deterministic helpers for consumer tests (never use the network).""" + +from __future__ import annotations + +import json +import threading +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, field +from typing import Any +from urllib.parse import parse_qsl + +import httpx + +from ._diagnostics import is_sensitive, redact +from .query import AciQuery, QueryValue, normalize_params + + +@dataclass(frozen=True) +class ExpectedRequest: + method: str + path: str + query: ( + AciQuery | Sequence[tuple[str, QueryValue]] | Mapping[str, QueryValue] | None + ) = None + headers: Mapping[str, str] | None = None + json: Any = None + response: ( + httpx.Response + | Mapping[str, Any] + | Exception + | Sequence[httpx.Response | Mapping[str, Any] | Exception] + ) = field(default_factory=lambda: {"imdata": [], "totalCount": "0"}) + repeat: int = 1 + + +@dataclass(frozen=True) +class RecordedRequest: + method: str + path: str + query: tuple[tuple[str, str], ...] + headers: Mapping[str, str] + json: Any + + +def _redact(headers: httpx.Headers) -> dict[str, str]: + return {k: "********" if is_sensitive(k) else v for k, v in headers.items()} + + +def _recorded_path(path: str) -> str: + # ND embeds the APIC query in the proxy path rather than the URL query. + base, separator, embedded_query = path.partition("?") + if separator and is_sensitive(embedded_query): + return base + "?[redacted]" + return path + + +class ScriptedTransport(httpx.BaseTransport): + def __init__( + self, expectations: Sequence[ExpectedRequest] = (), *, ordered: bool = True + ): + self._expected: list[tuple[ExpectedRequest, int, int]] = [ + (x, x.repeat, 0) for x in expectations + ] + self.ordered = ordered + self.history: list[RecordedRequest] = [] + self.closed = False + self._lock = threading.RLock() + + def _handle_locked(self, request: httpx.Request) -> httpx.Response: + content = request.read() + parsed = None + if content: + try: + parsed = json.loads(content) + except ValueError: + parsed = None + query = tuple( + parse_qsl( + request.url.query.decode() + if isinstance(request.url.query, bytes) + else request.url.query, + keep_blank_values=True, + ) + ) + self.history.append( + RecordedRequest( + request.method, + _recorded_path(request.url.path), + tuple( + ( + k, + "********" + if is_sensitive(k) or ("filter" in k and is_sensitive(v)) + else v, + ) + for k, v in query + ), + _redact(request.headers), + redact(parsed), + ) + ) + indexes = ( + range(min(1, len(self._expected))) + if self.ordered + else range(len(self._expected)) + ) + for index in indexes: + expected, remaining, response_index = self._expected[index] + if not self._matches(expected, request, query, parsed): + continue + responses = ( + expected.response + if isinstance(expected.response, (list, tuple)) + else (expected.response,) + ) + result = responses[min(response_index, len(responses) - 1)] + if remaining <= 1: + self._expected.pop(index) + else: + self._expected[index] = (expected, remaining - 1, response_index + 1) + if isinstance(result, Exception): + raise result + if isinstance(result, httpx.Response): + result.request = request + return result + return httpx.Response(200, json=result, request=request) + raise AssertionError( + f"unexpected request: {request.method} {_recorded_path(request.url.path)}" + ) + + @staticmethod + def _matches( + expected: ExpectedRequest, + request: httpx.Request, + query: tuple[tuple[str, str], ...], + parsed: Any, + ) -> bool: + if ( + expected.method.upper() != request.method + or expected.path != request.url.path + ): + return False + if expected.query is not None: + wanted = normalize_params(expected.query) + if sorted(wanted) != sorted(query): + return False + if expected.headers and any( + request.headers.get(k) != v for k, v in expected.headers.items() + ): + return False + return expected.json is None or expected.json == parsed + + def handle_request(self, request: httpx.Request) -> httpx.Response: + with self._lock: + return self._handle_locked(request) + + def close(self): + self.closed = True + + def assert_all_consumed(self): + if self._expected: + raise AssertionError( + f"{len(self._expected)} expected request(s) were not received" + ) + + +class ManualClock: + def __init__(self, start: float = 0.0): + self._time = float(start) + self.sleep_calls: list[float] = [] + + def monotonic(self) -> float: + return self._time + + def advance(self, seconds: float) -> None: + if seconds < 0: + raise ValueError("seconds cannot be negative") + self._time += seconds + + def sleep(self, seconds: float) -> None: + self.sleep_calls.append(seconds) + self.advance(seconds) + + +def managed_object( + class_name: str, + attributes: Mapping[str, Any], + children: Sequence[Mapping[str, Any]] = (), +) -> dict[str, Any]: + body = {"attributes": dict(attributes)} + if children: + body["children"] = list(children) + return {class_name: body} + + +def imdata_response( + *objects: Mapping[str, Any], total_count: int | None = None +) -> dict[str, Any]: + return { + "totalCount": str(len(objects) if total_count is None else total_count), + "imdata": list(objects), + } + + +def login_response( + token: str = "test-token", version: str = "6.0(2h)", refresh_timeout: int = 600 +) -> dict[str, Any]: + return imdata_response( + managed_object( + "aaaLogin", + { + "token": token, + "version": version, + "refreshTimeoutSeconds": str(refresh_timeout), + }, + ) + ) + + +def api_error_response( + code: str = "400", text: str = "request failed" +) -> dict[str, Any]: + return imdata_response(managed_object("error", {"code": code, "text": text})) + + +def subscription_response( + subscription_id: str | int = "1", *objects: Mapping[str, Any] +) -> dict[str, Any]: + value = imdata_response(*objects) + value["subscriptionId"] = subscription_id + return value + + +def subscription_event( + subscription_ids: str | int | Sequence[str | int], *objects: Mapping[str, Any] +) -> dict[str, Any]: + return {"subscriptionId": subscription_ids, "imdata": list(objects)} + + +def snapshot_job_response( + name: str, state: str = "success", dn: str | None = None, **attributes: Any +) -> dict[str, Any]: + dn = ( + dn or f"uni/backupst/jobs-[uni/fabric/configexp-{name}]/run-2026-10-26T10-00-00" + ) + return imdata_response( + managed_object( + "configJob", + { + "name": dn.rsplit("/run-", 1)[-1], + "dn": dn, + "operSt": state, + **attributes, + }, + ) + ) + + +def snapshot_container_response(name: str, *jobs: Mapping[str, Any]) -> dict[str, Any]: + # APIC emits relative names, rather than DNs, for nested configJob children. + children = json.loads(json.dumps(jobs)) + for child in children: + attrs = child["configJob"]["attributes"] + if dn := attrs.pop("dn", None): + attrs["rn"] = dn.rsplit("/", 1)[-1] + return imdata_response( + managed_object( + "configJobCont", + { + "dn": f"uni/backupst/jobs-[uni/fabric/configexp-{name}]", + "name": name, + "pDn": f"uni/fabric/configexp-{name}", + "lastJobName": children[-1]["configJob"]["attributes"]["name"] + if children + else "", + }, + children, + ) + ) diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..1383b7e --- /dev/null +++ b/examples/README.md @@ -0,0 +1,409 @@ +# Examples and usage notes + +[Back to the main README](../README.md) + +All tenant examples use `aciclient`. Start with the create example, then read +and update its bridge domain before deleting it. + +| Topic | Guide | +| --- | --- | +| Tenant and bridge-domain operations | [Create](#create-a-bridge-domain-in-a-tenant), [read](#read-a-tenant), [update](#update-a-bridge-domain-in-a-tenant), [delete](#delete-a-bridge-domain-from-a-tenant) | +| Child objects and filters | [Read child bridge domains](#read-a-tenants-child-bridge-domains) | +| Response models and pagination | [Response handling](#responses-and-pagination) | +| Authentication and TLS | [Connection settings](#authentication-and-tls) | +| Nexus Dashboard | [Gateway setup](#nexus-dashboard-gateway) and [limitations](#gateway-limitations) | +| Application integration | [Client lifecycle](#client-lifecycle), including FastAPI | +| Event notifications | [Subscriptions](#subscriptions) and [runnable script](subscription.py) | +| Configuration backups | [Snapshots](#snapshots) | +| Test an automation function with pytest | [Offline testing](#testing-and-observability) and [runnable example](test_automation.py) | +| Migration | [Legacy interfaces](#legacy-migration) | + +## Tenant management + +These standalone examples use Cisco's shared +[Always-On ACI Simulator](https://developer.cisco.com/docs/aci/sandbox/) +with its public credentials: + +- Endpoint: [https://sandboxapicdc.cisco.com](https://sandboxapicdc.cisco.com) +- Username: `admin` +- Password: `!v3G@!4@Y` + +[Install aciClient](../README.md#installation), then run the create example +first to set up tenant `aciclient` and bridge domain `web`. Read and update +them before trying the delete example. The subscription example also uses +this tenant. + +### Create a bridge domain in a tenant + +Create tenant `aciclient` with bridge domain `web` as a child in one request. +The `children` list contains the managed objects to create under the tenant: + +```python +from aciClient import AciClient, PasswordAuth + +with AciClient( + "sandboxapicdc.cisco.com", PasswordAuth("admin", "!v3G@!4@Y") +) as client: + client.post_mo( + { + "fvTenant": { + "attributes": {"name": "aciclient"}, + "children": [ + {"fvBD": {"attributes": {"name": "web"}}}, + ], + } + }, + dn="uni/tn-aciclient", + ) +``` + +### Read a tenant + +```python +from aciClient import AciClient, PasswordAuth + +with AciClient( + "sandboxapicdc.cisco.com", PasswordAuth("admin", "!v3G@!4@Y") +) as client: + tenant = client.get_mo("uni/tn-aciclient").one() + print(tenant.attributes["name"]) +``` + +`one()` raises `NoResultError` if the tenant does not exist. + +### Read a tenant's child bridge domains + +Request the tenant together with its direct bridge-domain children, then +read each child's attributes: + +```python +from aciClient import AciClient, AciQuery, PasswordAuth + +with AciClient( + "sandboxapicdc.cisco.com", PasswordAuth("admin", "!v3G@!4@Y") +) as client: + tenant = client.get_mo( + "uni/tn-aciclient", + params=AciQuery(rsp_subtree="children", rsp_subtree_class=("fvBD",)), + ).one() + for bridge_domain in tenant.children_of("fvBD"): + print(bridge_domain.attributes["name"], bridge_domain.dn) +``` + +`rsp_subtree="children"` includes direct children under the returned tenant. +`children_of("fvBD")` selects bridge-domain children, and `tenant.children` +gives all returned direct children. To request deeper descendants, use +`rsp_subtree="full"` and traverse each object's `children`. + +See the [Cisco APIC REST API filtering documentation](https://www.cisco.com/c/en/us/td/docs/dcn/aci/apic/all/apic-rest-api-configuration-guide/cisco-apic-rest-api-configuration-guide/m_using_the_rest_api.html) +for query scoping, child inclusion, and filter expressions. The same APIC +filter operators are available in aciClient through Python helpers such as +`eq()`, `ne()`, `and_()`, `or_()`, and `not_()`. Some helpers have descriptive +Python names: `between()` corresponds to APIC's `bw`, and `wildcard()` to +`wcard`. Pass these expressions to `AciQuery(query_target_filter=...)` to +filter query results or `AciQuery(rsp_subtree_filter=...)` to filter returned +children. Query parameter names use underscores in Python, such as +`rsp_subtree` for APIC's `rsp-subtree`. + +### Update a bridge domain in a tenant + +Update the description of the existing bridge domain `web` in tenant `aciclient`: + +```python +from aciClient import AciClient, PasswordAuth + +with AciClient( + "sandboxapicdc.cisco.com", PasswordAuth("admin", "!v3G@!4@Y") +) as client: + client.post_mo( + {"fvBD": {"attributes": {"descr": "Bridge domain for web servers"}}}, + dn="uni/tn-aciclient/BD-web", + ) +``` + +### Delete a bridge domain from a tenant + +Delete `web` from tenant `aciclient`: + +```python +from aciClient import AciClient, PasswordAuth + +with AciClient( + "sandboxapicdc.cisco.com", PasswordAuth("admin", "!v3G@!4@Y") +) as client: + client.delete_mo("uni/tn-aciclient/BD-web") +``` + +### Responses and pagination + +Modern methods return `AciResponse`, whose `objects` are immutable generic +`ManagedObject` instances. Unknown MIM classes and attributes are preserved. +Use `first()`, `one()`, `objects_of()`, `children_of()`, and `to_imdata()` for +typed access. `get_imdata()` returns the gradual-migration `list[dict]` shape. + +APIC attributes remain strings. A read object can contain blank sensitive +properties, so do not blindly post a read object's `to_imdata()` result. + +Normal requests do not silently paginate. Use `iter_pages`, `iter_objects`, +`collect_pages`, or `collect_objects` with `PaginationPolicy`. Pagination is +limited to an effective page size of 2,000, even when a larger size is supplied, +to accommodate APIC log-query caps. Termination uses that effective size. It is +zero-based and is not a stable snapshot: objects can be duplicated, omitted, +or changed between requests. APIC limits result datasets and event records to +100,000 objects and query execution to 90 seconds; the default read timeout is +100 seconds. + +`PaginationPolicy.deadline` is an elapsed-time budget in seconds. It includes +login, refresh, waiting for authentication, and page requests. Iteration stops +when it expires and does not yield pages that arrive late. Each HTTP phase, +including connection-pool acquisition, is capped at the remaining budget. +HTTPX uses phase timeouts rather than forcibly cancelling a synchronous call, +so an in-flight request can still return after the budget; its result is +discarded. A custom transport must enforce the supplied HTTP timeouts itself. + +## Authentication and TLS + +`domain="LDAP"` sends the direct-APIC username as `apic:LDAP\\user`. An +already-qualified username is preserved when `domain` is omitted, and double +qualification is rejected. `list_login_domains()` uses the platform's domain +API without authentication, including when a session already exists. ND's +`defaultDomain` and raw per-domain `passwordSettings` are preserved. + +Certificate authentication uses `CertificateAuth`; signatures are generated +with `cryptography` over the exact method, routed path/query, and serialized +body bytes. Private keys may be paths, PEM bytes, or compatible key objects. + +Modern clients verify TLS by default and accept a CA path or `SSLContext`. +`verify=False` emits `InsecureTLSWarning`. The legacy clients preserve their +insecure behavior in 2.0; verified TLS should be used for production. + +## Nexus Dashboard gateway + +Nexus Dashboard provides ACI gateway functionality through its +[APIC API Proxy](https://developer.cisco.com/docs/nexus-dashboard/latest/get-apic-data/), +which forwards APIC API requests to a managed ACI fabric. + +When moving an existing aciClient script from direct APIC access to Nexus +Dashboard, change only client creation. The gateway client is a drop-in +replacement for ordinary APIC requests: methods, DNs, payloads, query filters, +response objects, and pagination stay the same, subject to the limitations +below. aciClient handles authentication and gateway routing. + +Configure the client with: + +- `endpoint`: the Nexus Dashboard IP address or hostname. +- `auth`: your Nexus Dashboard username, password, and login domain. +- `nexus_dashboard_cluster_name`: the ACI/APIC cluster name as displayed in + Nexus Dashboard. + +This example requires your own Nexus Dashboard endpoint and account: + +```python +from aciClient import AciClient, NexusDashboardPasswordAuth + +with AciClient( + endpoint="nd.example.test", + auth=NexusDashboardPasswordAuth("admin", "secret", domain="local"), + nexus_dashboard_cluster_name="production-fabric", # ACI name in Nexus Dashboard +) as client: + tenant = client.get_mo("uni/tn-aciclient").one() + print(tenant.attributes["name"]) +``` + +### Gateway limitations + +- The ACI cluster must be registered in Nexus Dashboard and APIC Proxy Mode + must be enabled. Cisco requires a Super-Admin account for the API Broker; + the proxy mode determines the APIC identity used for requests. See + [Cisco's API Broker configuration](https://www.cisco.com/c/en/us/td/docs/dcn/nd/4x/articles-411/templates-overview-and-operations.html). +- Gateway mode supports `NexusDashboardPasswordAuth` only. Direct APIC + `PasswordAuth`, `CertificateAuth`, and Nexus Dashboard API keys are not + supported in this mode. The login domain defaults to `local`. +- APIC subscriptions and WebSocket notifications are not supported through + the gateway client. +- Each client targets one ACI cluster. Create a separate client for each + additional cluster. +- ND login accepts `jwttoken` or `token`. Sessions refresh through the ND + authentication API using `AuthCookie`; returned lifetimes or JWT expiry + schedule refresh. JWT expiry is a scheduling hint. Expired sessions log in + before the next request; rejected application requests are never replayed. +- ND login, refresh, and logout use `/api/v1/infra`. `logout()` validates + the server response and always clears local state. +- APIC query parameters travel inside the proxy's `apicPath`. There has been + two documented failures, wildcard anchors `^` (start of string) and `$` (end of + string) are unsupported through ND → ACI but are supported directly on ACI. + +## Client lifecycle + +Construction performs no network traffic. Login is lazy and coordinated when +multiple threads issue their first requests concurrently. + +One client is both one APIC authentication-session boundary and the owner of a +bounded HTTP keep-alive pool. Several HTTP connections reuse the same token; +opening a connection does not log in again. Share one `AciClient` per +connection identity across threads. Do not create a client per request, copy +clients, share sockets after `fork()`, or share clients across processes. +Create a separate client in each worker process. + +Applications own any `dict[ConnectionIdentity, AciClient]` registry and must +include endpoint, principal, domain, authentication type, gateway cluster, +TLS, and proxy configuration in its key. They also own idle eviction, +credential rotation, and shutdown. The library has no hidden registry or +multi-device client pool. + +`HttpConnectionLimits` controls the HTTP connection pool. Each request is sent +once; callers own retries, queuing, and metrics because only they know whether +repeating an operation is safe. + +The examples use Cisco's shared +[Always-On ACI Simulator](https://developer.cisco.com/docs/aci/sandbox/) and +its public credentials. The read example expects tenant `aciclient` to exist. + +### Framework-neutral lifecycle + +`serve_application()` represents your application's entry point. Its handlers +reuse the client passed to it, and the application closes the client during +shutdown. + +```python +from aciClient import AciClient, PasswordAuth + +client = AciClient( + "sandboxapicdc.cisco.com", PasswordAuth("admin", "!v3G@!4@Y") +) +try: + serve_application(client) # handlers reuse this instance +finally: + client.close() +``` + +### FastAPI lifecycle + +Create one client per worker process using the application's lifespan. This +example requires FastAPI in your application environment. The synchronous +route reads tenant `aciclient` through the shared client. + +```python +from contextlib import asynccontextmanager + +from fastapi import FastAPI, Request + +from aciClient import AciClient, PasswordAuth + + +@asynccontextmanager +async def lifespan(app: FastAPI): + client = AciClient( + "sandboxapicdc.cisco.com", PasswordAuth("admin", "!v3G@!4@Y") + ) + app.state.aci = client + try: + yield + finally: + client.close() + + +app = FastAPI(lifespan=lifespan) + + +@app.get("/tenant") +def get_tenant(request: Request): + result = request.app.state.aci.get_mo("uni/tn-aciclient") + return result.to_dict() +``` + +## Subscriptions + +[subscription.py](subscription.py) subscribes to tenant `aciclient` and its +direct children. Run the [create example](#create-a-bridge-domain-in-a-tenant) +first. Subscriptions require direct APIC `PasswordAuth`; gateway and +certificate authentication are not supported. + +**WebSocket setup, message reading, and refresh scheduling are application +code, outside aciClient.** The script uses +[`websockets.asyncio.client`](https://websockets.readthedocs.io/en/stable/reference/asyncio/client.html). +With aciClient installed, run from the repository root: + +```bash +pip install websockets +python examples/subscription.py +``` + +The script logs in, reads `client.session_token`, opens the same session's +`wss://.../socket` connection, then creates the subscription. See +[Cisco's subscription workflow](https://www.cisco.com/c/en/us/td/docs/dcn/aci/apic/all/apic-rest-api-configuration-guide/cisco-apic-rest-api-configuration-guide/m_using_the_rest_api.html) +for the protocol. `asyncio.to_thread()` keeps synchronous client HTTP calls +from blocking the WebSocket reader. + +The application refreshes the subscription every 60 seconds for a 120-second +lifetime. WebSocket ping/pong does not replace this APIC refresh. When the +reader exits, the refresh loop stops and the WebSocket and client connections +close. Reconnection and recreating subscriptions remain application +responsibilities. + +## Snapshots + +Create a snapshot and wait for it to finish: + +```python +job = client.create_snapshot(description="Before a configuration change") +result = job.wait(timeout=120) +print(result.snapshot_name) +``` + +`create_snapshot()` returns after submitting the export. `wait()` polls the +actual APIC run and returns `SnapshotResult` on success. It raises +`JobFailedError` if APIC reports failure or `JobTimeoutError` if the wait +expires. Timeouts retain the last response without additional discovery or +fault queries. Successful exports with warnings expose `result.warnings`. + +The wait budget includes authentication and polling requests. No new poll is +sent once the budget expires, and a response that arrives after it cannot +report success. HTTP phase timeouts are capped as described for pagination; +a synchronous request cannot be forcibly interrupted at the exact deadline. + +Secure fields are excluded by default. Pass `target_dn` to export a subtree, +`name` to choose the export policy name, or `include_secure_fields=True` to +include secure fields. + +## Testing and observability + +[`test_automation.py`](test_automation.py) demonstrates an automation function +with a reusable pytest fixture and scripted APIC responses. With aciClient and +pytest installed, run it from the repository root: + +```bash +python -m pytest -q examples/test_automation.py +``` + +All requests in this example stay offline. The fixture closes the client and +checks that every expected request was made. + +Inject a standard httpx transport. `aciClient.testing` provides +`ScriptedTransport`, request matchers, response builders, and `ManualClock`. +Unexpected calls fail without falling through to the network, repeated query +parameters are retained, and recorded credentials are redacted. The client +closes an injected transport when it is closed. See [TESTING.md](../TESTING.md) +for pytest examples. + +`AciApiError` exposes structured APIC status, code, text, and redacted +response data. Authentication and transport failures have distinct exception +types. Invalid JSON, malformed managed objects, and invalid login, domain, or +subscription responses raise `ResponseDecodeError` instead of built-in parsing +exceptions. + +## Legacy migration + +`ACI`, `ACICert`, their historical import paths, constructor spellings +(`apicPasword`), attributes, methods, and return shapes remain in 2.0. +Construction emits one `LegacyAPIWarning`; `allow_legacy=True` is the 2.0 +default. The 2.1 release targeted for October 2027 will require explicit +`allow_legacy=True`. Removal may be considered separately for 3.0, as may +removing insecure TLS support. + +Replace calls incrementally: + +- `getJson()` → `get_imdata()` or `get()` +- `postJson()` → `post_mo()` +- `deleteMo()` → `delete_mo()` +- `snapshot()` → `create_snapshot()` followed by `wait()` diff --git a/examples/subscription.py b/examples/subscription.py index bc07a53..60e9690 100644 --- a/examples/subscription.py +++ b/examples/subscription.py @@ -1,68 +1,57 @@ -import json -import time -import ssl -from threading import Thread - -from aciClient import ACI -import websocket - - -def on_message(ws, message): - print("Websocket received a message") - # Message contains the data transmited from ACI in plaintext - print(json.loads(message)["imdata"]) - - -def on_close(ws, close_status_code, close_msg): - print("Websocket was closed") - - -def on_open(ws): - print("Websocket was opened") - +"""Subscribe to tenant aciclient using application-owned WebSocket handling. -def open_websocket(ip: str, token: str): - ws = websocket.WebSocketApp( - f"wss://{ip}/socket{token}", - on_message=on_message, - on_open=on_open, - on_close=on_close, - ) - ws.run_forever(sslopt={"cert_reqs": ssl.CERT_NONE}) +Install the optional example dependency with: pip install websockets +Create tenant aciclient with examples/README.md before running this script. +""" +import asyncio +import json -def main(): - hostname = "sandboxapicdc.cisco.com" - username = "admin" - password = "!v3G@!4@Y" - - aci = ACI(hostname, username, password) - - # Login to ACI - aci.login() - - # Open websocket to ACI with the login token - - Thread(target=open_websocket, args=(aci.apicIp, aci.token)).start() - time.sleep(5) - - # Subscribe to an ACI object - subscription_dn = "node/class/faultInst.json" - timeout = 60 # Optional. Default value in ACI - query_parameters = ["order-by=faultInst.lastTransition|asc"] # Optional - - response = aci.subscribe( - subscription_dn=subscription_dn, - timeout=timeout, - query_parameters=query_parameters, - ) - subscription_id = response["subscriptionId"] - - while True: - time.sleep(30) - print("Refreshing subscription") - aci.subscription_refresh(subscription_id=subscription_id) +from websockets.asyncio.client import connect + +from aciClient import AciClient, AciQuery, PasswordAuth + + +async def refresh_subscription(subscription, stopped): + # Refresh scheduling is application code, outside aciClient. + while not stopped.is_set(): + try: + await asyncio.wait_for(stopped.wait(), timeout=60) + except TimeoutError: + await asyncio.to_thread(subscription.refresh) + + +async def main(): + with AciClient( + "sandboxapicdc.cisco.com", PasswordAuth("admin", "!v3G@!4@Y") + ) as client: + await asyncio.to_thread(client.login) + token = client.session_token + assert token is not None + + # WebSocket setup and message handling are not part of aciClient. + endpoint = client.connection_identity.endpoint.replace("https://", "wss://", 1) + async with connect(f"{endpoint}/socket{token}") as websocket: + subscription = await asyncio.to_thread( + client.subscribe_mo, + "uni/tn-aciclient", + params=AciQuery(rsp_subtree="children"), + refresh_timeout=120, + ) + print("Subscription:", subscription.subscription_id) + for tenant in subscription.initial_response.objects: + print("Initial object:", tenant.dn) + + stopped = asyncio.Event() + async with asyncio.TaskGroup() as tasks: + tasks.create_task(refresh_subscription(subscription, stopped)) + try: + async for message in websocket: + event = json.loads(message) + print(event.get("subscriptionId"), event.get("imdata", [])) + finally: + stopped.set() if __name__ == "__main__": - main() + asyncio.run(main()) diff --git a/examples/test_automation.py b/examples/test_automation.py new file mode 100644 index 0000000..6d511d4 --- /dev/null +++ b/examples/test_automation.py @@ -0,0 +1,46 @@ +"""Test an automation function offline: python -m pytest -q examples/test_automation.py.""" + +import pytest + +from aciClient import AciClient, PasswordAuth +from aciClient.testing import ( + ExpectedRequest, + ScriptedTransport, + imdata_response, + login_response, + managed_object, +) + + +def tenant_names(client: AciClient) -> list[str]: + """Application code: return tenant names in a predictable order.""" + tenants = client.get_class("fvTenant").objects + return sorted(tenant.attributes["name"] for tenant in tenants) + + +@pytest.fixture +def tenant_client(): + transport = ScriptedTransport( + [ + ExpectedRequest("POST", "/api/aaaLogin.json", response=login_response()), + ExpectedRequest( + "GET", + "/api/class/fvTenant.json", + response=imdata_response( + managed_object("fvTenant", {"name": "prod"}), + managed_object("fvTenant", {"name": "dev"}), + ), + ), + ] + ) + with AciClient( + "apic.example.test", + PasswordAuth("admin", "test-password"), + transport=transport, + ) as client: + yield client + transport.assert_all_consumed() + + +def test_tenant_names_are_sorted(tenant_client): + assert tenant_names(tenant_client) == ["dev", "prod"] diff --git a/pyproject.toml b/pyproject.toml index b87162b..dafc5ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,24 +4,25 @@ build-backend = "setuptools.build_meta" [project] name = "aciClient" -version = "1.9.0" +version = "2.0.0" description = "ACI communication helper class" readme = "README.md" -requires-python = ">=3.10" -license = { text = "MIT" } +requires-python = ">=3.12" +license = "MIT" authors = [ { name = "Netcloud AG", email = "nc_dev@netcloud.ch" }, ] dependencies = [ - "pyOpenSSL>=26.0.0,<27", + "certifi>=2026.10.19", + "cryptography>=50.0.2,<51", + "httpx[socks]>=0.27,<1", + "pyOpenSSL>=26.4.0,<27", "requests[socks]>=2.33.1,<3", + "urllib3>=2.8.0,<3", ] classifiers = [ - "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", @@ -31,6 +32,7 @@ classifiers = [ Homepage = "https://github.com/netcloud/aciClient" Repository = "https://github.com/netcloud/aciClient" Issues = "https://github.com/netcloud/aciClient/issues" +Documentation = "https://github.com/netcloud/aciClient/blob/master/README.md" [dependency-groups] dev = [ @@ -42,8 +44,11 @@ dev = [ [tool.setuptools] packages = ["aciClient"] +[tool.setuptools.package-data] +aciClient = ["py.typed"] + [tool.pytest.ini_options] testpaths = ["test"] [tool.ruff] -target-version = "py310" +target-version = "py312" diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 0000000..ab3c9ea --- /dev/null +++ b/test/__init__.py @@ -0,0 +1 @@ +"""Offline tests for the Cisco ACI client.""" diff --git a/test/helpers.py b/test/helpers.py new file mode 100644 index 0000000..1d48c1c --- /dev/null +++ b/test/helpers.py @@ -0,0 +1,84 @@ +"""Shared factories for deterministic client and snapshot tests.""" + +import base64 +import json + +import httpx + +from aciClient import ( + AciClient, + NexusDashboardPasswordAuth, + PasswordAuth, +) +from aciClient.testing import ( + ExpectedRequest, + ManualClock, + imdata_response, + login_response, + snapshot_container_response, + snapshot_job_response, +) + + +def client_for(handler, *, clock=None, gateway=False): + return AciClient( + "apic.example", + NexusDashboardPasswordAuth("u", "p") if gateway else PasswordAuth("u", "p"), + nexus_dashboard_cluster_name="fabric" if gateway else None, + transport=httpx.MockTransport(handler), + runtime=clock, + ) + + +def jwt(exp): + payload = ( + base64.urlsafe_b64encode(json.dumps({"exp": exp}).encode()).decode().rstrip("=") + ) + return "header." + payload + ".signature" + + +def login_expectation(username="admin", password="secret", domain=None): + name = f"apic:{domain}\\{username}" if domain else username + return ExpectedRequest( + "POST", + "/api/aaaLogin.json", + json={"aaaUser": {"attributes": {"name": name, "pwd": password}}}, + response=login_response(), + ) + + +def snapshot_client(state="success", *, reuse=False, missing=False, requests=None): + count = 0 + old_dn = "uni/backupst/jobs-[uni/fabric/configexp-snap]/run-2026-10-25T10-00-00" + new_dn = "uni/backupst/jobs-[uni/fabric/configexp-snap]/run-2026-10-26T10-00-00" + + def handler(request): + nonlocal count + if requests is not None: + requests.append(request) + if request.url.path.endswith("aaaLogin.json"): + return httpx.Response(200, json=login_response()) + if request.url.path == "/api/class/configJobCont.json": + assert ( + request.url.params["query-target-filter"] + == 'eq(configJobCont.pDn,"uni/fabric/configexp-snap")' + ) + count += 1 + jobs = [] + if reuse: + jobs.append(snapshot_job_response("snap", dn=old_dn)["imdata"][0]) + if count > 1 and not missing: + jobs.append( + snapshot_job_response( + "snap", + state, + dn=new_dn, + details="export detail", + executeTime="2026-10-26T10:00:00+00:00", + lastStepTime="2026-10-26T10:00:02+00:00", + )["imdata"][0] + ) + return httpx.Response(200, json=snapshot_container_response("snap", *jobs)) + return httpx.Response(200, json=imdata_response()) + + return client_for(handler, clock=ManualClock()), new_dn diff --git a/test/test_aci.py b/test/test_aci.py index 85db26f..02acd37 100644 --- a/test/test_aci.py +++ b/test/test_aci.py @@ -4,6 +4,7 @@ # Copyright (c) 2020 Netcloud AG """AciClient Testing""" + from requests import RequestException from aciClient.aci import ACI diff --git a/test/test_authentication.py b/test/test_authentication.py new file mode 100644 index 0000000..894bb45 --- /dev/null +++ b/test/test_authentication.py @@ -0,0 +1,561 @@ +"""Password and certificate authentication, session renewal, and discovery.""" + +import base64 +import threading +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime, timezone +from threading import Event + +import httpx +import pytest +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import padding, rsa + +from aciClient import ( + AciApiError, + AciClient, + AciQuery, + AuthenticationError, + AuthorizationError, + CertificateAuth, + PaginationPolicy, + PasswordAuth, + ResponseDecodeError, + eq, +) +from aciClient.exceptions import TransportError +from aciClient.testing import ( + ExpectedRequest, + ManualClock, + ScriptedTransport, + imdata_response, + login_response, + managed_object, +) + +from .helpers import client_for, jwt, login_expectation + + +def test_direct_login_query_and_managed_object(): + transport = ScriptedTransport( + [ + login_expectation(domain="LDAP"), + ExpectedRequest( + "GET", + "/api/class/fvTenant.json", + query=[("query-target-filter", 'eq(fvTenant.name,"a\\"b")')], + response=imdata_response( + managed_object("fvTenant", {"dn": "uni/tn-a", "name": "a"}) + ), + ), + ] + ) + with AciClient( + "apic.example", PasswordAuth("admin", "secret", "LDAP"), transport=transport + ) as client: + response = client.get_class( + "fvTenant", params=AciQuery(query_target_filter=eq("fvTenant.name", 'a"b')) + ) + assert response.one().dn == "uni/tn-a" + assert response.total_count == 1 + assert client.connection_info.apic_version.major == 6 + assert transport.history[0].json["aaaUser"]["attributes"]["pwd"] == "********" + transport.assert_all_consumed() + + +def test_many_threads_share_one_login_session(): + lock = threading.Lock() + counts = {"login": 0, "get": 0} + + def handler(request): + with lock: + if request.url.path.endswith("aaaLogin.json"): + counts["login"] += 1 + return httpx.Response(200, json=login_response(), request=request) + counts["get"] += 1 + return httpx.Response(200, json=imdata_response(), request=request) + + client = AciClient( + "apic.example", + PasswordAuth("admin", "secret"), + transport=httpx.MockTransport(handler), + ) + with ThreadPoolExecutor(max_workers=8) as executor: + list(executor.map(lambda _: client.get_class("fvTenant"), range(24))) + assert counts == {"login": 1, "get": 24} + + +def test_certificate_signature_covers_exact_path_query_and_body(): + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + signature = base64.b64encode( + key.sign( + b"GET/api/class/fvTenant.json?page=0", + padding.PKCS1v15(), + hashes.SHA256(), + ) + ).decode() + cookie = "; ".join( + [ + "APIC-Certificate-Fingerprint=cert-name", + "APIC-Certificate-Algorithm=v1.0", + "APIC-Certificate-DN=uni/userext/user-cert", + f"APIC-Request-Signature={signature}", + ] + ) + transport = ScriptedTransport( + [ + ExpectedRequest( + "GET", + "/api/class/fvTenant.json", + query=[("page", "0")], + headers={"Cookie": cookie}, + response=imdata_response(), + ) + ] + ) + client = AciClient( + "apic.example", + CertificateAuth("cert-name", key, "uni/userext/user-cert"), + transport=transport, + ) + client.get_class("fvTenant", params={"page": 0}) + + +def test_login_domain_discovery_before_login(): + response = imdata_response( + managed_object( + "aaaDomain", {"name": "LDAP", "realm": "ldap", "isDefault": "yes"} + ) + ) + transport = ScriptedTransport( + [ExpectedRequest("GET", "/api/aaaListDomains.json", response=response)] + ) + client = AciClient( + "apic.example", PasswordAuth("admin", "secret"), transport=transport + ) + domains = client.list_login_domains() + assert domains.default_domain == "LDAP" + assert not client.is_authenticated + + +def test_domain_discovery_uses_client_transport_errors(): + transport = ScriptedTransport( + [ + ExpectedRequest( + "GET", + "/api/aaaListDomains.json", + response=httpx.ConnectError("down"), + ) + ] + ) + client = AciClient( + "apic.example", PasswordAuth("admin", "secret"), transport=transport + ) + with pytest.raises(TransportError): + client.list_login_domains() + + +def test_session_token_tracks_authentication_lifecycle_without_io(): + transport = ScriptedTransport( + [ + login_expectation(), + ExpectedRequest("POST", "/api/aaaLogout.json", response=imdata_response()), + ] + ) + with AciClient( + "apic.example", PasswordAuth("admin", "secret"), transport=transport + ) as client: + assert client.session_token is None + assert transport.history == [] + + client.login() + assert client.session_token == "test-token" + assert len(transport.history) == 1 + + client.logout() + assert client.session_token is None + assert len(transport.history) == 2 + + transport.assert_all_consumed() + + +def test_logout_clears_session_metadata(): + transport = ScriptedTransport( + [ + login_expectation(), + ExpectedRequest("POST", "/api/aaaLogout.json", response=imdata_response()), + ] + ) + client = AciClient( + "apic.example", PasswordAuth("admin", "secret"), transport=transport + ) + client.login() + assert client.session_info.authenticated_at is not None + + client.logout() + + assert not client.session_info.authenticated + assert client.session_info.authenticated_at is None + assert client.session_info.refresh_deadline is None + + +def test_flat_and_wrapped_domains_preserved(): + records = [ + {"name": "TACACS", "type": "OTHER"}, + {"aaaDomain": {"attributes": {"name": "local", "isDefault": "yes"}}}, + ] + with client_for(lambda r: httpx.Response(200, json={"imdata": records})) as client: + domains = client.list_login_domains() + assert [d.name for d in domains.domains] == ["TACACS", "local"] + assert domains.default_domain == "local" + assert domains.domains[0].raw["type"] == "OTHER" + assert not client.is_authenticated + + +def test_nd_default_domain_does_not_treat_string_no_as_true(): + body = { + "defaultDomain": "local", + "domains": [ + {"name": "local", "realm": "Local"}, + {"name": "ldap", "isDefault": "no"}, + ], + } + with client_for(lambda r: httpx.Response(200, json=body), gateway=True) as client: + domains = client.list_login_domains() + assert domains.default_domain == "local" + assert [d.is_default for d in domains.domains] == [True, False] + + +def test_concurrent_refresh_rotates_once_with_get(): + clock = ManualClock() + calls = [] + lock = threading.Lock() + + def handler(request): + with lock: + calls.append((request.method, request.url.path)) + if request.url.path.endswith("aaaLogin.json"): + return httpx.Response(200, json=login_response()) + assert request.method == "GET" and not request.content + return httpx.Response(200, json=login_response(token="rotated")) + + with client_for(handler, clock=clock) as client: + client.login() + clock.advance(580) + with ThreadPoolExecutor(max_workers=8) as pool: + list(pool.map(lambda _: client.ensure_authenticated(), range(16))) + assert client.session_token == "rotated" + assert calls == [ + ("POST", "/api/aaaLogin.json"), + ("GET", "/api/aaaRefresh.json"), + ] + + +@pytest.mark.parametrize("maximum", [None, 100]) +def test_expired_or_maximum_session_reauthenticates_before_sending(maximum): + clock = ManualClock() + logins = [] + + def handler(request): + if request.url.path.endswith("aaaLogin.json"): + logins.append(request) + body = login_response( + token="token-" + str(len(logins)), refresh_timeout=600 + ) + if maximum is not None: + body["imdata"][0]["aaaLogin"]["attributes"][ + "maximumLifetimeSeconds" + ] = str(maximum) + return httpx.Response(200, json=body) + assert request.headers["Cookie"] == "APIC-cookie=token-2" + return httpx.Response(200, json=imdata_response()) + + with client_for(handler, clock=clock) as client: + client.login() + clock.advance(120 if maximum else 601) + assert not client.is_authenticated + client.get_class("fvTenant") + assert len(logins) == 2 + + +def test_failed_request_invalidates_without_replay_then_explicit_login_works(): + calls = [] + + def handler(request): + calls.append(request.url.path) + if request.url.path.endswith("aaaLogin.json"): + return httpx.Response(200, json=login_response()) + return httpx.Response(401, json={"message": "expired", "code": 401}) + + with client_for(handler) as client: + with pytest.raises(AuthenticationError): + client.post_mo({"fvTenant": {"attributes": {"dn": "uni/tn-test"}}}) + assert client.session_token is None + assert len(calls) == 2 + client.login() + assert len(calls) == 3 + + +def test_refresh_rejection_clears_session_and_propagates(): + clock = ManualClock() + + def handler(request): + if request.url.path.endswith("aaaLogin.json"): + return httpx.Response(200, json=login_response()) + return httpx.Response(403, json={"message": "refresh rejected", "code": 403}) + + with client_for(handler, clock=clock) as client: + client.login() + clock.advance(580) + with pytest.raises(AuthorizationError): + client.ensure_authenticated() + assert ( + client.session_token is None + and client.session_info.refresh_deadline is None + ) + + +def test_nd_jwt_refresh_uses_infra_endpoint(): + token1 = jwt(datetime.now(timezone.utc).timestamp() + 300) + token2 = jwt(datetime.now(timezone.utc).timestamp() + 900) + calls = [] + clock = ManualClock() + + def handler(request): + calls.append(request.url.path) + assert request.method == "POST" + if request.url.path.endswith("/login"): + return httpx.Response(200, json={"jwttoken": token1}) + assert request.url.path == "/api/v1/infra/refresh" + assert request.headers["Cookie"] == "AuthCookie=" + token1 + return httpx.Response(200, json={"jwttoken": token2}) + + with client_for(handler, clock=clock, gateway=True) as client: + client.login() + clock.advance(280) + client.ensure_authenticated() + assert client.session_token == token2 + assert calls == ["/api/v1/infra/login", "/api/v1/infra/refresh"] + + +def test_logout_failure_is_reported_and_local_state_cleared(): + def handler(request): + return ( + httpx.Response(200, json=login_response()) + if request.url.path.endswith("aaaLogin.json") + else httpx.Response( + 500, json={"message": "logout unavailable", "code": 500} + ) + ) + + with client_for(handler) as client: + client.login() + with pytest.raises(AciApiError, match="logout unavailable"): + client.logout() + assert client.session_token is None and not client.is_authenticated + + +@pytest.mark.parametrize("method", ["GET", "POST", "DELETE"]) +def test_signature_matches_actual_request_bytes_with_quotes_and_unicode(method): + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + + def handler(request): + cookies = dict(x.split("=", 1) for x in request.headers["Cookie"].split("; ")) + key.public_key().verify( + base64.b64decode(cookies["APIC-Request-Signature"]), + request.method.encode() + request.url.raw_path + request.content, + padding.PKCS1v15(), + hashes.SHA256(), + ) + assert b"%22" in request.url.raw_path + return httpx.Response(200, json=imdata_response()) + + auth = CertificateAuth("cert", key, "uni/userext/user-test/usercert-cert") + with AciClient( + "apic.example", auth, transport=httpx.MockTransport(handler) + ) as client: + dn = 'uni/tn-tést/annotationKey-["quoted"]' + if method == "GET": + client.get_mo(dn, params={"query-target-filter": 'eq(fvTenant.name,"a b")'}) + elif method == "DELETE": + client.delete_mo(dn) + else: + client.post_mo({"fvTenant": {"attributes": {"dn": dn}}}, dn=dn) + + +@pytest.mark.parametrize("versions", [("6.2(2e)", "6.2(2e)"), ("6.0(9f)", "6.2(2e)")]) +def test_firmware_fallback_refuses_mixed_versions(versions): + def handler(request): + assert request.url.path == "/api/class/firmwareCtrlrRunning.json" + assert not request.url.query + return httpx.Response( + 200, + json=imdata_response( + *[ + managed_object("firmwareCtrlrRunning", {"version": v}) + for v in versions + ] + ), + ) + + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + with AciClient( + "apic.example", + CertificateAuth("cert", key, "user-cert"), + transport=httpx.MockTransport(handler), + ) as client: + if len(set(versions)) == 1: + assert client.discover_version().apic_version_raw == versions[0] + else: + with pytest.raises(ResponseDecodeError, match="ambiguous"): + client.discover_version() + + +@pytest.mark.parametrize("authenticated", [False, True]) +def test_nd_public_domains_never_authenticate_or_send_cached_cookie(authenticated): + calls = [] + domain_body = { + "defaultDomain": "local", + "domains": [ + { + "name": "local", + "realm": "Local", + "passwordSettings": {"minimumLength": 8, "strongCheckEnabled": True}, + } + ], + } + + def handler(request): + calls.append(request.url.path) + if request.url.path.endswith("/login"): + return httpx.Response(200, json={"jwttoken": "nd-token"}) + assert request.url.path == "/api/v1/infra/logindomains" + assert ( + "Cookie" not in request.headers and "Authorization" not in request.headers + ) + return httpx.Response(200, json=domain_body) + + with client_for(handler, gateway=True) as client: + if authenticated: + client.login() + client._http.cookies.set("AuthCookie", "jar-token") + domains = client.list_login_domains() + assert domains.default_domain == "local" + assert ( + domains.domains[0].raw["passwordSettings"] + == domain_body["domains"][0]["passwordSettings"] + ) + assert client.is_authenticated == authenticated + assert calls == (["/api/v1/infra/login"] if authenticated else []) + [ + "/api/v1/infra/logindomains" + ] + + +def test_nd_public_domain_rejection_does_not_trigger_login_fallback(): + calls = [] + + def handler(request): + calls.append(request.url.path) + return httpx.Response(401, json={"error": "Authorization field missing"}) + + with client_for(handler, gateway=True) as client: + with pytest.raises(AuthenticationError, match="Authorization field missing"): + client.list_login_domains() + assert not client.is_authenticated + assert calls == ["/api/v1/infra/logindomains"] + + +@pytest.mark.parametrize("lifetime", ["NaN", "Infinity", "-Infinity"]) +def test_nonfinite_session_lifetime_rejects_login_and_clears_state(lifetime): + response = login_response(refresh_timeout=lifetime) + with client_for(lambda request: httpx.Response(200, json=response)) as client: + with pytest.raises(ResponseDecodeError, match="authentication lifetime"): + client.login() + assert not client.is_authenticated and client.session_token is None + + +@pytest.mark.parametrize("expiry", ["NaN", "Infinity", "-Infinity"]) +def test_nonfinite_jwt_expiry_falls_back_to_returned_lifetime(expiry): + calls = [] + clock = ManualClock() + + def handler(request): + calls.append(request.url.path) + return httpx.Response( + 200, + json={ + "jwttoken": jwt(expiry), + "refreshTimeoutSeconds": 300, + }, + ) + + with client_for(handler, gateway=True, clock=clock) as client: + client.login() + clock.advance(200) + client.ensure_authenticated() + assert calls == ["/api/v1/infra/login"] + clock.advance(90) + client.ensure_authenticated() + assert calls == ["/api/v1/infra/login", "/api/v1/infra/refresh"] + + +def test_new_login_version_replaces_a_previously_discovered_version(): + logins = [] + + def handler(request): + if request.url.path.endswith("aaaLogin.json"): + logins.append(request) + body = login_response(version="6.1(1a)") + if len(logins) == 1: + body["imdata"][0]["aaaLogin"]["attributes"].pop("version") + return httpx.Response(200, json=body) + if request.url.path.endswith("firmwareCtrlrRunning.json"): + return httpx.Response( + 200, + json=imdata_response( + managed_object("firmwareCtrlrRunning", {"version": "6.0(2h)"}) + ), + ) + return httpx.Response(200, json=imdata_response()) + + with AciClient( + "apic.example", PasswordAuth("u", "p"), transport=httpx.MockTransport(handler) + ) as client: + client.login() + assert client.discover_version().apic_version_raw == "6.0(2h)" + client.logout() + client.login() + assert client.connection_info.apic_version_raw == "6.1(1a)" + + +def test_authentication_wait_respects_budget_without_cancelling_other_callers(): + started, release = Event(), Event() + logins = [] + + def handler(request): + if request.url.path.endswith("aaaLogin.json"): + logins.append(request) + started.set() + assert release.wait(5) + return httpx.Response(200, json=login_response()) + return httpx.Response(200, json=imdata_response()) + + with AciClient( + "apic.example", PasswordAuth("u", "p"), transport=httpx.MockTransport(handler) + ) as client: + with ThreadPoolExecutor(max_workers=2) as pool: + login = pool.submit(client.login) + try: + assert started.wait(5) + pages = pool.submit( + client.collect_objects, + "class/fvTenant.json", + policy=PaginationPolicy(deadline=0.05), + ) + assert pages.result(timeout=2) == [] + assert not login.done() + finally: + release.set() + assert login.result(timeout=2).authenticated + client.get_class("fvTenant") + assert len(logins) == 1 diff --git a/test/test_client.py b/test/test_client.py new file mode 100644 index 0000000..aac3845 --- /dev/null +++ b/test/test_client.py @@ -0,0 +1,179 @@ +"""Client configuration, lifecycle, request paths, and public value objects.""" + +import copy +import ssl +from pathlib import Path + +import certifi +import httpx +import pytest + +from aciClient import ( + AciClient, + CertificateAuth, + HttpConnectionLimits, + ManagedObject, + NexusDashboardPasswordAuth, + PasswordAuth, +) +from aciClient.exceptions import ConfigurationError, QueryParameterError +from aciClient.query import normalize_params +from aciClient.testing import ( + ExpectedRequest, + ScriptedTransport, + imdata_response, + login_response, +) + +from .helpers import login_expectation + + +@pytest.mark.parametrize( + "auth,cluster", + [ + (NexusDashboardPasswordAuth("id", "secret"), None), + (PasswordAuth("u", "p"), "cluster"), + ], +) +def test_invalid_auth_mode_rejected_without_io(auth, cluster): + with pytest.raises(ConfigurationError): + AciClient( + "apic.example", + auth, + nexus_dashboard_cluster_name=cluster, + transport=ScriptedTransport(), + ) + + +def test_dn_is_preserved_and_safely_encoded(): + dn = ( + "uni/tn-a/ap-b/epg-e/rsPathAtt-[topology/pod-1/paths-101/pathep-[eth1/1 space]]" + ) + path = "/api/mo/uni/tn-a/ap-b/epg-e/rsPathAtt-[topology/pod-1/paths-101/pathep-[eth1/1 space]].json" + transport = ScriptedTransport( + [login_expectation(), ExpectedRequest("GET", path, response=imdata_response())] + ) + AciClient( + "apic.example", PasswordAuth("admin", "secret"), transport=transport + ).get_mo(dn) + + +def test_query_validation_and_models(): + with pytest.raises(QueryParameterError): + normalize_params([("page", 0), ("page", 1)]) + child = ManagedObject.create("fvAEPg", {"dn": "uni/tn-a/ap-a/epg-a"}) + tenant = ManagedObject.create("fvTenant", {"dn": "uni/tn-a"}, (child,)) + assert ManagedObject.from_imdata(tenant.to_imdata()) == tenant + assert tenant.require_child("fvAEPg") == child + + +def test_copy_is_rejected(): + client = AciClient( + "apic.example", PasswordAuth("u", "p"), transport=ScriptedTransport() + ) + with pytest.raises(TypeError): + copy.copy(client) + with pytest.raises(TypeError): + copy.deepcopy(client) + + +def test_public_arguments_reject_typos(): + with pytest.raises(TypeError, match="unexpected keyword argument 'timout'"): + AciClient("apic.example", PasswordAuth("u", "p"), timout=1) + + client = AciClient( + "apic.example", PasswordAuth("u", "p"), transport=ScriptedTransport() + ) + with pytest.raises(TypeError, match="unexpected keyword argument 'timout'"): + client.get_class("fvTenant", timout=1) + + +def test_client_closes_injected_transport(): + transport = ScriptedTransport() + AciClient( + "nd.example", + NexusDashboardPasswordAuth("id", "secret"), + nexus_dashboard_cluster_name="c", + transport=transport, + ).close() + assert transport.closed + + +def test_certificate_identity_distinguishes_users_with_the_same_certificate_name(): + def identity(user): + auth = CertificateAuth( + "automation", b"unused", f"uni/userext/user-{user}/usercert-automation" + ) + with AciClient("apic.example", auth, transport=ScriptedTransport()) as client: + return client.connection_identity + + assert identity("ops") != identity("admin") + assert identity("ops") == identity("ops") + assert identity("ops").principal == "uni/userext/user-ops/usercert-automation" + + +def test_ca_path_creates_a_verified_ssl_context(monkeypatch): + http_client = httpx.Client + contexts = [] + + def capture_client(**kwargs): + contexts.append(kwargs["verify"]) + return http_client(**kwargs) + + monkeypatch.setattr(httpx, "Client", capture_client) + transport = ScriptedTransport( + [ExpectedRequest("GET", "/api/aaaListDomains.json", response={"imdata": []})] + ) + bundle = Path(certifi.where()) + with AciClient( + "apic.example", PasswordAuth("u", "p"), verify=bundle, transport=transport + ) as client: + assert client.list_login_domains().domains == () + assert client.connection_identity.tls_profile == str(bundle) + + context = contexts[0] + assert isinstance(context, ssl.SSLContext) + assert context.check_hostname + assert context.verify_mode == ssl.CERT_REQUIRED + assert context.get_ca_certs() + transport.assert_all_consumed() + + +@pytest.mark.parametrize("pool_timeout", [None, 0.2]) +@pytest.mark.parametrize("request_timeout", [None, 5.0, 0.0]) +def test_login_and_requests_preserve_pool_timeout(pool_timeout, request_timeout): + requests = [] + + def handler(request): + requests.append(request) + payload = ( + login_response() + if request.url.path.endswith("aaaLogin.json") + else imdata_response() + ) + return httpx.Response(200, json=payload) + + with AciClient( + "apic.example", + PasswordAuth("u", "p"), + connection_limits=HttpConnectionLimits(pool_timeout=pool_timeout), + transport=httpx.MockTransport(handler), + ) as client: + client.get_class("fvTenant", timeout=request_timeout) + + assert len(requests) == 2 + login_timeout, read_timeout = [r.extensions["timeout"] for r in requests] + expected_pool = 100.0 if pool_timeout is None else pool_timeout + assert login_timeout == { + "connect": 100.0, + "read": 100.0, + "write": 100.0, + "pool": expected_pool, + } + expected_timeout = 100.0 if request_timeout is None else request_timeout + assert read_timeout == { + "connect": expected_timeout, + "read": expected_timeout, + "write": expected_timeout, + "pool": expected_pool, + } diff --git a/test/test_gateway.py b/test/test_gateway.py new file mode 100644 index 0000000..f0ee193 --- /dev/null +++ b/test/test_gateway.py @@ -0,0 +1,107 @@ +"""Tests for gateway behavior.""" + +import httpx +import pytest + +from aciClient import ( + AciApiError, + AciClient, + NexusDashboardPasswordAuth, +) +from aciClient.testing import ( + ExpectedRequest, + ScriptedTransport, + imdata_response, +) + +from .helpers import client_for + + +def test_gateway_routing_embeds_apic_query_in_proxy_path(): + transport = ScriptedTransport( + [ + ExpectedRequest( + "POST", + "/api/v1/infra/login", + json={ + "domain": "local", + "userName": "admin", + "userPasswd": "secret", + }, + response={"token": "test-token", "statusCode": 200}, + ), + ExpectedRequest( + "GET", + "/api/v1/oneManage/apics/prod fabric/api/class/fvTenant.json?page=0", + query=[], + headers={"Cookie": "AuthCookie=test-token"}, + response=imdata_response(), + ), + ] + ) + client = AciClient( + "https://nd.example:8443/api", + NexusDashboardPasswordAuth("admin", "secret"), + nexus_dashboard_cluster_name="prod fabric", + transport=transport, + ) + client.get_class("fvTenant", params={"page": 0}) + transport.assert_all_consumed() + + +def test_nd_error_code_message_and_raw_are_retained(): + def handler(request): + if request.url.path.endswith("/login"): + return httpx.Response(200, json={"token": "test-token"}) + return httpx.Response( + 400, + json={ + "code": 4065, + "message": "bad proxy request", + "description": "details", + }, + ) + + with client_for(handler, gateway=True) as client: + with pytest.raises(AciApiError) as error: + client.get_class("fvTenant") + assert error.value.error_code == "4065" + assert error.value.error_text == "bad proxy request" + assert error.value.raw["description"] == "details" + + +@pytest.mark.parametrize("method", ["GET", "POST", "DELETE"]) +def test_nd_apic_query_is_inside_proxy_path(method): + from urllib.parse import unquote + + sent = [] + + def handler(request): + if request.url.path.endswith("/login"): + return httpx.Response(200, json={"token": "nd-token"}) + sent.append(request) + return httpx.Response(200, json=imdata_response()) + + with client_for(handler, gateway=True) as client: + client._request( + method, + "class/fvTenant.json", + params=[ + ("query-target-filter", 'eq(fvTenant.name,"common")'), + ("page-size", "2"), + ("repeated", "a"), + ("repeated", "b"), + ], + ) + request = sent[0] + assert request.method == method and not request.url.query + target = request.url.raw_path.decode("ascii") + assert ( + "/api/class/fvTenant.json%3Fquery-target-filter=eq%28fvTenant.name%2C%22common%22%29" + in target + ) + inner_path = unquote(target.split("/apics/fabric/", 1)[1]) + assert ( + inner_path + == 'api/class/fvTenant.json?query-target-filter=eq(fvTenant.name,"common")&page-size=2&repeated=a&repeated=b' + ) diff --git a/test/test_pagination.py b/test/test_pagination.py new file mode 100644 index 0000000..338a020 --- /dev/null +++ b/test/test_pagination.py @@ -0,0 +1,130 @@ +"""Tests for pagination behavior.""" + +import httpx +import pytest + +from aciClient import ( + AciClient, + AciTimeoutError, + PaginationPolicy, + PasswordAuth, +) +from aciClient.testing import ( + ExpectedRequest, + ManualClock, + ScriptedTransport, + imdata_response, + login_response, + managed_object, +) + +from .helpers import client_for, login_expectation + + +def test_pagination_replaces_page_and_honors_max_items(): + transport = ScriptedTransport( + [ + login_expectation(), + ExpectedRequest( + "GET", + "/api/class/fvTenant.json", + query=[("page", "0"), ("page-size", "2")], + response=imdata_response( + managed_object("fvTenant", {"name": "a"}), + managed_object("fvTenant", {"name": "b"}), + ), + ), + ExpectedRequest( + "GET", + "/api/class/fvTenant.json", + query=[("page", "1"), ("page-size", "2")], + response=imdata_response( + managed_object("fvTenant", {"name": "c"}), + managed_object("fvTenant", {"name": "d"}), + ), + ), + ] + ) + client = AciClient( + "apic.example", PasswordAuth("admin", "secret"), transport=transport + ) + objects = client.collect_objects( + "class/fvTenant.json", policy=PaginationPolicy(page_size=2, max_items=3) + ) + assert [x.attributes.get("name") for x in objects] == ["a", "b", "c"] + + +def test_pagination_caps_wire_size_and_collects_beyond_first_cap(): + pages = [] + + def handler(request): + if request.url.path.endswith("aaaLogin.json"): + return httpx.Response(200, json=login_response()) + assert request.url.params["page-size"] == "2000" + page = int(request.url.params["page"]) + pages.append(page) + size = 2000 if page < 2 else 1 + return httpx.Response( + 200, + json=imdata_response( + *[ + managed_object("eventRecord", {"id": str(page * 2000 + i)}) + for i in range(size) + ], + total_count=4001, + ), + ) + + with client_for(handler) as client: + values = client.collect_objects( + "class/eventRecord.json", policy=PaginationPolicy(page_size=50000) + ) + assert len(values) == 4001 and pages == [0, 1, 2] + + +def test_pagination_discards_late_pages_and_does_not_leak_budget(): + clock = ManualClock() + requests = [] + + def handler(request): + requests.append(request) + if request.url.path.endswith("aaaLogin.json"): + return httpx.Response(200, json=login_response()) + if "/class/" in request.url.path: + clock.advance(2) + return httpx.Response( + 200, json=imdata_response(managed_object("fvTenant", {"name": "a"})) + ) + + with AciClient( + "apic.example", + PasswordAuth("u", "p"), + transport=httpx.MockTransport(handler), + runtime=clock, + ) as client: + assert ( + client.collect_objects( + "class/fvTenant.json", policy=PaginationPolicy(deadline=1) + ) + == [] + ) + assert client.get_mo("uni/tn-a").one().attributes["name"] == "a" + assert set(requests[-1].extensions["timeout"].values()) == {100} + + +def test_shorter_http_timeout_is_not_swallowed_by_a_longer_pagination_budget(): + def handler(request): + if request.url.path.endswith("aaaLogin.json"): + return httpx.Response(200, json=login_response()) + raise httpx.ReadTimeout("read stalled", request=request) + + with AciClient( + "apic.example", + PasswordAuth("u", "p"), + transport=httpx.MockTransport(handler), + runtime=ManualClock(), + ) as client: + with pytest.raises(AciTimeoutError, match="read stalled"): + client.collect_objects( + "class/fvTenant.json", timeout=0.5, policy=PaginationPolicy(deadline=10) + ) diff --git a/test/test_responses.py b/test/test_responses.py new file mode 100644 index 0000000..2ed0a2c --- /dev/null +++ b/test/test_responses.py @@ -0,0 +1,162 @@ +"""Response decoding, malformed server data, and structured API errors.""" + +import httpx +import pytest + +from aciClient import ( + AciApiError, + AciClient, + AuthenticationError, + NexusDashboardPasswordAuth, + PasswordAuth, + ResponseDecodeError, +) +from aciClient.testing import ( + ExpectedRequest, + ScriptedTransport, + api_error_response, + login_response, +) + + +def test_dataset_limit_uses_api_error(): + transport = ScriptedTransport( + [ + ExpectedRequest( + "POST", + "/api/v1/infra/login", + json={ + "domain": "local", + "userName": "id", + "userPasswd": "secret", + }, + response={"token": "test-token", "statusCode": 200}, + ), + ExpectedRequest( + "GET", + "/api/v1/oneManage/apics/c/api/class/fvBD.json", + headers={"Cookie": "AuthCookie=test-token"}, + response=httpx.Response( + 400, + json=api_error_response( + "103", "Unable to process query, result dataset is too big" + ), + ), + ), + ] + ) + client = AciClient( + "nd.example", + NexusDashboardPasswordAuth("id", "secret"), + nexus_dashboard_cluster_name="c", + transport=transport, + ) + with pytest.raises(AciApiError): + client.get_class("fvBD") + + +@pytest.mark.parametrize( + "payload", + [ + {"imdata": False}, + {"imdata": {}}, + {"imdata": [None]}, + ], +) +def test_invalid_envelope_is_a_decode_error(payload): + def handler(request): + return httpx.Response( + 200, + json=login_response() + if request.url.path.endswith("aaaLogin.json") + else payload, + ) + + with AciClient( + "apic.example", PasswordAuth("u", "p"), transport=httpx.MockTransport(handler) + ) as client: + with pytest.raises(ResponseDecodeError): + client.get_class("fvTenant") + + +@pytest.mark.parametrize( + "field,value", + [ + ("attributes", 5), + ("attributes", "invalid"), + ("attributes", []), + ("attributes", None), + ("children", {}), + ("children", "invalid"), + ("children", [5]), + ], +) +def test_invalid_managed_object_fields_are_decode_errors(field, value): + payload = {"imdata": [{"fvTenant": {field: value}}]} + + def handler(request): + return httpx.Response( + 200, + json=login_response() + if request.url.path.endswith("aaaLogin.json") + else payload, + ) + + with AciClient( + "apic.example", PasswordAuth("u", "p"), transport=httpx.MockTransport(handler) + ) as client: + with pytest.raises(ResponseDecodeError): + client.get_class("fvTenant") + + +@pytest.mark.parametrize( + "payload", + [ + {"imdata": [5]}, + {"imdata": [{"aaaLogin": []}]}, + {"imdata": [{"aaaLogin": {"attributes": 5}}]}, + {"imdata": [{"aaaLogin": {"attributes": {"token": {"value": "secret"}}}}]}, + ], +) +def test_malformed_login_is_a_decode_error_and_clears_session(payload): + with AciClient( + "apic.example", + PasswordAuth("u", "p"), + transport=httpx.MockTransport(lambda r: httpx.Response(200, json=payload)), + ) as client: + with pytest.raises(ResponseDecodeError): + client.login() + assert not client.is_authenticated + assert client.session_token is None + + +@pytest.mark.parametrize( + "gateway,payload", + [ + (False, {"imdata": [{"aaaDomain": {"attributes": []}}]}), + (True, {"domains": [5]}), + (True, {"domains": "invalid"}), + ], +) +def test_malformed_domains_are_decode_errors(gateway, payload): + auth = NexusDashboardPasswordAuth("u", "p") if gateway else PasswordAuth("u", "p") + with AciClient( + "apic.example", + auth, + nexus_dashboard_cluster_name="fabric" if gateway else None, + transport=httpx.MockTransport(lambda r: httpx.Response(200, json=payload)), + ) as client: + with pytest.raises(ResponseDecodeError): + client.list_login_domains() + + +def test_http_authentication_error_survives_a_malformed_error_body(): + with AciClient( + "apic.example", + PasswordAuth("u", "p"), + transport=httpx.MockTransport( + lambda r: httpx.Response(401, json={"imdata": 5}) + ), + ) as client: + with pytest.raises(AuthenticationError): + client.login() diff --git a/test/test_snapshots.py b/test/test_snapshots.py new file mode 100644 index 0000000..67bfc01 --- /dev/null +++ b/test/test_snapshots.py @@ -0,0 +1,183 @@ +"""Tests for snapshots behavior.""" + +import httpx +import pytest + +from aciClient import ( + AciClient, + JobFailedError, + JobState, + JobTimeoutError, + PasswordAuth, +) +from aciClient.snapshots import SnapshotJob, pending_job +from aciClient.testing import ( + ExpectedRequest, + ManualClock, + ScriptedTransport, + imdata_response, + login_response, + snapshot_container_response, + snapshot_job_response, +) + +from .helpers import client_for, login_expectation, snapshot_client + + +def test_snapshot_submission_and_wait_are_distinct(): + transport = ScriptedTransport( + [ + login_expectation(), + ExpectedRequest( + "GET", "/api/class/configJobCont.json", response=imdata_response() + ), + ExpectedRequest("POST", "/api/mo.json", response=imdata_response()), + ExpectedRequest( + "GET", + "/api/class/configJobCont.json", + response=snapshot_container_response( + "snap", snapshot_job_response("snap")["imdata"][0] + ), + ), + ] + ) + clock = ManualClock() + client = AciClient( + "apic.example", + PasswordAuth("admin", "secret"), + transport=transport, + runtime=clock, + ) + job = client.create_snapshot(name="snap") + assert not job.done + assert job.wait(timeout=10).job.state.value == "succeeded" + + +@pytest.mark.parametrize( + "state", ["success", "success-with-warnings", "failed", "fail-no-data"] +) +def test_real_snapshot_container_discovery_states_and_context(state): + client, new_dn = snapshot_client(state, reuse=True) + with client: + job = client.create_snapshot( + name="snap", description="keep me", target_dn="uni/tn-test" + ) + submitted = job.info.submitted_at + assert job.info.job_dn is None + if state in {"failed", "fail-no-data"}: + with pytest.raises(JobFailedError) as error: + job.wait(timeout=5) + assert error.value.error_text == "export detail" + else: + result = job.wait(timeout=5) + assert result.warnings == ( + ("export detail",) if state.endswith("warnings") else () + ) + assert result.completed_at.isoformat() == "2026-10-26T10:00:02+00:00" + assert job.info.job_dn == new_dn + assert job.info.policy_dn == "uni/fabric/configexp-snap" + assert job.info.submitted_at == submitted + assert job.info.description == "keep me" and job.info.target_dn == "uni/tn-test" + assert job.done + + +def test_snapshot_timeout_only_polls_jobs_without_diagnostic_requests(): + requests = [] + client, _ = snapshot_client(missing=True, requests=requests) + with client: + job = client.create_snapshot(name="snap") + original_info = job.info + requests.clear() + with pytest.raises(JobTimeoutError) as error: + job.wait(timeout=1, poll_interval=0.5) + assert "last state: pending" in str(error.value) + assert error.value.raw == original_info.raw + assert job.info == original_info + # Poll at 0 and 0.5 seconds; do not send another request at the deadline. + assert len(requests) == 2 + assert all( + r.method == "GET" and r.url.path == "/api/class/configJobCont.json" + for r in requests + ) + assert job.state == JobState.PENDING and job.info.job_dn is None + + +def test_snapshot_unknown_submission_time_stays_unknown(): + from aciClient.models import ManagedObject + from aciClient.snapshots import build_job_info + + raw = snapshot_job_response("snap")["imdata"][0] + obj = ManagedObject.from_imdata(raw) + first = build_job_info(obj, {"imdata": [raw]}) + refreshed = build_job_info(obj, {"imdata": [raw]}, first) + assert first.submitted_at is None and refreshed.submitted_at is None + assert first.policy_dn == refreshed.policy_dn == "uni/fabric/configexp-snap" + + +def test_snapshot_discovery_refuses_runs_from_another_policy(): + from aciClient.snapshots import discover_jobs + + body = snapshot_container_response( + "foreign", snapshot_job_response("foreign")["imdata"][0] + ) + + def handler(request): + return httpx.Response( + 200, + json=login_response() + if request.url.path.endswith("aaaLogin.json") + else body, + ) + + with client_for(handler) as client: + assert discover_jobs(client, "uni/fabric/configexp-snap") == () + + +def test_snapshot_budget_includes_login_and_rejects_late_success(): + clock = ManualClock() + requests = [] + + def handler(request): + requests.append(request) + if request.url.path.endswith("aaaLogin.json"): + clock.advance(0.2) + return httpx.Response(200, json=login_response()) + clock.advance(1) + return httpx.Response( + 200, + json=snapshot_container_response( + "snap", snapshot_job_response("snap")["imdata"][0] + ), + ) + + with AciClient( + "apic.example", + PasswordAuth("u", "p"), + transport=httpx.MockTransport(handler), + runtime=clock, + ) as client: + job = SnapshotJob( + client, pending_job("uni/fabric/configexp-snap", "snap"), clock + ) + with pytest.raises(JobTimeoutError): + job.wait(timeout=1) + assert not job.done + assert set(requests[0].extensions["timeout"].values()) == {1} + assert all( + value == pytest.approx(0.8) + for value in requests[1].extensions["timeout"].values() + ) + + +def test_zero_snapshot_budget_does_not_send_a_request(): + transport = ScriptedTransport() + clock = ManualClock() + with AciClient( + "apic.example", PasswordAuth("u", "p"), transport=transport, runtime=clock + ) as client: + job = SnapshotJob( + client, pending_job("uni/fabric/configexp-snap", "snap"), clock + ) + with pytest.raises(JobTimeoutError): + job.wait(timeout=0) + assert transport.history == [] diff --git a/test/test_subscriptions.py b/test/test_subscriptions.py new file mode 100644 index 0000000..eeb9a69 --- /dev/null +++ b/test/test_subscriptions.py @@ -0,0 +1,86 @@ +"""Tests for subscriptions behavior.""" + +import httpx +import pytest + +from aciClient import ( + AciClient, + AciQuery, + NexusDashboardPasswordAuth, + PasswordAuth, + ResponseDecodeError, +) +from aciClient.exceptions import ConfigurationError +from aciClient.testing import ( + ExpectedRequest, + ScriptedTransport, + login_response, + subscription_response, +) + + +def test_gateway_subscription_is_rejected_before_request(): + transport = ScriptedTransport() + gateway = AciClient( + "nd.example", + NexusDashboardPasswordAuth("id", "secret"), + nexus_dashboard_cluster_name="c", + transport=transport, + ) + with pytest.raises(ConfigurationError): + gateway.subscribe_class("fvTenant") + + +@pytest.mark.parametrize( + "params, override, expected_timeout", + [ + (AciQuery(refresh_timeout=140), None, 140), + ({"refresh-timeout": 140}, None, 140), + ([("refresh-timeout", "140")], None, 140), + (AciQuery(refresh_timeout=140), 90, 90), + (None, 90, 90), + (None, None, None), + ], +) +def test_subscription_preserves_query_timeout_unless_overridden( + params, override, expected_timeout +): + query = {"subscription": "yes"} + if expected_timeout is not None: + query["refresh-timeout"] = str(expected_timeout) + transport = ScriptedTransport( + [ + ExpectedRequest("POST", "/api/aaaLogin.json", response=login_response()), + ExpectedRequest( + "GET", + "/api/class/fvTenant.json", + query=query, + response=subscription_response(), + ), + ] + ) + with AciClient( + "apic.example", PasswordAuth("u", "p"), transport=transport + ) as client: + subscription = client.subscribe_class( + "fvTenant", params=params, refresh_timeout=override + ) + assert subscription.refresh_timeout == expected_timeout + transport.assert_all_consumed() + + +@pytest.mark.parametrize("subscription_id", [None, "", " ", [], {}, True]) +def test_missing_or_invalid_subscription_ids_are_decode_errors(subscription_id): + def handler(request): + payload = ( + login_response() + if request.url.path.endswith("aaaLogin.json") + else {"imdata": [], "subscriptionId": subscription_id} + ) + return httpx.Response(200, json=payload) + + with AciClient( + "apic.example", PasswordAuth("u", "p"), transport=httpx.MockTransport(handler) + ) as client: + with pytest.raises(ResponseDecodeError, match="subscriptionId"): + client.subscribe_class("fvTenant") diff --git a/test/test_testing.py b/test/test_testing.py new file mode 100644 index 0000000..4cd6b7b --- /dev/null +++ b/test/test_testing.py @@ -0,0 +1,141 @@ +"""Consumer test helpers: request matching and credential redaction.""" + +import httpx +import pytest + +from aciClient import ( + AciClient, + AciQuery, + NexusDashboardPasswordAuth, + PasswordAuth, +) +from aciClient.testing import ( + ExpectedRequest, + ScriptedTransport, + login_response, + managed_object, +) + + +@pytest.mark.parametrize( + "params", + [ + {"subscription": True}, + {"subscription": False}, + {"repeated": ["a", "b"]}, + {"repeated": ("a", "a", "b")}, + [("repeated", "a"), ("repeated", "b"), ("page", 0)], + AciQuery(subscription=True, refresh_timeout=140), + ], +) +def test_expected_query_uses_the_same_normalization_as_the_client(params): + transport = ScriptedTransport( + [ + ExpectedRequest("POST", "/api/aaaLogin.json", response=login_response()), + ExpectedRequest("GET", "/api/class/fvTenant.json", query=params), + ] + ) + with AciClient( + "apic.example", PasswordAuth("u", "p"), transport=transport + ) as client: + assert client.get_class("fvTenant", params=params).is_empty + transport.assert_all_consumed() + + +def test_expected_query_still_rejects_missing_repeated_values(): + transport = ScriptedTransport( + [ + ExpectedRequest("POST", "/api/aaaLogin.json", response=login_response()), + ExpectedRequest( + "GET", "/api/class/fvTenant.json", query={"repeated": ["a", "b"]} + ), + ] + ) + with ( + AciClient( + "apic.example", PasswordAuth("u", "p"), transport=transport + ) as client, + pytest.raises(AssertionError, match="unexpected request"), + ): + client.get_class("fvTenant", params={"repeated": ["a"]}) + + +@pytest.mark.parametrize( + "field", + [ + "key", + "encPwd", + "rootpwd", + "userPasswd", + "monitoringPassword", + "private_key", + "sharedSecret", + ], +) +def test_history_redacts_apic_credentials_without_changing_the_sent_body(field): + payload = managed_object( + "aaaTacacsPlusProvider", {"name": "server", field: "sensitive-value"} + ) + transport = ScriptedTransport( + [ + ExpectedRequest("POST", "/api/aaaLogin.json", response=login_response()), + ExpectedRequest("POST", "/api/mo.json", json=payload), + ] + ) + with AciClient( + "apic.example", PasswordAuth("u", "p"), transport=transport + ) as client: + client.post_mo(payload) + assert "sensitive-value" not in repr(transport.history) + assert ( + transport.history[-1].json["aaaTacacsPlusProvider"]["attributes"]["name"] + == "server" + ) + assert payload["aaaTacacsPlusProvider"]["attributes"][field] == "sensitive-value" + transport.assert_all_consumed() + + +def test_history_masks_sensitive_headers_query_parameters_and_filters(): + transport = ScriptedTransport([ExpectedRequest("POST", "/api/mo.json")]) + query = { + "token": "query-secret", + "query-target-filter": 'eq(aaaUser.pwd,"filter-secret")', + } + with httpx.Client(transport=transport) as http: + http.post( + "https://apic.example/api/mo.json", + params=query, + headers={ + "Proxy-Authorization": "proxy-secret", + "X-API-Key": "api-secret", + "Accept": "application/json", + }, + ) + assert not any( + secret in repr(transport.history) + for secret in ["query-secret", "filter-secret", "proxy-secret", "api-secret"] + ) + assert transport.history[-1].headers["accept"] == "application/json" + + +def test_history_masks_queries_embedded_in_gateway_paths(): + transport = ScriptedTransport( + [ + ExpectedRequest( + "POST", "/api/v1/infra/login", response={"token": "nd-token"} + ), + ExpectedRequest( + "GET", + "/api/v1/oneManage/apics/fabric/api/class/aaaUser.json?token=path-secret", + ), + ] + ) + with AciClient( + "nd.example", + NexusDashboardPasswordAuth("u", "p"), + nexus_dashboard_cluster_name="fabric", + transport=transport, + ) as client: + client.get_class("aaaUser", params={"token": "path-secret"}) + assert "path-secret" not in repr(transport.history) + transport.assert_all_consumed() diff --git a/uv.lock b/uv.lock index a2f783b..d8f49c4 100644 --- a/uv.lock +++ b/uv.lock @@ -1,14 +1,18 @@ version = 1 -revision = 3 -requires-python = ">=3.10" +revision = 5 +requires-python = ">=3.12" [[package]] name = "aciclient" -version = "1.9.0" +version = "2.0.0" source = { editable = "." } dependencies = [ + { name = "certifi" }, + { name = "cryptography" }, + { name = "httpx", extra = ["socks"] }, { name = "pyopenssl" }, { name = "requests", extra = ["socks"] }, + { name = "urllib3" }, ] [package.dev-dependencies] @@ -20,8 +24,12 @@ dev = [ [package.metadata] requires-dist = [ - { name = "pyopenssl", specifier = ">=26.0.0,<27" }, + { name = "certifi", specifier = ">=2026.10.19" }, + { name = "cryptography", specifier = ">=50.0.2,<51" }, + { name = "httpx", extras = ["socks"], specifier = ">=0.27,<1" }, + { name = "pyopenssl", specifier = ">=26.4.0,<27" }, { name = "requests", extras = ["socks"], specifier = ">=2.33.1,<3" }, + { name = "urllib3", specifier = ">=2.8.0,<3" }, ] [package.metadata.requires-dev] @@ -31,200 +39,242 @@ dev = [ { name = "ruff", specifier = ">=0.13.0,<0.16" }, ] +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + [[package]] name = "certifi" -version = "2026.2.25" +version = "2026.10.23" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/11/b524cbd9bbdaca58608e4cf4053fd463417549e1959fa8d3aa18319f9555/certifi-2026.10.23.tar.gz", hash = "sha256:a9895e6e2758b371e0545e321a3a8fd1fcfd9829ada8824a936dfb16aaaf36cd", size = 136225, upload-time = "2026-10-23T11:36:16.478Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, + { url = "https://files.pythonhosted.org/packages/32/d1/a3652fe2c19134d0d15922177821d863cbd7e842d53a21bbe7e8e3a1b5ac/certifi-2026.10.23-py3-none-any.whl", hash = "sha256:e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1", size = 135082, upload-time = "2026-10-23T11:36:14.763Z" }, ] [[package]] name = "cffi" -version = "2.0.0" +version = "2.1.1" 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" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, - { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, - { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, - { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, - { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, - { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, - { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, - { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, - { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, - { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, - { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, - { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, - { 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" }, + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, ] [[package]] name = "charset-normalizer" -version = "3.4.7" +version = "3.5.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +sdist = { url = "https://files.pythonhosted.org/packages/33/1c/f41d4e74c28ab327ff3acd36053f7ea506c55872d7a90b0fa71aa3ab0c89/charset_normalizer-3.5.2.tar.gz", hash = "sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef", size = 172659, upload-time = "2026-09-30T04:39:23.398Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/26/08/0f303cb0b529e456bb116f2d50565a482694fbb94340bf56d44677e7ed03/charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", size = 315182, upload-time = "2026-04-02T09:25:40.673Z" }, - { url = "https://files.pythonhosted.org/packages/24/47/b192933e94b546f1b1fe4df9cc1f84fcdbf2359f8d1081d46dd029b50207/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", size = 209329, upload-time = "2026-04-02T09:25:42.354Z" }, - { url = "https://files.pythonhosted.org/packages/c2/b4/01fa81c5ca6141024d89a8fc15968002b71da7f825dd14113207113fabbd/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", size = 231230, upload-time = "2026-04-02T09:25:44.281Z" }, - { url = "https://files.pythonhosted.org/packages/20/f7/7b991776844dfa058017e600e6e55ff01984a063290ca5622c0b63162f68/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", size = 225890, upload-time = "2026-04-02T09:25:45.475Z" }, - { url = "https://files.pythonhosted.org/packages/20/e7/bed0024a0f4ab0c8a9c64d4445f39b30c99bd1acd228291959e3de664247/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", size = 216930, upload-time = "2026-04-02T09:25:46.58Z" }, - { url = "https://files.pythonhosted.org/packages/e2/ab/b18f0ab31cdd7b3ddb8bb76c4a414aeb8160c9810fdf1bc62f269a539d87/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", size = 202109, upload-time = "2026-04-02T09:25:48.031Z" }, - { url = "https://files.pythonhosted.org/packages/82/e5/7e9440768a06dfb3075936490cb82dbf0ee20a133bf0dd8551fa096914ec/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", size = 214684, upload-time = "2026-04-02T09:25:49.245Z" }, - { url = "https://files.pythonhosted.org/packages/71/94/8c61d8da9f062fdf457c80acfa25060ec22bf1d34bbeaca4350f13bcfd07/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", size = 212785, upload-time = "2026-04-02T09:25:50.671Z" }, - { url = "https://files.pythonhosted.org/packages/66/cd/6e9889c648e72c0ab2e5967528bb83508f354d706637bc7097190c874e13/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", size = 203055, upload-time = "2026-04-02T09:25:51.802Z" }, - { url = "https://files.pythonhosted.org/packages/92/2e/7a951d6a08aefb7eb8e1b54cdfb580b1365afdd9dd484dc4bee9e5d8f258/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", size = 232502, upload-time = "2026-04-02T09:25:53.388Z" }, - { url = "https://files.pythonhosted.org/packages/58/d5/abcf2d83bf8e0a1286df55cd0dc1d49af0da4282aa77e986df343e7de124/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", size = 214295, upload-time = "2026-04-02T09:25:54.765Z" }, - { url = "https://files.pythonhosted.org/packages/47/3a/7d4cd7ed54be99973a0dc176032cba5cb1f258082c31fa6df35cff46acfc/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", size = 227145, upload-time = "2026-04-02T09:25:55.904Z" }, - { url = "https://files.pythonhosted.org/packages/1d/98/3a45bf8247889cf28262ebd3d0872edff11565b2a1e3064ccb132db3fbb0/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", size = 218884, upload-time = "2026-04-02T09:25:57.074Z" }, - { url = "https://files.pythonhosted.org/packages/ad/80/2e8b7f8915ed5c9ef13aa828d82738e33888c485b65ebf744d615040c7ea/charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", size = 148343, upload-time = "2026-04-02T09:25:58.199Z" }, - { url = "https://files.pythonhosted.org/packages/35/1b/3b8c8c77184af465ee9ad88b5aea46ea6b2e1f7b9dc9502891e37af21e30/charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", size = 159174, upload-time = "2026-04-02T09:25:59.322Z" }, - { url = "https://files.pythonhosted.org/packages/be/c1/feb40dca40dbb21e0a908801782d9288c64fc8d8e562c2098e9994c8c21b/charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", size = 147805, upload-time = "2026-04-02T09:26:00.756Z" }, - { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, - { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, - { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, - { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, - { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, - { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, - { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, - { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, - { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, - { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, - { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, - { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, - { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, - { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, - { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, - { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, - { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, - { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, - { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, - { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, - { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, - { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, - { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, - { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, - { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, - { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, - { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, - { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, - { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, - { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, - { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, - { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, - { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, - { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, - { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, - { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, - { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, - { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, - { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, - { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, - { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, - { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, - { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, - { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, - { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, - { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, - { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, - { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, - { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, - { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, - { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, - { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, - { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, - { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, - { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, - { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, - { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, - { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, - { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, - { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, - { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, - { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, - { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, - { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, - { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, - { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, - { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, - { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, - { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, - { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, - { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, - { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, - { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, - { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, - { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, - { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, - { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, - { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, - { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, - { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c8/693809898870237d82785a03f3b2b58fe4c9f14669f84a7d4e623c92a59e/charset_normalizer-3.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed2a239c0ea213acc1908150a3037257083c7c083128f1a4cec2ec4b97dca491", size = 367780, upload-time = "2026-09-30T04:35:30.888Z" }, + { url = "https://files.pythonhosted.org/packages/c9/87/2fea8c13dc24b3ca9c6f803a5b2dfdeae73eb4f9e12c7885ed908ff0433c/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b91363207bd9dc966a691e959bb47f64b30f7ac4b072be9968b366982f7db77c", size = 246730, upload-time = "2026-09-30T04:35:32.286Z" }, + { url = "https://files.pythonhosted.org/packages/a8/9e/09efac30b937722f46d3110ba30b875b24b2e3a266ed746cc4e376a94d80/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:38a873987f3be698494da8b2e3085e29da02da7b633dce73e79c699a113d7bf0", size = 237707, upload-time = "2026-09-30T04:35:33.709Z" }, + { url = "https://files.pythonhosted.org/packages/9e/18/70d76670b13686237863a379928d60bd10e021f17d243ab3d7014c4a5f4e/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:355ad8011081dec5412240c087a9a0c9d4d5039f3ed11a3f13e18c2b29b56c51", size = 273050, upload-time = "2026-09-30T04:35:35.138Z" }, + { url = "https://files.pythonhosted.org/packages/54/e2/77a8b09d5adc013ed07b95b01b8b8fa5441c4e810e83ee7e4aae2fa4d91a/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ee21e28f0430bd6dc9086c6e525d5e818a44a5ad19720c8a0ef766792f3eb5e5", size = 270345, upload-time = "2026-09-30T04:35:36.502Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c5/38806a25ab5e65fc178f39affeda20858efafede2fce1ffc2556cfc9fe73/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d31298449090ab8d47b7b1b2a555ff73cac7ed438a08b7ac160980c7ebed649", size = 257601, upload-time = "2026-09-30T04:35:37.919Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8d/213565184708fdb263ae55e2c04ee1ff748129dd65d48ed0e3502da9c85a/charset_normalizer-3.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5cde776b7cc66e4f6c99612cea4aa7269aa65863f7a15841b2c264f103822f4e", size = 252222, upload-time = "2026-09-30T04:35:39.544Z" }, + { url = "https://files.pythonhosted.org/packages/7e/24/76d2cefc25472531e4c5c7dfff68865eb1c39b78482f0fdc15b46f047830/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae4f5fea5b8b8ccff88238cc8569303e5ee95efae67fa62922a311397a71f346", size = 248482, upload-time = "2026-09-30T04:35:41.088Z" }, + { url = "https://files.pythonhosted.org/packages/7d/dc/65a801b66ab4c197e22c433ab25e7ac24324ac6f45a2269aca42cce309bf/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f7d486c83842422badd511868fd8a9a20e9407ace71564b6af47ce7e60a336c1", size = 241206, upload-time = "2026-09-30T04:35:42.59Z" }, + { url = "https://files.pythonhosted.org/packages/a7/95/ca9b5eabde673002c6f1e7ada1b223916fe18f6d661da7aabd4d643718f1/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:11a4d68a6ecda3292cb1e50239e111543ba5d709bb62a6b4ea1afcfa729d8875", size = 273190, upload-time = "2026-09-30T04:35:44.347Z" }, + { url = "https://files.pythonhosted.org/packages/2d/8b/803b4d2a3f6e1740f63f1e87b04d14b42f3d4fdfe6ed7d4db2d34102b14f/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d6734d2ef8a50fbf8445c139477da401f50d62a0606bf00e20ec6d87773fefb1", size = 253527, upload-time = "2026-09-30T04:35:45.915Z" }, + { url = "https://files.pythonhosted.org/packages/a9/55/93c0e5dbd085ae0471346026abbe7e0db9ea2d6fea74e51f0b5a46f233a7/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a815775b6c38d4e0ff7bcffbeba67feded90202bb6a226b8dd35f1c855217413", size = 271285, upload-time = "2026-09-30T04:35:47.49Z" }, + { url = "https://files.pythonhosted.org/packages/95/69/0dbd0e0b9b16cfa816cdfcb3e2e3854a1f680dc07fb1245ea125e7448060/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:23851fb4e1b85ed3f6c2a27b777cdfe2e19fb5b38429a8faf38c7542b7665869", size = 260010, upload-time = "2026-09-30T04:35:48.996Z" }, + { url = "https://files.pythonhosted.org/packages/58/9d/e7b88e7b1bf403590c3b573277b5e1e488c68c7a6fbacca310a2c324e90c/charset_normalizer-3.5.2-cp312-cp312-win32.whl", hash = "sha256:db19d07e2e0129e974a0e65d0064fc222a446cd5122c2fd4184d2af9fc734a9e", size = 184125, upload-time = "2026-09-30T04:35:50.777Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e6/e6e083884cbcfd49c64865af05027fe7011be7b2d9179524f099a1b611f3/charset_normalizer-3.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:780fbe7cab297b81dad9fb8dc5eb003c0468ffb0d9e5f65068c53a34661a96bc", size = 207486, upload-time = "2026-09-30T04:35:52.194Z" }, + { url = "https://files.pythonhosted.org/packages/c4/e3/017aea0911ada7405a825c7d937eb3a13009664e2f5b38e8c4bbf2abf894/charset_normalizer-3.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:e2af3aad578aa6bd1384bcf4750fc285e5a9de53f40b7d41e5a0bf748edeb2b3", size = 196734, upload-time = "2026-09-30T04:35:53.636Z" }, + { url = "https://files.pythonhosted.org/packages/c5/34/68292d68512768591aaff07c59bb53ee31341c87759433a859c4641a50c2/charset_normalizer-3.5.2-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:ed905975ab14056a2e5eb1c376cb2e1ebc5396baf84163939c518556fccde9f5", size = 225946, upload-time = "2026-09-30T04:35:55.313Z" }, + { url = "https://files.pythonhosted.org/packages/e3/80/bee0b01b90ccd5322ae1d0abb33fab1bd95b7c2eadaf02aeccf22e04ee83/charset_normalizer-3.5.2-cp313-cp313-android_24_x86_64.whl", hash = "sha256:a66c3bc5ab1f0ff2164fc9965ddd611ff0802173f4b9d24554c563f6ab7e1d6e", size = 238619, upload-time = "2026-09-30T04:35:56.863Z" }, + { url = "https://files.pythonhosted.org/packages/78/6e/60ce52a85a7fd631ae8482ae6d74521014ca2f255892679484dc04d7ef56/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2374b62878abb00cd8309b32af6c0b715cd02dec0ca74ef12e5069bdc64144a", size = 206701, upload-time = "2026-09-30T04:35:58.639Z" }, + { url = "https://files.pythonhosted.org/packages/36/8c/71aafad23f971afc84c2b295bc0c560739ce1dac558aad9fec22e39f3639/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d376bbd28b3a8999db1a103b3b388aee6f1ddeb3e51bc2172993efdcd86e064d", size = 210506, upload-time = "2026-09-30T04:36:00.147Z" }, + { url = "https://files.pythonhosted.org/packages/91/da/3c5a7798c046df7d2d68ad653cf5b6c5a8bfee225055a843c6f2f42aac1a/charset_normalizer-3.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6045373d5a89a5ec71afde535db987ca28e76dfa276c2d4c818265b375d4b055", size = 366626, upload-time = "2026-09-30T04:36:01.77Z" }, + { url = "https://files.pythonhosted.org/packages/e1/16/710ac3de2ee354e2bd1a9c94efe45a2d27b5c6ad39b2d6a905be2c094b6c/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:849df64e889b2e17230d58410a03dba311a65b163508fd33679b2b737d4b7858", size = 244624, upload-time = "2026-09-30T04:36:03.389Z" }, + { url = "https://files.pythonhosted.org/packages/d6/39/45c7439f5b63d24f7d5b2a1d760f34af7628782d7144b4cc8ded45c2d4bc/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:15c44f7edfd477b06f517a5cc317fc1707edb9de2c865f43d4b6513907473234", size = 235731, upload-time = "2026-09-30T04:36:04.987Z" }, + { url = "https://files.pythonhosted.org/packages/4d/34/38f3154785ce92e9f56eb226f4d35bdfae6b008480dd055f58837a89c810/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a89012d6d5476ee112d20d998570ed58df2260a852afb1758809cd6900411d21", size = 270352, upload-time = "2026-09-30T04:36:06.412Z" }, + { url = "https://files.pythonhosted.org/packages/04/f3/859f74e7babc977705026b30593b3be04049632a522fb7000f83c033d747/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0c951d5e6dd9c2ff60609476752bee49da4206adde960ebc247766937f72e718", size = 267393, upload-time = "2026-09-30T04:36:07.865Z" }, + { url = "https://files.pythonhosted.org/packages/4b/85/41d27f234b82e47c167a5f6c0f62501dc0c640585ff4aba79e08a390336a/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7218e8f32b0956cfcd048fd42d9d5779809745ca1d86113ca56f66e7ae1549c4", size = 254660, upload-time = "2026-09-30T04:36:09.248Z" }, + { url = "https://files.pythonhosted.org/packages/58/ca/5d1a997587febe5b26d8daffe363b5c1a091cece19828eec6502fd09c5ef/charset_normalizer-3.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19a731138fc27d5682277d3b9df22855cea1239bce7fcec5f78f42ef2d1f3c3", size = 249996, upload-time = "2026-09-30T04:36:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1f/d1e78246f7ed60c8c8d606b4ac27f66ce49cc3e95f24893ccbeba9f77302/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:62603db9a7caa0802eaa28c1c46fecd7b3a263a774069c24c3c28c302448721c", size = 247028, upload-time = "2026-09-30T04:36:12.294Z" }, + { url = "https://files.pythonhosted.org/packages/8e/37/eba316edd4f0c4d3a5d945924c4eeeae59abac4056aa815d8a4268f863a2/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:b6856554c4f44d79fc2307d5768854310a8f0096e501c75637542c82292b0429", size = 240562, upload-time = "2026-09-30T04:36:13.887Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8e/aaa037d40ca9ef045977f1a661048b1aa33f223adfce3452fe9be9f79d14/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1bc0baf5ef96b6ede57d47f4b8fe4d9d84019c3bfcbeb20a41edc6a6ee341f1f", size = 271699, upload-time = "2026-09-30T04:36:15.41Z" }, + { url = "https://files.pythonhosted.org/packages/26/19/1c1c9f75974adf523b87f34b8a2adc5a435cd65916812bcbd0dfa45f9a29/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:56bc200a365efb37383b7852e4cc5898d3b2da5987289b543956cf8cad71018a", size = 252030, upload-time = "2026-09-30T04:36:16.839Z" }, + { url = "https://files.pythonhosted.org/packages/bc/90/0660ef18e18df0a4d2a1a0edff7dfbba42d4e50ef2425557a5bb7051f77b/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2c9ad19a6cfcd5ea5c0d41161d22f9df1dcc277e9bef2751391334546a314c00", size = 268565, upload-time = "2026-09-30T04:36:18.468Z" }, + { url = "https://files.pythonhosted.org/packages/79/ba/57adc269824e8658f1a0f97a9e514c247445a9632b3419b97e0ba37f16dc/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e243bd13217235fc7290c621941c3f5cc8b66e4872495be821d7436ba2fb838d", size = 257627, upload-time = "2026-09-30T04:36:19.938Z" }, + { url = "https://files.pythonhosted.org/packages/9a/85/33abd4315c052d3d4f54c92b1ee49bfbc0dc7115a981e462a793b6d2ab87/charset_normalizer-3.5.2-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:a090bb2c68df85450502e3e20d665e3a5af9c65a84d6508ed477badd49166fd3", size = 143407, upload-time = "2026-09-30T04:36:21.376Z" }, + { url = "https://files.pythonhosted.org/packages/4f/de/6435e18d1aaa5d910b896d551411c96af1f42a0c56c29afc2016c61ccc2e/charset_normalizer-3.5.2-cp313-cp313-win32.whl", hash = "sha256:2b7b3bbfb4fe8ef40600792d762fbaa9057559f9d3fad209525b7a22b99e91fd", size = 183207, upload-time = "2026-09-30T04:36:22.776Z" }, + { url = "https://files.pythonhosted.org/packages/9c/76/b8ec57f4e9ee3253541abf95e4a462c0175fe8032dcd070f1f2421240942/charset_normalizer-3.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:78456a747de8dc58360ffa581f30a002baf5aa28cb262536545e91f113ed7639", size = 206489, upload-time = "2026-09-30T04:36:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/3e/60/c647c6ae47480221e875ea5d743ff94946f7416e3c69415ab772928e8d32/charset_normalizer-3.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:11912e4bb14baae7c5d8791aa55ba0a3a03ec6729073307b0f57270abaa713d3", size = 195501, upload-time = "2026-09-30T04:36:25.846Z" }, + { url = "https://files.pythonhosted.org/packages/58/ca/7aa91362a2f77ac8e9e28a9b902a74f7d0e11a851ef0d27a74308da8cd90/charset_normalizer-3.5.2-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:1afb975bd5d68d5ce9f6b6d44fdf2f7e34b895a35e95708a7a91b20a3b51d187", size = 226010, upload-time = "2026-09-30T04:36:27.669Z" }, + { url = "https://files.pythonhosted.org/packages/a8/cf/ac8878d0322cf88a1aad4c7b147db32ca0bd806eb0060957b2e31486dbe6/charset_normalizer-3.5.2-cp314-cp314-android_24_x86_64.whl", hash = "sha256:bbbfc8e28816f19d7c0f1816664980c0a9875d01b27cdf8eedddb639d9e108ad", size = 239266, upload-time = "2026-09-30T04:36:29.434Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6d/9a08d7e0b29b7208e2c6c01dc56c8e0520e7c7beadbbfb024b58fd69c8a5/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7967d08cf06dee78443b874f98c98036f624f3a4e73e11f9f64f5be4d25393cf", size = 206783, upload-time = "2026-09-30T04:36:30.872Z" }, + { url = "https://files.pythonhosted.org/packages/82/44/b0aa350280e6ff5a5492d17cf10460dd39d5ee848f872f7ba2df10607f60/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c2b5031f63e331e3839b40aed2dd6f191e9c07edbde303e7876846ea1946995", size = 210449, upload-time = "2026-09-30T04:36:32.625Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/40db9aa9f5907bb0e6f8b6d64064bf8852fb33d4b813ff9414911df7647c/charset_normalizer-3.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fcff63213e8e6e47770541a4607175404f47cbb3ebea7b6058cc82d524a0e424", size = 367815, upload-time = "2026-09-30T04:36:34.197Z" }, + { url = "https://files.pythonhosted.org/packages/7f/72/9c5e7707b57c8ddfa9ddf7b0b1d009d7fbab9e9e887d5b721060f37e307d/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8d86d6fc60743dc916eb79e2eb1ec4818e21e427731543af40a3021851174a13", size = 247095, upload-time = "2026-09-30T04:36:35.803Z" }, + { url = "https://files.pythonhosted.org/packages/83/09/71e453691e927de4ddf792770cfaab3f49d494e222f66ea5e404bbd5e39c/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a881931aa470808df94a8c380eed2bbbc76cd9dc622310f99665658c821eb6d", size = 233942, upload-time = "2026-09-30T04:36:37.407Z" }, + { url = "https://files.pythonhosted.org/packages/9f/86/85c84e4da8b27dd409577d9437926ff581c5f9d3c66038dc68c1a526de51/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8024d00c3faf3fc0c16e07a69f4405e8eac7cc0ab15f65fe6cf43827c4cf72b4", size = 273762, upload-time = "2026-09-30T04:36:38.904Z" }, + { url = "https://files.pythonhosted.org/packages/92/08/564955a4b5f2ccb410ab480bbe8c6a18063ff27f2d35458731c4a5335df9/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4d48f2d08b9de5864e2c8744d4461b862fb149a18274abc8b698c45975573438", size = 269514, upload-time = "2026-09-30T04:36:40.469Z" }, + { url = "https://files.pythonhosted.org/packages/18/24/bad3ac4271589df29cf5ce2f5ae490518a5739358052bd0d61209e6fea54/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34276fd796040bf0993ab33a369aa572e6979c7aab225a88893667ad8eac8f7a", size = 255056, upload-time = "2026-09-30T04:36:42.02Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3e/350d89ad49916b86554d6f5f2d03ec1152148f87e5ff735106c6a03b1a36/charset_normalizer-3.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0521c5665880b33d603717defa76c094048900010897909952397feb3039da56", size = 251311, upload-time = "2026-09-30T04:36:43.577Z" }, + { url = "https://files.pythonhosted.org/packages/56/5b/4970a2d154df502e133402906dd04e3ae7cada7b3011283c88d0479a2585/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:eff0ac9dbe711a4aee69bf04a83896aa9b85f19641264053a9f6d48573abb7dd", size = 250190, upload-time = "2026-09-30T04:36:45.185Z" }, + { url = "https://files.pythonhosted.org/packages/88/8c/f1a91bddc8fb47c2889e29ea7ea49a194eb0d9868675d786806519c00d76/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1503bccbeb36d5527790c3930327704c39af22de3112f1b1666a9f3ce15ee204", size = 237098, upload-time = "2026-09-30T04:36:46.689Z" }, + { url = "https://files.pythonhosted.org/packages/24/0e/bb5dace3cc7e79068425386a6589c19b5a2ab5fefc2a46abea6919683332/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:52aa6992700996af31f375de0c6bacd402b0097fe40b53c426b9f51a90ebabc7", size = 274228, upload-time = "2026-09-30T04:36:48.31Z" }, + { url = "https://files.pythonhosted.org/packages/9d/79/b849ad523017ea9f5a45581bbebed91439e0cf42fd2860a6f64e358eb5a6/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e09a3942ecbdee5cce73ea9d42da82b81b72ac1bf031ce069b93b5adf4eac8cd", size = 252401, upload-time = "2026-09-30T04:36:50.091Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/75469d690cf47200bce8f6cad7655724fc23148e147abfc5ce78b5f65863/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c7c9ab723cde841fefb34efbad91e87f00a674b1fe1cd0784fde742bf2c154dc", size = 271846, upload-time = "2026-09-30T04:36:51.719Z" }, + { url = "https://files.pythonhosted.org/packages/26/cd/6d52d3c7437cdcf2e310ce9f28f282e733d4ef60ed19105d1819c356255f/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ddc7dacc8ece3a182e7f15cb862d1fd616b46d076cb1ae9dd232b2c38b655874", size = 258151, upload-time = "2026-09-30T04:36:53.234Z" }, + { url = "https://files.pythonhosted.org/packages/f7/4c/070b38bdb5f49a70199fce923ec0726a49536a63ab262abbfcaaf351110b/charset_normalizer-3.5.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:ee43c17b173d46a3212baa6ead3ae258eeabdae48c263a01ccf0218c366dd655", size = 143570, upload-time = "2026-09-30T04:36:54.816Z" }, + { url = "https://files.pythonhosted.org/packages/81/84/9ebfc8ed6c8c4fcd8e726ff6bf220cc8deb3966e31dce9be8dd8aa017e64/charset_normalizer-3.5.2-cp314-cp314-win32.whl", hash = "sha256:4f87960d57feabfb618e4e0af6e7371645fa26a277860739d6e5d6e0012c92f0", size = 186412, upload-time = "2026-09-30T04:36:56.643Z" }, + { url = "https://files.pythonhosted.org/packages/d1/78/5ed86f743d4bc350db307e7636419a0a5ee1d91806d30c7f667bd5c80dae/charset_normalizer-3.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:e4e81e09c1578b8df602e3db08b0b3ea0a6947ad612f52bf8dc5ea8d47691f0c", size = 210965, upload-time = "2026-09-30T04:36:58.205Z" }, + { url = "https://files.pythonhosted.org/packages/53/94/a3a7698e9b1a395e1eb99ccd9a324be9347973bff4e72db2a06496d7cd27/charset_normalizer-3.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:80d02b6f04e92601a081dd97b23d3128033098bff5d35d392ddcc0476ea11253", size = 200406, upload-time = "2026-09-30T04:36:59.764Z" }, + { url = "https://files.pythonhosted.org/packages/c1/48/c5dd00d5ef7791f02666de250a5bb6071e29b7e133cf4b835800b6d3bc27/charset_normalizer-3.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:dca9ab98072a5a54ebacebdc45f53e645336b320c667410b061be1ca588ae709", size = 396871, upload-time = "2026-09-30T04:37:01.543Z" }, + { url = "https://files.pythonhosted.org/packages/12/c8/8379554b42e8368161d898476686947a0fdbd3e8865170d7909dcabfdee8/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0aa869112ef88429ae17820d99c3dd9504c9e9c671d3c246f3d7442cb051084", size = 249711, upload-time = "2026-09-30T04:37:03.111Z" }, + { url = "https://files.pythonhosted.org/packages/4a/eb/2ddb1035d17320caa9f41682935123a9a250277b261c3efc86b2d2a21343/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c0afc6800ba57ccc350374c5bd6150419915d95ce93cdbab2d783d75eaf30ecb", size = 231146, upload-time = "2026-09-30T04:37:04.721Z" }, + { url = "https://files.pythonhosted.org/packages/4a/24/2ecb4bde104322cd7859d6594fcfa74649f8d90b3221c9feecbef149875b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dcd882da75ef9adf94903b1e3b9419e8aa8fb4c7396822b834b9ef7fb96954f", size = 271552, upload-time = "2026-09-30T04:37:06.295Z" }, + { url = "https://files.pythonhosted.org/packages/3f/98/9d5f6ebc3aee9fef5d30b4aff11fb2ab7a1222b4064f8ef2c7c87cde217a/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2e06a3a98f916dd41d27f3105e02e7a40181c98c94b9158733d03a6f80506c09", size = 269810, upload-time = "2026-09-30T04:37:07.905Z" }, + { url = "https://files.pythonhosted.org/packages/09/e1/a3b06a10461b1b7628853c934c644e03bc28e42767116afb52f19a56519b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bd128f206a7752ae1f2ab6c61bf8a24ba28913a10df8b14c2637b973ff97a80", size = 259845, upload-time = "2026-09-30T04:37:09.554Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d3/6f561f74a296cf27d61775a1dc665ad13f3bff6a798810ca05907f37a7c4/charset_normalizer-3.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c8f3d67aeaf55f017982b73683f0e7342ba2f6635a78f69ce89ebb26aa411e5c", size = 252825, upload-time = "2026-09-30T04:37:11.274Z" }, + { url = "https://files.pythonhosted.org/packages/26/9f/69e13ca3b18f43e0eafcd34c04a45b732ae22a43b54a5fc9e119103356eb/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fe9753dfee015c570d73df76f899f18444d41388bffcde097deba51c4fadbb9f", size = 252659, upload-time = "2026-09-30T04:37:12.941Z" }, + { url = "https://files.pythonhosted.org/packages/73/a9/ace29806a0dae18939919c76ba526472d83214afa101105fabff2cf30625/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:92888bb3187c5ba50500b00b3b310c9f2c651709d28036077680cb5255450a03", size = 238819, upload-time = "2026-09-30T04:37:14.659Z" }, + { url = "https://files.pythonhosted.org/packages/f8/c1/6116d52a2e3311ec80f21f5fb5e17b27405f10b9608af8f6e69516841a1b/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d008d90a7f2471519aef0c90dfbe73b3e6e4d5e66ac48e19154c17e89e98b604", size = 272973, upload-time = "2026-09-30T04:37:16.346Z" }, + { url = "https://files.pythonhosted.org/packages/19/aa/9955c7e93bba10a9c7e8f7a5031b7ced66f3a1883a55c00712b8d5850ff3/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31f3930700408d211f13378ccbe1c40845d8da54bd0681fac3a9b5aae81c7aa8", size = 253692, upload-time = "2026-09-30T04:37:18.212Z" }, + { url = "https://files.pythonhosted.org/packages/bb/33/2a6ae7fdc1b10cb581cef91addd8cdfc5f40d50abb5702309369d5834579/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:2a925889534b3748302dae5dead07cc13480de1dac3aea80a941b729b471ef93", size = 268757, upload-time = "2026-09-30T04:37:19.877Z" }, + { url = "https://files.pythonhosted.org/packages/a2/22/80992720a0282cd39bba1db35868e6b9c22f41281160143a836544bc1d8a/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f5ec61164adcec446f8969a3358ec3f9b26bbda3b9213e5586d219afa8df2915", size = 262220, upload-time = "2026-09-30T04:37:21.583Z" }, + { url = "https://files.pythonhosted.org/packages/92/9f/181fd07e1bffea1d95cd80c84ac537354f50699c22cfc4d3c02b6fc16208/charset_normalizer-3.5.2-cp314-cp314t-win32.whl", hash = "sha256:598a11a2c7ebaa5334bf698bf29568c9c390abac6a154d8170fedecd1cea38c5", size = 197797, upload-time = "2026-09-30T04:37:23.235Z" }, + { url = "https://files.pythonhosted.org/packages/49/1c/25d8415ec1c4f2f41f1680435e4c87cfb378ff2f677d950946f2a45d0632/charset_normalizer-3.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:7fdde2c9fd9e3eca40631e024664cf2584272cc8f96308cbe5fdfc930f51d8bc", size = 222765, upload-time = "2026-09-30T04:37:24.891Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b4/46b48f013dadfc0d0d33b375438e31bdf5a989dc68389c6bf627054d4df9/charset_normalizer-3.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d1befeed746d247c81127bb14de9dc3d30edb6e5976d34f83f86ed262b1d9105", size = 210985, upload-time = "2026-09-30T04:37:26.634Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e9/34e597dee616d0b8ee4b34d29399e85c2204ade174157a48505d42baa4ff/charset_normalizer-3.5.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:87475fabc8d9996fd9c27debb395e642e8c838d78a00b6e932227a0e06b81e26", size = 367676, upload-time = "2026-09-30T04:37:28.329Z" }, + { url = "https://files.pythonhosted.org/packages/60/9f/a5d1c91c0263745e2cd344c5a4415d787c575501ab1d449f1148ac6b495d/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9409a8bf35cf78353942504b24a57de3d75b708997a1e4bd8db71ac8633ce364", size = 248414, upload-time = "2026-09-30T04:37:30.167Z" }, + { url = "https://files.pythonhosted.org/packages/26/79/e697f77464748a3ee3cf490c83d592459400d4898380d66c38366b03080c/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:498dc3188ca05a68231ac3fdbfc7f57eb67e1343c30e0fea17f8218c1599b253", size = 244775, upload-time = "2026-09-30T04:37:31.964Z" }, + { url = "https://files.pythonhosted.org/packages/ca/87/3d42a42e18ea066e2513936fd678a00696e77878b5ae04528976abdbcb83/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e242bb1c5e76e97dfa9e7f209a71e93a01d7f19ffdd5cfbb2e2d55b4f08f8ab0", size = 273877, upload-time = "2026-09-30T04:37:33.661Z" }, + { url = "https://files.pythonhosted.org/packages/c3/76/8a28136f3938ba9836f84280ce0c4d61ed1cf15a036b2034900c62634162/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:def79fa35ef0cef8d2accec024f4fdc7ead3012ff02f5215c783f39f03ef8cfc", size = 269455, upload-time = "2026-09-30T04:37:35.573Z" }, + { url = "https://files.pythonhosted.org/packages/a0/a1/4fbf5d0f0f1b2a080474c1cf9a2f12c4c6531bb0e8ba591055e846d2b4e9/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df041de8887954562c9b261cba85ca0e9ded74048daf125f45edcfaa4832229", size = 257336, upload-time = "2026-09-30T04:37:37.397Z" }, + { url = "https://files.pythonhosted.org/packages/ba/a2/8b50aa320adb880ad579518e6f718f24944804b42a88b83d267d5d444125/charset_normalizer-3.5.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04851f73ae72b8413dddadb16a49dfee95263553741fd42d546f7d66907e6be5", size = 257584, upload-time = "2026-09-30T04:37:39.522Z" }, + { url = "https://files.pythonhosted.org/packages/a5/57/50e3fed84e175f40349bd0da7a4fce94c87f0378f52d74f511d89e0bdc20/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:183b88127acdb4fabe59d951ab424faf1af7b63cdbb5f776186c1ea2ffcaed98", size = 251854, upload-time = "2026-09-30T04:37:41.23Z" }, + { url = "https://files.pythonhosted.org/packages/d6/54/f7fbb3493c9f49091213b9c2d6dd65800696f1ce1a3f196a4205f50417b1/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:16fa0eccf81304b79c5cd87f9271c3b85dd9dd99245e4422ae9c0dd45e0f99d3", size = 248767, upload-time = "2026-09-30T04:37:42.883Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/b3a6385acc5a1e45b39ae9c90bfb9cf838a09b9dd37ef2740ab4c6b4a2eb/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:7441d755b7ab94f8d4eb3e43ec05482d760842fd263d003a99102d742cd835e2", size = 274479, upload-time = "2026-09-30T04:37:44.658Z" }, + { url = "https://files.pythonhosted.org/packages/89/44/809913e2cfd279e635a9294fdbbfb1b1dc62a8189d473d561f649fce98d8/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:ca403d7e4798f525fdfc78e258820419cbbd0f0ecbab9de7840e3c017cf6b8cf", size = 258310, upload-time = "2026-09-30T04:37:46.529Z" }, + { url = "https://files.pythonhosted.org/packages/af/a2/f28400ab13359d91bd39179df8e149376b9bf36588e739a3a4f9de2b84b2/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:df29a0a7107f7011e77f4eebdddec4c7331e24d787a0b21a46d63bdf7445da95", size = 271100, upload-time = "2026-09-30T04:37:48.399Z" }, + { url = "https://files.pythonhosted.org/packages/e9/89/9bab37955edf0adb3b66f8a3a6617d9f2f487e0d56f295a6a286cb640aa6/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f3c96f633825733f735c5a9cf21d21a257d8e1edf0b1cee0a064b9c424ca0f7d", size = 259936, upload-time = "2026-09-30T04:37:50.023Z" }, + { url = "https://files.pythonhosted.org/packages/23/b5/4459e08d45a679f903d50fea08bc52cfa728cca4d7bd02c757b5e5abda2e/charset_normalizer-3.5.2-cp315-cp315-win32.whl", hash = "sha256:281cb91036248400f4cc957495cccd44c275c2e0c5854f7e45ac5cf7dc193847", size = 186301, upload-time = "2026-09-30T04:37:51.722Z" }, + { url = "https://files.pythonhosted.org/packages/98/e8/55d5fd3935b4bce6da4fe0df61898e8c82653e317e677bd58aceb9c60f13/charset_normalizer-3.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:89b53f3cda69831909888e0494f4fa0bcd3537e3e138dabeb620bd6ad946bae8", size = 210858, upload-time = "2026-09-30T04:37:53.427Z" }, + { url = "https://files.pythonhosted.org/packages/a9/5b/974423c2fd8e524c7a7f64318c1e02240ef954912fa2b4d70344107b9c68/charset_normalizer-3.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:6be488a102b8cf28d0391d8c4ba7748938ae28b78ad901f8585520fca33ead1a", size = 200309, upload-time = "2026-09-30T04:37:55.015Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f9/00ee0195db1013d8f7c416fd770fbeb560bb46eb2e36b054d05cb56f6cfa/charset_normalizer-3.5.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:915563965d418f986e7e145accc592eae9e1a1be3566ff98a05d7a9ec42a76e1", size = 395343, upload-time = "2026-09-30T04:37:56.743Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/c00b50e94c964cf934c7899cd47c97952fc11dad71cc5884b3c61795b09b/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65cd72beeeca9d3aaea1201e5923859f308f952f9c71de93f06063c79f0f7a3b", size = 250114, upload-time = "2026-09-30T04:37:58.607Z" }, + { url = "https://files.pythonhosted.org/packages/50/27/d102dc880bbcffd0479ab64dfc1fb96777a854355a55e2bda72a71efadcb/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b7fd005a73d9e657273b7a10dc71a9e03c8fb9ee6999798d6918ce095b81ac7f", size = 239997, upload-time = "2026-09-30T04:38:00.511Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4a/bf7ef45794dd293fab5f98a9309817977fbb845b9998f171b8cc5d8437a3/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e54da4baf05720032d527874d40b65fa4d7e5c6c6a43d0c3adbeffcaf275a2b3", size = 272313, upload-time = "2026-09-30T04:38:02.509Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ee/008a2837737991474c5754bb3191010007663860979701990982a502cbaf/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:124fbf1a8ff966d87ae05bb8bd45a71f966055ed8bba320d0c7cf450bc5f4d0e", size = 269986, upload-time = "2026-09-30T04:38:04.435Z" }, + { url = "https://files.pythonhosted.org/packages/93/ad/bd74a283940dc910c5b14f8e4f80a248082bc9c0fcbe1f54530cb6d9cc5e/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28b4f0d66fb834ff90f28209ac7bce77868c45d8c93e26f906709d9b7c2e1af9", size = 260202, upload-time = "2026-09-30T04:38:06.549Z" }, + { url = "https://files.pythonhosted.org/packages/8a/7b/ed341c66f69f688723501fac752be3d63c7159ca0d0d4174fc611e5710bb/charset_normalizer-3.5.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58ca3755ee7ff7f59b57789ec9833c9de9ea275405cdd240eda1f193112e398a", size = 259083, upload-time = "2026-09-30T04:38:08.311Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9d/e41588b777965e5031a43128a1e96173ebb35ac75fc53ec3b517e7c21cd4/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:443eae2bf318abeaf6f15d785138f71fd6de770e99a92158b8b814265e079115", size = 253081, upload-time = "2026-09-30T04:38:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/81/35/b761eb6d8c1eb218b9b42b9b4d5ac902afdc399fb6dac6f9a9aac7bda589/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:58f361dcbab699cf8f42db3f47c8e7fd1036f138c23a5d08de9fde5f425a730c", size = 248712, upload-time = "2026-09-30T04:38:12.317Z" }, + { url = "https://files.pythonhosted.org/packages/4d/2c/147169a041b747759f37405c0a97157e8e92de967968373101ff14915cba/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1b4cbc7c3491ccb4aa17fcd8165649d01cf39f76de1696da8631b5f71b85401d", size = 274176, upload-time = "2026-09-30T04:38:14.138Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2d/0ff8db0d373ba8538db686db11cd7e8912031490b9e4f383b41912e8d594/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:ba0b1d2620edf869789c3879223f52bf2afc5d31b3cb47cc57b3a12c05e2aa9d", size = 259413, upload-time = "2026-09-30T04:38:15.841Z" }, + { url = "https://files.pythonhosted.org/packages/8a/8e/b4a085fb47c9d3a7e43576a4784fdd8fe23f907514a972de8086edaf7a48/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:5e2b6b57e9733d39f0c9fd3185efa6b8e29652c4cd8fe94180272cf6ed9a78c4", size = 269434, upload-time = "2026-09-30T04:38:17.626Z" }, + { url = "https://files.pythonhosted.org/packages/83/1c/d8d8d7322a7c3eecdf3237a4a419cf41d2eaad8e006ce7dfdd9d4c8fa2eb/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:51cf45226a9b588d0d2b4880c62d686934b63ab0bd79ca23ab0e9762eb27441b", size = 262716, upload-time = "2026-09-30T04:38:19.214Z" }, + { url = "https://files.pythonhosted.org/packages/a0/16/0e4c6ba9b44e97a2da150e52d331e8f9c968b21b358fbffa6c856cebcd89/charset_normalizer-3.5.2-cp315-cp315t-win32.whl", hash = "sha256:5fb29fb8cd1a46c27a1bf9613ad5ec2599310d46b4025d9556404a6b6a292800", size = 197159, upload-time = "2026-09-30T04:38:21.037Z" }, + { url = "https://files.pythonhosted.org/packages/be/33/e90bc2b1374f7f36ef106f56620de5a783907e19ca857efe2277e31cac3e/charset_normalizer-3.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:a192e2c40070d92c3ccf777e3a5c4ff515573cd2bb7ed0c537fdadbbec5bbf21", size = 221322, upload-time = "2026-09-30T04:38:22.886Z" }, + { url = "https://files.pythonhosted.org/packages/66/89/dfa6dcb08c200b7830ab56439e8c1890f2971d51aafbb3937894a2e7fcfc/charset_normalizer-3.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:749e97e1b32313717a565abbe321bc2190bc8b35f1a67e4cdbc7c56c8d8ffe58", size = 209192, upload-time = "2026-09-30T04:38:24.648Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ab/176fbfd5b64939c55d652366aa5b9ef1d767af207a3aa6ebeb0d226c484d/charset_normalizer-3.5.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd", size = 331815, upload-time = "2026-09-30T04:38:26.216Z" }, + { url = "https://files.pythonhosted.org/packages/7e/84/371eac6b30bdbcbf2d632a1a01809103459216fcaae61b8b8d922c1bfb8a/charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7", size = 253276, upload-time = "2026-09-30T04:38:28.032Z" }, + { url = "https://files.pythonhosted.org/packages/43/6f/c4fbae58febff71709c51bc7e18fdfa55341dc382704740f9f0cbf03817b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f", size = 241239, upload-time = "2026-09-30T04:38:29.732Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/458c3f42164a07d0c5210798e9e704b39e540a6793b05aba67f3a35243a9/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93", size = 231121, upload-time = "2026-09-30T04:38:31.462Z" }, + { url = "https://files.pythonhosted.org/packages/09/54/ab9e89367076f6331bb6c65c4bf14a5361fa5191cb6561bf534f18504e1b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade", size = 260350, upload-time = "2026-09-30T04:38:33.239Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c1/061431ecc688d9d76602502cb57cc01e691e682c18f1beb45f9673b5bbd2/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0", size = 255430, upload-time = "2026-09-30T04:38:34.865Z" }, + { url = "https://files.pythonhosted.org/packages/8d/1f/20c8949f0676f7ab811abdeb7f4d7f1cbc6e61ff20bef08b44edeb092bc8/charset_normalizer-3.5.2-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26", size = 250612, upload-time = "2026-09-30T04:38:36.649Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9e/46f2fa4c431fc98c4ae76a8cb5bdca54e0341e3cfc3fcfd8e82740250818/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011", size = 242083, upload-time = "2026-09-30T04:38:38.26Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/559be29a0c0f086e0bba6922babd38916cc5e0b58ced4de13ee01ea05508/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621", size = 232738, upload-time = "2026-09-30T04:38:39.81Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6c/387b0e4f756a282831c1d9fc6aeb6c51ca4507ca202767c8de15ce9b12e2/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4", size = 260703, upload-time = "2026-09-30T04:38:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/96/92/1fdf015f09ef449f50d3ac4b67c90887c9c318b727daa95cc4f866e6521d/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e", size = 247622, upload-time = "2026-09-30T04:38:42.937Z" }, + { url = "https://files.pythonhosted.org/packages/dc/3c/8e7b8a5671ad5d433669fb2a76f1a0164df2d9b1718b0206bc2a16d840cc/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c", size = 257500, upload-time = "2026-09-30T04:38:44.604Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f0/45b579df5cabc1d5d53ea1cc35e8437d3ca768c0acccc7041517cb6fbb32/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0", size = 255100, upload-time = "2026-09-30T04:38:46.289Z" }, + { url = "https://files.pythonhosted.org/packages/31/68/fdec18a343f5fb3f310588dd478b09ac4799e0b187dbade3a8cd776f03ef/charset_normalizer-3.5.2-cp37-abi3-win32.whl", hash = "sha256:01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf", size = 174499, upload-time = "2026-09-30T04:38:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8a/b618149cc5207943a0242068d7a27897f56a62947b5a039085f2a22029f8/charset_normalizer-3.5.2-cp37-abi3-win_amd64.whl", hash = "sha256:9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036", size = 200092, upload-time = "2026-09-30T04:38:49.707Z" }, + { url = "https://files.pythonhosted.org/packages/03/cf/4c66866fa9e2b1c78e3c911516d1de497a677b7ac60f1eceda74ce777ca3/charset_normalizer-3.5.2-cp37-abi3-win_arm64.whl", hash = "sha256:40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e", size = 294363, upload-time = "2026-09-30T04:38:51.312Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ad/d07d7862a62ffa6d79d68074d14823243dd235a77c45262acbf6adeb28bf/charset_normalizer-3.5.2-py3-none-any.whl", hash = "sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685", size = 68872, upload-time = "2026-09-30T04:39:21.828Z" }, ] [[package]] @@ -238,141 +288,174 @@ wheels = [ [[package]] name = "cryptography" -version = "48.0.1" +version = "50.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/12/45/870e7f4bef50e5f53b9f51d4428aee5290eedf58ba443f16b1ebb7ab8e66/cryptography-48.0.1.tar.gz", hash = "sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a", size = 832989, upload-time = "2026-06-09T22:32:31.8Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/af/182eb91b0df3fe75c4d9f26fe70684569566745f6ba7e5c9c73a862c5252/cryptography-50.0.2.tar.gz", hash = "sha256:7b46165bb56eb4704e2eaaf86f3c940d19154535d9b0ca7d6d590b04060e00d5", size = 880623, upload-time = "2026-09-30T15:30:04.884Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/56/d194340cc4a57535e82e1bee9e89667ac4b7c13b5d3f59686deae3094dd5/cryptography-50.0.2-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:fa8f5efb344d6908a1ce62f4a24e2e5780f825d6f53f5f50ec5ffacac72936cb", size = 3914904, upload-time = "2026-09-30T14:43:44.339Z" }, + { url = "https://files.pythonhosted.org/packages/d9/69/c9bd862c3bf43d6399c433caf002df16e2dffd4be49bdf515cda38038711/cryptography-50.0.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:79def8d059362e7831389ed3be0ecdf58a89386e1271e35dd9f5af84e81bffd0", size = 4731146, upload-time = "2026-09-30T14:43:47.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/69/64cef1f702bf6657e0cc186ed1a2891d50d29fb41586b254e1c07adea261/cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:630ebfea3bf689d075f82316324ff7433dc447fe6bc1bfc76524b74b4a9567d2", size = 4719841, upload-time = "2026-09-30T14:43:49.01Z" }, + { url = "https://files.pythonhosted.org/packages/38/6b/61a3f8d8c5e1e49a6cddccafc4015cc1c0021360ab0acb4080e7a423644a/cryptography-50.0.2-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f9f6143a8c75945eb960d9eb98905a441394abfa24afaae239d514ffb2586480", size = 4738340, upload-time = "2026-09-30T14:43:50.932Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2e/7212ca32fd43dc91f2f41db20160b268098874b4c9a0e7be94d6835f5b2e/cryptography-50.0.2-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a582ab2ae1d34f67112cadc86702774c9ea4374df6bca6afe672817203c99134", size = 5367029, upload-time = "2026-09-30T14:43:52.911Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f1/b474e930c4d910328780e3940da76f5aa5cbc48ce1fc14e44d239d9ea9db/cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4061c0079120205fb760c58acab6443e217307dcf05e3702cf970e0689972856", size = 4753050, upload-time = "2026-09-30T14:43:55.272Z" }, + { url = "https://files.pythonhosted.org/packages/7c/52/9af10e80ac16b0fcc2123f9cbd5e7afbd0fd5075bb7a607c592258a39cda/cryptography-50.0.2-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ac9ed99d81760c62fe89d5f0815cdfa1ba9a35141cf30f1c2d044f04b4803d2e", size = 4376724, upload-time = "2026-09-30T14:43:57.24Z" }, + { url = "https://files.pythonhosted.org/packages/71/37/6202e488cc1eb625ea110c292c6bda92823176e023f427d8d5660ce8d632/cryptography-50.0.2-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:87e9ce85beb6b328ba370cc6e6aea483c92617b4c95b1d33a49297eb662bfb04", size = 4737859, upload-time = "2026-09-30T14:43:59.541Z" }, + { url = "https://files.pythonhosted.org/packages/8f/30/e86d7d518489b0ae2497091a35287abcb1a2ce4037837a34afbe9b1d6964/cryptography-50.0.2-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f265528741e048bce55c3463ed721fb0aa45a5888d8add8cfeccb3035451bbdc", size = 5324103, upload-time = "2026-09-30T14:44:01.901Z" }, + { url = "https://files.pythonhosted.org/packages/d3/69/2c833a049475e0a3444e94c7d0aca0aa51d166374a449b09e92ac98138de/cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079", size = 4752576, upload-time = "2026-09-30T14:44:04.545Z" }, + { url = "https://files.pythonhosted.org/packages/6c/5d/906970b83bbfc1f5bbfb677a143c181f2801f23b6a7204a3b47c42c97e65/cryptography-50.0.2-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25784ce8b9621c90c643efb9e1e2162ab3b0224cae446ad5e70e7fcb1ce18b51", size = 4870819, upload-time = "2026-09-30T14:44:06.884Z" }, + { url = "https://files.pythonhosted.org/packages/68/e3/f2298d3bb55e0c4a91841ec4d01b3f020ba8c5fbf15ccdcc6dcf03f97025/cryptography-50.0.2-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:85d0d9a31b9098e98534226d5686b47264b95e62ce459dc2e62fdfc809f9fe93", size = 5030152, upload-time = "2026-09-30T14:44:09.443Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4f/adfc442765721292fff86d314ce385d3249d22db42295c0dd057727b60f3/cryptography-50.0.2-cp311-abi3-win_amd64.whl", hash = "sha256:7afa5a6602a9f29af1f3a2965f831bae7c9d5d597b7cbb716d41ab3b7d89879c", size = 3824692, upload-time = "2026-09-30T14:44:11.671Z" }, + { url = "https://files.pythonhosted.org/packages/ce/cb/52eb3770c0d0be2702a98c6e96065ddc0a2877cf0845aa9c23397c142cd4/cryptography-50.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f785f6161f202ab04d8ca194158968798e480ca058943907972da5f12e2881e8", size = 3892731, upload-time = "2026-09-30T14:44:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/19/8e/aa1fc533d4546b127b45de8aa024eb5933d23eff9debfe25931e56861095/cryptography-50.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0ecbc5652bdb6fc9eaf89a7d196e20941adfe812f43bc4ca05d9150496821047", size = 4710431, upload-time = "2026-09-30T14:44:15.427Z" }, + { url = "https://files.pythonhosted.org/packages/6a/64/72bc3f75176e7e406b748a3e3830432b8c51297b38368713df04dc04898a/cryptography-50.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab50ee449bf968271e820086f10a33d101dd060370abc10bcd22279be2656539", size = 4694824, upload-time = "2026-09-30T14:44:17.69Z" }, + { url = "https://files.pythonhosted.org/packages/4e/c6/62c77550edfa5ca3f14bf44a1e6739b9fa09d6e998a11d97ed8213bccc98/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a9f7355e6fab51f6c369b86fb7571cffa05edee2c2121e0380a37fb9ac1cd5c1", size = 4716967, upload-time = "2026-09-30T14:44:19.661Z" }, + { url = "https://files.pythonhosted.org/packages/f4/37/cce70f150c432914460157a6ecc161752e053aa5ec0ef3b3f7dc6e31039a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:94e5e9f108ee10471288214d3d233fbfbb492840a8457eb85178d643ddeb32c7", size = 5328676, upload-time = "2026-09-30T14:44:21.744Z" }, + { url = "https://files.pythonhosted.org/packages/aa/9a/6f2f0304d634ceafdeaf23e84537336664ac419b5d07611675c2ad3f6b7a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:241449bf940a5d27309bd317e6f9a2af6932113818bb2b8f5c59ddc7ef16da18", size = 4727698, upload-time = "2026-09-30T14:44:24.178Z" }, + { url = "https://files.pythonhosted.org/packages/1d/de/66bcf9244d118663b2e1aaded8990f4640e3d7b7411870a5765f252074d2/cryptography-50.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d8947001be83df1394050758ce0e745dd74fb134eef0a4b5124208dfc3a68c37", size = 4354821, upload-time = "2026-09-30T14:44:26.263Z" }, + { url = "https://files.pythonhosted.org/packages/bd/e6/db28a28c7b6c676addce89136de3d8db49ea825a8c863472e36e42ead4ad/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4a20ce1e5cb4284a86692fdcba7cb8754185c6b2e5c56fcef3751cf451d3cdc2", size = 4716748, upload-time = "2026-09-30T14:44:28.447Z" }, + { url = "https://files.pythonhosted.org/packages/30/96/01546c7f69ea0e2ab790a2e4f0934a4052fb9b388147fbf83c2fd72f1e57/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:84f964e537f916e2cc85199e5a88742e964939b575ac8598b3f9d6cc416cdaf1", size = 5285085, upload-time = "2026-09-30T14:44:30.704Z" }, + { url = "https://files.pythonhosted.org/packages/6c/01/03263395f74d50b071e9e66daace3f8bef80493e5d410726f2ba8554736b/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:828d49b0ff5a0e3975865571c5d91dbbdd0d38d8289b249a163e9425413a5e05", size = 4727268, upload-time = "2026-09-30T14:44:32.92Z" }, + { url = "https://files.pythonhosted.org/packages/eb/94/2bfe8f29ec0cc9c0d99359c4161adf32858e4934b72c6d100d2ac0bbe962/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:deb9fde5c60e437ee4821bc9bc39ff31b42135c27e1dc61ef0a629389c1de62e", size = 4849503, upload-time = "2026-09-30T14:44:34.969Z" }, + { url = "https://files.pythonhosted.org/packages/54/44/e80651ecbf0e42b62e2bb5f5768916e07eea72e1297338956a61df361f88/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8c71ba2cd31fc93748c38e1b613200ff1c2665cbfd5341fe3a61cfde35a1430e", size = 5004057, upload-time = "2026-09-30T14:44:37.064Z" }, + { url = "https://files.pythonhosted.org/packages/f8/cc/1d33befb3cd7ea7e77d2d73f43f2066471da1b21f24a6156efcaabf6d2e8/cryptography-50.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:78198641e5be9521beea5aa782bb551a58068d10e6eb04c9c680c1b69f2e7d45", size = 3795868, upload-time = "2026-09-30T14:44:39.71Z" }, + { url = "https://files.pythonhosted.org/packages/2d/49/93f6a6e7a87c9aa68d44d3e1cdb5fe8f60c90d5d2f46acae9a56892816b8/cryptography-50.0.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:edc3342adf8f697fc5f59c887a304356f147b397809440ed64e2fa6af2f50f37", size = 4133708, upload-time = "2026-09-30T14:44:41.807Z" }, + { url = "https://files.pythonhosted.org/packages/8c/75/32ac2a56243d778805c16ca6a32b8f74fb757df7e28d7ecb560afafb59cf/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d370b8d1dfcdf7130178137f6fbee6140774a1acc6cacefc4b42643ec11d0a3a", size = 4956267, upload-time = "2026-09-30T14:44:43.693Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/2c8d734e43d97f0842ee9f1b7b4bfb3d0cf5e19edebf43c2afe6675c2320/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2f9bd7f90c64fe89253f0a2c05e3c4856072660429ce8831b4235bf29403a67", size = 4966465, upload-time = "2026-09-30T14:44:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/c2/58/ee288c829a6f41f6235ae9dd33d82fd19b45442b65b4c8a3da36963d9f7a/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_aarch64.whl", hash = "sha256:e275096ea1e60cc595cda2836fd4a6c725d1125108b868be17f53684d164e2cc", size = 4959356, upload-time = "2026-09-30T14:44:48.211Z" }, + { url = "https://files.pythonhosted.org/packages/92/20/9ded6d51ddd9897f6b6e81fb9ebea7951d7cc5d6c890b0ed8abf77a51a80/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_ppc64le.whl", hash = "sha256:b13478603dcd0a2479ff8e87e2c19a7d525734686fe3c49542472293a204212d", size = 5548822, upload-time = "2026-09-30T14:44:50.86Z" }, + { url = "https://files.pythonhosted.org/packages/02/a8/8df951850d6b31d2a00218f19e2b3f999523437ed7a819df7fa427942fca/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl", hash = "sha256:58a0c478eeca76fe5e07993c5a0703def34a6dc6a0cda4f5564639b33112ffe7", size = 5001199, upload-time = "2026-09-30T14:44:53.379Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f9/36b3022218ce75b7cdf068fb95f809f9bd0d820e4955ef43b90c255cc7ac/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_31_armv7l.whl", hash = "sha256:d38cdff612d06fa6a32840d5e1b1f7a27cee4a349aa9085d94a67789d6bfd408", size = 4629333, upload-time = "2026-09-30T14:44:55.635Z" }, + { url = "https://files.pythonhosted.org/packages/8c/72/20f99a219f6af47cdd1cbd978c243b92d71496e168a746138af44ded4f29/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_aarch64.whl", hash = "sha256:fdd28f912fccfec1846a94e2e1e8f9b0012f557f0c46fe4f3eb0d7a87afcf90b", size = 4958822, upload-time = "2026-09-30T14:44:59.639Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/196f112617fb08eb4d608a2a6c422373d46f9cc2857f38fc0667033c0899/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_ppc64le.whl", hash = "sha256:cbc8738fd8526d80f35cb3a40d41f41a2e7030bb3b18b09a6778ef63d291c2fd", size = 5506351, upload-time = "2026-09-30T14:45:02.267Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/83378121ef3eaaaf71d4b781577ff794acb39b9e1b87a3f156898c8497ed/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_x86_64.whl", hash = "sha256:e105ab60406787da31fccc883fc0f733af1efd78f0136a4599692c4083a73d0c", size = 5000859, upload-time = "2026-09-30T14:45:05.009Z" }, + { url = "https://files.pythonhosted.org/packages/22/f7/70fd7ae4d1dbfa7ba29b02e1b9068771519a86027756510b700ce81086a8/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6f8700550aa1474a91e5dc07049c46f98b423b5b1ddd0483e0b51362eeeaf5be", size = 5092151, upload-time = "2026-09-30T15:29:15.932Z" }, + { url = "https://files.pythonhosted.org/packages/d4/be/688367b74de86984bd58d8efacfc7c9e68b89a6a22ced0fb4f38db50254a/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:c71be1cbfa5cd9a41ee452acf1eccd82b2c05950358b106ec8ceb83411d1a020", size = 5286120, upload-time = "2026-09-30T15:29:18.309Z" }, + { url = "https://files.pythonhosted.org/packages/39/d1/55f8a3f2ef5d1529e16835ef10cf0fe3d559ce237b46dddc440c0bba3649/cryptography-50.0.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:c423ab384a46c4dff7217b2ea5ba2e11cffdeab6441acd04cf65a369caf0366c", size = 4111557, upload-time = "2026-09-30T15:29:20.155Z" }, + { url = "https://files.pythonhosted.org/packages/23/ad/ac987755d00e1e64273760228d2635ae38dae2be83e3c6e0d3289d91dec3/cryptography-50.0.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:0ec5f09541743261e66e291b4a0cbf0fb2997aeaab6d9e9c740b9dba1b58d1c2", size = 3943588, upload-time = "2026-09-30T15:29:22.265Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8d/6d585339bedf85d45044c85d8412dac53f2bb6f918e8b7777efba1787844/cryptography-50.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5e67125c7dca78d199ec4e116aa93dbb83494808ecbb8211a2cb09b1bf41dbd", size = 4756166, upload-time = "2026-09-30T15:29:24.58Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f1/1c1f6874e8550cfddd4b688ceb38cefb6ed15ceed224d56f133f3d88c214/cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee247f5c245c9a2fe7c8e2214e295918838e44e00a45a6718451e4004219e767", size = 4749145, upload-time = "2026-09-30T15:29:26.807Z" }, + { url = "https://files.pythonhosted.org/packages/c1/63/61b15dc1a8de03fe0adbe3fd7608b3ad5c73bf50993bbcb1faaa930afe33/cryptography-50.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dfe9763530994147d9af1def057a5b9658b00e8f8fe8743d144d1e0911c2e454", size = 4763638, upload-time = "2026-09-30T15:29:28.588Z" }, + { url = "https://files.pythonhosted.org/packages/fc/35/b345bdfa40c9126df1a9d33236aa98418367931b8725f84fc3ae2b98dc59/cryptography-50.0.2-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:58ddb5a8e3179d12f19e4ea34d2d32e9d63a4baa142c875c1eb59f41b7243acd", size = 5382217, upload-time = "2026-09-30T15:29:30.589Z" }, + { url = "https://files.pythonhosted.org/packages/4f/87/ef344a9e616871f2519c22d6afcda79ddd5d35e9592d95eb6e677608d055/cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f21e8a22c8605750c7af886bab299a363721264061b4ac0a30efb73cfd58efc5", size = 4781387, upload-time = "2026-09-30T15:29:32.605Z" }, + { url = "https://files.pythonhosted.org/packages/90/5b/f2fdb13cd0b96f6f932c8627bb292a45f11c64d21620a8e120aee9a3b848/cryptography-50.0.2-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:9c8402a82ea0dc4ceeab793db05f0fafa8ca139ca34fcde5df0f596103c74107", size = 4403790, upload-time = "2026-09-30T15:29:34.374Z" }, + { url = "https://files.pythonhosted.org/packages/bc/ce/7e4f662b1e3c393513569e402cfc85ac7da0bd3d5435e122a3140219eb2d/cryptography-50.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:0ddc924c04591c2811ca024d62ecad4f7f6f08af8939c211438f48a16bd23602", size = 4764319, upload-time = "2026-09-30T15:29:36.149Z" }, + { url = "https://files.pythonhosted.org/packages/3c/3f/86ff33ce34cc0de6847fb96e035a1a760d81652e38643f617c02ad32ef7a/cryptography-50.0.2-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a6557e5f38e065ca9fbdaf7cfc7435ecb1d113aa81a022d1b51921ee7432e227", size = 5338560, upload-time = "2026-09-30T15:29:39.053Z" }, + { url = "https://files.pythonhosted.org/packages/40/cf/6b5c8e2fd9202d98988ab7cb5cc5c991704c4ad55f492ff408e4969f83f1/cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c", size = 4780973, upload-time = "2026-09-30T15:29:41.251Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/8d6ebc7dded797bd0f0160d52188021211f011a2b164ef0ae1dac4587465/cryptography-50.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a8701d6b584d76e909e3d305b7d126b41439876a5aaf76cddc67fc230eafa2e", size = 4897738, upload-time = "2026-09-30T15:29:43.106Z" }, + { url = "https://files.pythonhosted.org/packages/d4/aa/f3f6e0de7e6253b8baa8b2d8fb9d50924fa75cee3d4624bd4bc1208ee923/cryptography-50.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ce47f66801c20ec6c6632453bb5960fe38939e9306970b48b3a5a26de7745d94", size = 5058280, upload-time = "2026-09-30T15:29:44.827Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b6/a1faf3a27ae9405fb34b1713cc73b2d8a26b04d5c561578fa2e6ef3e5bb9/cryptography-50.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de", size = 3854095, upload-time = "2026-09-30T15:29:46.782Z" }, +] + +[[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/1b/bc/ee4137cbbe105652c0ee4252792b78fc8e7afa4b8e61d9d5dc05a7f45731/cryptography-48.0.1-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3e4a1a3232eef2e6c732827d5722db29a0cc8b27af2a4d865b094cf954be9ca1", size = 8008324, upload-time = "2026-06-09T22:31:00.702Z" }, - { url = "https://files.pythonhosted.org/packages/d5/85/6379d42181bfc713094f081360fc5784d6c816b599d45e7f082502d173ce/cryptography-48.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:32143b24adb918f078134e1e230f1eb8cc04886b92c28b5f0041aaf3e5699225", size = 4696243, upload-time = "2026-06-09T22:32:33.446Z" }, - { url = "https://files.pythonhosted.org/packages/9c/87/c85d147b53323c7eb4d850920c8901377323c2a0ff8d79c262d4fee89aa2/cryptography-48.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0d27a5696721ef7a672b8c810f6aded391058e0b9486e63e6d93baf765da691", size = 4713235, upload-time = "2026-06-09T22:31:40.141Z" }, - { url = "https://files.pythonhosted.org/packages/79/58/67cbf8cf1ee7c54b439ca07bbecf8362c07afc11a3724fea70f745784add/cryptography-48.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb86ce1af36fe65041b6db9a8bb064ee621a7e5fded0f80d475ec243477cd242", size = 4702323, upload-time = "2026-06-09T22:31:42.191Z" }, - { url = "https://files.pythonhosted.org/packages/89/c6/24266ac10c47f6cd2a865f4446062b466da1d1f10b27189eac00e61bf0c9/cryptography-48.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b024e784ad6c077ee0147b35ea9cbfc1e34e1fd4c1dcca214c2794d73a12df08", size = 5300085, upload-time = "2026-06-09T22:31:58.703Z" }, - { url = "https://files.pythonhosted.org/packages/d2/bb/cc4b78784f97efc8c5874c2a9743708d172be6663024b34a0467885ae0c8/cryptography-48.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3752f2dbc8f07a30aad2932c986cea495b03bb554887828225da104f732852b6", size = 4746137, upload-time = "2026-06-09T22:31:31.01Z" }, - { url = "https://files.pythonhosted.org/packages/1f/52/0c44de3f5267f8fbe8e835138017522a333436166e406f0db9b9e6e3033f/cryptography-48.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:bd81490cd5801d755cf97bb68ac191f14b708470b1c7cf4580f669b9c9264cd8", size = 4333867, upload-time = "2026-06-09T22:32:28.096Z" }, - { url = "https://files.pythonhosted.org/packages/9a/2e/772d7adbfa931537bc401640b7cac9976bff689bda187833e5d63b428e49/cryptography-48.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:66fd0771e7b9c6dcd44cf1120690d2338d16d72795cf40cae2786a39eba65429", size = 4701805, upload-time = "2026-06-09T22:31:38.284Z" }, - { url = "https://files.pythonhosted.org/packages/f8/a3/b06844f303873493c963caf581c04df31c7035e0c1b0f02c4814d319ec80/cryptography-48.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:3fd2ca57062b241c856670b073487d2e86c4637937ca5601e48f97bf8e11fc8f", size = 5258461, upload-time = "2026-06-09T22:31:04.187Z" }, - { url = "https://files.pythonhosted.org/packages/9f/13/8b765e2e12b07c74941caadb9d1c8fdc006c4dfbf2b8f2d610519758954d/cryptography-48.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:0ee6ea481db1ab889cba043ec1eda17bb9c1ea79db6722f779c3667f9f70322f", size = 4745488, upload-time = "2026-06-09T22:32:30.07Z" }, - { url = "https://files.pythonhosted.org/packages/2e/aa/48972bce55049b32a94f4907eda4d75fa385aad8a39506cc2fc72196ecf0/cryptography-48.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f2ceef93cb096aa3c4cc4b5c94ca6131f9196d28c64d6111533402a9b2054d41", size = 4830256, upload-time = "2026-06-09T22:31:43.868Z" }, - { url = "https://files.pythonhosted.org/packages/47/a2/e5079a032fb85cf6005046ca92bbd78b0c82dad2b5751ab8c311659da06f/cryptography-48.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bd3f92d76217892b15df84ca256c2c113d386fdda7a7d8691aeeced976507c6", size = 4979117, upload-time = "2026-06-09T22:31:05.845Z" }, - { url = "https://files.pythonhosted.org/packages/b7/a0/8f50cae9c74e718ed769d63ed5c74bd0ea830c9550a74629cebd1b9c7bc7/cryptography-48.0.1-cp311-abi3-win32.whl", hash = "sha256:b9a32b876490d66c8bcc9963ef220199569748434ab01a9d6aaeabf88e7f5158", size = 3304154, upload-time = "2026-06-09T22:32:16.845Z" }, - { url = "https://files.pythonhosted.org/packages/c5/69/0572c77dbace6fef72f33755bd52ea399c71367250d366237f8691826b9e/cryptography-48.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:39489bfca54c7a1f6b297efcd8bc608ab92d16c4ca631b0cad4da46724588b24", size = 3817138, upload-time = "2026-06-09T22:32:00.388Z" }, - { url = "https://files.pythonhosted.org/packages/42/06/3e768b4c3bc78201583fa35a0e18f640dd782ff41afba88f8545481a8874/cryptography-48.0.1-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:f817adc181390bd54f2f700107a7419040fb7c1bdf2fc26f36551a06a68c3345", size = 7989830, upload-time = "2026-06-09T22:31:07.8Z" }, - { url = "https://files.pythonhosted.org/packages/8a/13/6476736484b94041110c8340a3eb63962fea4975baea8cb4a512adb44d4d/cryptography-48.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d5d30989c6917b478b5817902e85fddaea2261efa8648383d965381ccb9e1ac4", size = 4689201, upload-time = "2026-06-09T22:31:09.745Z" }, - { url = "https://files.pythonhosted.org/packages/79/62/65a87f34d2a431546e2509b85d55e8c90df86d668f6731da64d538512ac2/cryptography-48.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:df637c05205ea7c1d7fbcbe54bbfea648a52951155f997af13d895d0ecc96991", size = 4702822, upload-time = "2026-06-09T22:32:24.409Z" }, - { url = "https://files.pythonhosted.org/packages/7f/59/810b5204b0a9b10f4b6bc06bd551a8b609803cd931806bc3b71884b225e5/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:869c3b8a53bfe27147832df48b32adadf558249d50e76cb3769d40e986b13265", size = 4694875, upload-time = "2026-06-09T22:32:08.737Z" }, - { url = "https://files.pythonhosted.org/packages/24/dc/d8ca05ffea724eec6d232ea6f18e74c269eb6bdfdcc9bfba689790d1325f/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:e361afba8918070d376df76f408a4f67fec0ee9cff81a99e48fe9a233ef59e17", size = 5290385, upload-time = "2026-06-09T22:31:15.212Z" }, - { url = "https://files.pythonhosted.org/packages/03/8c/3be6cb4da181f5bb6c19cf560c2359d60644a6b5fc5b57854e528f47b296/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:d069066deead00ac7f090be101be875a06855908f7ec004c27b8fefb4acfb411", size = 4737082, upload-time = "2026-06-09T22:32:22.66Z" }, - { url = "https://files.pythonhosted.org/packages/aa/f6/d5f60a5a1434dbfd949e227fd0065d194c7e6b6ac526b17f5c06152b8231/cryptography-48.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:09f73a725d582cef64b91281a322cd798d14a33b2b6f2b7ad9531dc336d84c02", size = 4325328, upload-time = "2026-06-09T22:32:10.777Z" }, - { url = "https://files.pythonhosted.org/packages/17/b7/ba75dd947a14b6ad907b01ae8f6b5b348cdd1b48142f0063dee9e20c1d9d/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:15254441469dd6bf027039453288e2072124f8b6603563f5d759e1c9b69273fa", size = 4694530, upload-time = "2026-06-09T22:31:53.105Z" }, - { url = "https://files.pythonhosted.org/packages/62/29/50d6b9e8aff12d8b67afaeb3569335e32dc83a5723e3bbded24fdac9f809/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:8ace4507d1e6533c125f4fac754f8bb8b6a74c08e92179dabd7e16571a3efbf3", size = 5245046, upload-time = "2026-06-09T22:31:25.774Z" }, - { url = "https://files.pythonhosted.org/packages/9f/04/618f4115cfc0add0838c82507aa18a346089428da8653ad38b3ff36f5cb3/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b4e391975f038e66432328639620a4aff2d307513b004f1ca06d6225bced815c", size = 4736660, upload-time = "2026-06-09T22:32:12.676Z" }, - { url = "https://files.pythonhosted.org/packages/24/9c/06e062462a0de28a3b3911322eded4c16deb9f441b1b7575d3dc59488ab5/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42fcd8e26fe555d9b3577a135f5091fefa0aa4e99129c23fb56787a1bd4ada72", size = 4822229, upload-time = "2026-06-09T22:31:17.062Z" }, - { url = "https://files.pythonhosted.org/packages/f4/be/0561971eaaee4b8a0e7d5113c536921063ab91aaf23278ac374eaf881e11/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c1400da5e32a43253392277eac7490a60e497d810a63dd5608d71bbd7af507c9", size = 4966364, upload-time = "2026-06-09T22:31:32.842Z" }, - { url = "https://files.pythonhosted.org/packages/a4/27/728c77876f12b000820b69ae490f3c4083775e79e07827e9e60be07ad209/cryptography-48.0.1-cp314-cp314t-win32.whl", hash = "sha256:0df56b056bc17c1b7d6821dfa65216e62bd232d8ab05eb3db44e71d235651471", size = 3278498, upload-time = "2026-06-09T22:31:29.154Z" }, - { url = "https://files.pythonhosted.org/packages/06/e3/79a612c6d7b1e6ee0edd43633d53035bec2cfb78c82b76f7864f39e36f34/cryptography-48.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:9de21387aa95e2a895823d0745b430bed4f33503ba9ab5e0b5311f33e37d66d2", size = 3798790, upload-time = "2026-06-09T22:31:56.697Z" }, - { url = "https://files.pythonhosted.org/packages/ca/6c/00fa2a95997164c8b2072ce327c23d4ab20809ccc323ea5fab91e53a4bba/cryptography-48.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:4fdc69f8e4316bcf0c8c8ec1f26f285d12e8142d88d96c876a59a03be3f6ae67", size = 7987408, upload-time = "2026-06-09T22:32:20.777Z" }, - { url = "https://files.pythonhosted.org/packages/b0/d9/45f309a7e4e5f3f8f121d6d3be9e94024a7726ec598d6e08ae04edb2f04d/cryptography-48.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48fe40804d4caa2288f24e70ca8c64c42dd826da0ad7e4f1b41b2128d679e6c8", size = 4690196, upload-time = "2026-06-09T22:31:54.74Z" }, - { url = "https://files.pythonhosted.org/packages/5f/9f/a1bc8bcc798811b8527eb374bbccf30a3f3e806829d967118222bf1125eb/cryptography-48.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:86be3b1b0b6bf09482fb50a979c508d2950ed95f5621ec77f4e385962006b83a", size = 4696782, upload-time = "2026-06-09T22:31:45.615Z" }, - { url = "https://files.pythonhosted.org/packages/66/c2/81a4fb4e4373c500bb526bc337ac5719dd31dd15b970b84a238168c6aa08/cryptography-48.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4ab0a343c807bbcd90c971cd1ecf072937cd01847a9e002bef88fb47ac6be577", size = 4696618, upload-time = "2026-06-09T22:31:11.564Z" }, - { url = "https://files.pythonhosted.org/packages/e5/0b/aa68b221dde92d09cb29a024ede17550ee21e77a404e59fc093c82bb51e1/cryptography-48.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9621de99d2da096006b629979efd8ae7eb2d8b822488d0c89ee4000c306c59b1", size = 5289970, upload-time = "2026-06-09T22:31:20.368Z" }, - { url = "https://files.pythonhosted.org/packages/78/13/fba657f958d2af66ea959a4ba01212632089249d34af1ae48054136344d7/cryptography-48.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:88c852a0ae366e262e5a1744b685e6a433dc8788dd2a277e418bf4904203609d", size = 4731873, upload-time = "2026-06-09T22:31:22.253Z" }, - { url = "https://files.pythonhosted.org/packages/4c/4c/9a964756d24a26b3e34dfcb16f961b89838786e6700b635b0d1e3adff4b6/cryptography-48.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:43c5835e2cb98c8733d86f57d6fc879b613f5c3478607281c3e36daffc6dd8a6", size = 4330804, upload-time = "2026-06-09T22:31:36.56Z" }, - { url = "https://files.pythonhosted.org/packages/4b/0f/a10f3a6eb12950a10e3a874070283aa2dd5875b2bfd15fad8a3e17b3f13e/cryptography-48.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:fe0180af5bf9236518a087e35bf2d9a347d5f5f51e63c579d683ddff424e3d46", size = 4696217, upload-time = "2026-06-09T22:31:13.351Z" }, - { url = "https://files.pythonhosted.org/packages/f3/6f/5cd12f951165ea73ef85266775d97e4c763b2474ccfd816dd69d3a18d6f8/cryptography-48.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:b7a2d1a937a738a881737cec135a38bb61470589b17515b9f73f571d0ae10401", size = 5245252, upload-time = "2026-06-09T22:32:02.193Z" }, - { url = "https://files.pythonhosted.org/packages/68/ab/8aaa12e4516ec4464033ab79b6f3b592bd5a92102467c4ace8a0d970203f/cryptography-48.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b74ca3b8e5ecdd833bf6a002ca41b4793bb27fb8f1c06ffaf2643c9e9140e31b", size = 4731388, upload-time = "2026-06-09T22:32:04.019Z" }, - { url = "https://files.pythonhosted.org/packages/1b/24/50027ea4dca85ec1f40688f3c24fb32ccacd520583c9592c3cc95628e6fb/cryptography-48.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2c37f2461406063b417837f5f3daab668652acd82423efcd7f0a9f04be972de1", size = 4824186, upload-time = "2026-06-09T22:32:18.707Z" }, - { url = "https://files.pythonhosted.org/packages/52/41/04cb5eb17085ade6f50cc611fb657df6a0f5885350de8764ece89c050197/cryptography-48.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:86fe77abb1bd87afb251d4d02ada7ecf53a32cee9b67d976abb2e45a13297475", size = 4964539, upload-time = "2026-06-09T22:31:18.793Z" }, - { url = "https://files.pythonhosted.org/packages/36/bf/ed70785c496e89d7e73b7cda2d21f2447fd6d4e821714b8d04ff217fed92/cryptography-48.0.1-cp39-abi3-win32.whl", hash = "sha256:6b2c0c3e6ccf3ade7750f836ef3ee36eea250cc467d45c256895573ac08cc6f1", size = 3282307, upload-time = "2026-06-09T22:30:53.162Z" }, - { url = "https://files.pythonhosted.org/packages/b3/ff/371ea7d252656ee1eb6d83eeeef3d1d0c6baf1d6497687d081ea03814670/cryptography-48.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:9a49ca6c81417f6a5edb50375a60cccdd70fa0a91a5211829dbea74eba94d2ac", size = 3793408, upload-time = "2026-06-09T22:32:15.191Z" }, - { url = "https://files.pythonhosted.org/packages/a9/d3/eb4e394e587341fdad09a09101fa76478ead3a78b0ad63e55c22f0d75c02/cryptography-48.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:08a597acce1ff37f347400087776599e2348a3a8bc53b44120e463cd274efe4a", size = 3951747, upload-time = "2026-06-09T22:31:23.871Z" }, - { url = "https://files.pythonhosted.org/packages/e0/4a/3f43451b4f858bfceaaaffc649e6e787e8d4fb332a1d443af39ab02cc8f1/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:735824ec41b7f74a7c45fb1591349333e4c696cb6c044e5f46356e560143e4cd", size = 4641226, upload-time = "2026-06-09T22:31:02.532Z" }, - { url = "https://files.pythonhosted.org/packages/73/4e/855584c2c23b09e4ce2d3b9c30e983e679cd60b068c513c6bbdb91e11782/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:92a46e1d638daa264ba2971c0b0489c9409787943efae4d60ffda3d091ef832c", size = 4668958, upload-time = "2026-06-09T22:32:06.213Z" }, - { url = "https://files.pythonhosted.org/packages/42/3b/d35750e41d803d1e516fd6d6011f065424924da7af1748cef4cc9cb3ede1/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:7e234ac052af99f2700826a5c29ea99d9c1b1f80341cde62d11c8154dc8e0bd9", size = 4640793, upload-time = "2026-06-09T22:32:26.331Z" }, - { url = "https://files.pythonhosted.org/packages/ca/aa/cdb7181fe865285e87e96825aaab239400f1de0c3bfba9bd9769b79f1a92/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:33842cf0888951cef5bc7ac724ab844a42044c1727b967b7f8997289a0464f92", size = 4668505, upload-time = "2026-06-09T22:31:27.534Z" }, - { url = "https://files.pythonhosted.org/packages/5d/8c/ce3823c06c2804f194f9e64f0d67fa3f4094a39f2bb1a990cd03603af8fc/cryptography-48.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6184ca7b174f28d7c703f1290d4b297217c45355f77a98f67e9b7f14549ac54a", size = 3742204, upload-time = "2026-06-09T22:31:34.773Z" }, + { 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 = "exceptiongroup" -version = "1.3.1" +name = "httpx" +version = "0.28.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +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/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, + { 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.optional-dependencies] +socks = [ + { name = "socksio" }, ] [[package]] name = "idna" -version = "3.15" +version = "3.20" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, ] [[package]] name = "iniconfig" -version = "2.3.0" +version = "2.3.1" 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" } +sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" }, ] [[package]] name = "packaging" -version = "26.0" +version = "26.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, ] [[package]] name = "pluggy" -version = "1.6.0" +version = "1.7.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" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" }, ] [[package]] name = "pycparser" -version = "3.0" +version = "3.12" 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" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/27/eeb9f307bf07060f3d9f9aa0fb8c15b01bdfded0a941e4b2719c7f94cacd/pycparser-3.12.tar.gz", hash = "sha256:f03387877eab82ce01bbdfd0ba7252ee55a90b1c708d5129e2ce915475cf1db6", size = 158785, upload-time = "2026-10-22T13:40:03.56Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/b6/ae/315bbeee126a769cbde43cf4908acb4b40408d8bf8bbf334dd7368d8dadf/pycparser-3.12-py3-none-any.whl", hash = "sha256:a076f3f489b39b43ed13563a1dafe42447445bec2e294154ec5c57fcdda4c85e", size = 62600, upload-time = "2026-10-22T13:40:02.021Z" }, ] [[package]] name = "pygments" -version = "2.20.0" +version = "2.21.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, ] [[package]] name = "pyopenssl" -version = "26.2.0" +version = "26.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1a/51/27a5ad5f939d08f690a326ef9582cda7140555180db71695f6fb747d6a36/pyopenssl-26.2.0.tar.gz", hash = "sha256:8c6fcecd1183a7fc897548dfe388b0cdb7f37e018200d8409cf33959dbe35387", size = 182195, upload-time = "2026-05-04T23:06:09.72Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/e8/7325d258199b159eb2c03fe32107533e2832e70e63f4fb88a6aa00023201/pyopenssl-26.4.0.tar.gz", hash = "sha256:28dfcce0162b9211413e26dfbfdf1d24317fbeba18fc93c12400a1856b2a0bc7", size = 182046, upload-time = "2026-08-01T19:50:50.512Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/73/b8/a0e2790ae249d6f38c9f66de7a211621a7ab2650217bcd04e1262f578a56/pyopenssl-26.2.0-py3-none-any.whl", hash = "sha256:4f9d971bc5298b8bc1fab282803da04bf000c755d4ad9d99b52de2569ca19a70", size = 55823, upload-time = "2026-05-04T23:06:08.395Z" }, + { url = "https://files.pythonhosted.org/packages/51/ad/2cf6d3fa2fae5c79e1ed9960c0d42badd0f94d81dd12b50604cdc839e648/pyopenssl-26.4.0-py3-none-any.whl", hash = "sha256:f0eb0cb2d581d3ad2b9c489468485e7f2ab6727d08401bcf9d824c3caddf3c1c", size = 56026, upload-time = "2026-08-01T19:50:48.94Z" }, ] [[package]] @@ -386,25 +469,23 @@ wheels = [ [[package]] name = "pytest" -version = "9.0.3" +version = "9.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, { name = "pygments" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] [[package]] name = "requests" -version = "2.33.1" +version = "2.34.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -412,9 +493,9 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] [package.optional-dependencies] @@ -436,98 +517,52 @@ wheels = [ [[package]] name = "ruff" -version = "0.13.3" +version = "0.15.22" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/8e/f9f9ca747fea8e3ac954e3690d4698c9737c23b51731d02df999c150b1c9/ruff-0.13.3.tar.gz", hash = "sha256:5b0ba0db740eefdfbcce4299f49e9eaefc643d4d007749d77d047c2bab19908e", size = 5438533, upload-time = "2025-10-02T19:29:31.582Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/06/ae069393fc66e8ff33036d4b368003833bf6e88ccf182e17e7a2f1c754fd/ruff-0.15.22.tar.gz", hash = "sha256:3f15175b1fb580126f58285a5dae6b2ea89000136d980c64499211f116b54809", size = 4785063, upload-time = "2026-07-16T15:14:13.244Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/33/8f7163553481466a92656d35dea9331095122bb84cf98210bef597dd2ecd/ruff-0.13.3-py3-none-linux_armv6l.whl", hash = "sha256:311860a4c5e19189c89d035638f500c1e191d283d0cc2f1600c8c80d6dcd430c", size = 12484040, upload-time = "2025-10-02T19:28:49.199Z" }, - { url = "https://files.pythonhosted.org/packages/b0/b5/4a21a4922e5dd6845e91896b0d9ef493574cbe061ef7d00a73c61db531af/ruff-0.13.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:2bdad6512fb666b40fcadb65e33add2b040fc18a24997d2e47fee7d66f7fcae2", size = 13122975, upload-time = "2025-10-02T19:28:52.446Z" }, - { url = "https://files.pythonhosted.org/packages/40/90/15649af836d88c9f154e5be87e64ae7d2b1baa5a3ef317cb0c8fafcd882d/ruff-0.13.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fc6fa4637284708d6ed4e5e970d52fc3b76a557d7b4e85a53013d9d201d93286", size = 12346621, upload-time = "2025-10-02T19:28:54.712Z" }, - { url = "https://files.pythonhosted.org/packages/a5/42/bcbccb8141305f9a6d3f72549dd82d1134299177cc7eaf832599700f95a7/ruff-0.13.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c9e6469864f94a98f412f20ea143d547e4c652f45e44f369d7b74ee78185838", size = 12574408, upload-time = "2025-10-02T19:28:56.679Z" }, - { url = "https://files.pythonhosted.org/packages/ce/19/0f3681c941cdcfa2d110ce4515624c07a964dc315d3100d889fcad3bfc9e/ruff-0.13.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5bf62b705f319476c78891e0e97e965b21db468b3c999086de8ffb0d40fd2822", size = 12285330, upload-time = "2025-10-02T19:28:58.79Z" }, - { url = "https://files.pythonhosted.org/packages/10/f8/387976bf00d126b907bbd7725219257feea58650e6b055b29b224d8cb731/ruff-0.13.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78cc1abed87ce40cb07ee0667ce99dbc766c9f519eabfd948ed87295d8737c60", size = 13980815, upload-time = "2025-10-02T19:29:01.577Z" }, - { url = "https://files.pythonhosted.org/packages/0c/a6/7c8ec09d62d5a406e2b17d159e4817b63c945a8b9188a771193b7e1cc0b5/ruff-0.13.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:4fb75e7c402d504f7a9a259e0442b96403fa4a7310ffe3588d11d7e170d2b1e3", size = 14987733, upload-time = "2025-10-02T19:29:04.036Z" }, - { url = "https://files.pythonhosted.org/packages/97/e5/f403a60a12258e0fd0c2195341cfa170726f254c788673495d86ab5a9a9d/ruff-0.13.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:17b951f9d9afb39330b2bdd2dd144ce1c1335881c277837ac1b50bfd99985ed3", size = 14439848, upload-time = "2025-10-02T19:29:06.684Z" }, - { url = "https://files.pythonhosted.org/packages/39/49/3de381343e89364c2334c9f3268b0349dc734fc18b2d99a302d0935c8345/ruff-0.13.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6052f8088728898e0a449f0dde8fafc7ed47e4d878168b211977e3e7e854f662", size = 13421890, upload-time = "2025-10-02T19:29:08.767Z" }, - { url = "https://files.pythonhosted.org/packages/ab/b5/c0feca27d45ae74185a6bacc399f5d8920ab82df2d732a17213fb86a2c4c/ruff-0.13.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc742c50f4ba72ce2a3be362bd359aef7d0d302bf7637a6f942eaa763bd292af", size = 13444870, upload-time = "2025-10-02T19:29:11.234Z" }, - { url = "https://files.pythonhosted.org/packages/50/a1/b655298a1f3fda4fdc7340c3f671a4b260b009068fbeb3e4e151e9e3e1bf/ruff-0.13.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:8e5640349493b378431637019366bbd73c927e515c9c1babfea3e932f5e68e1d", size = 13691599, upload-time = "2025-10-02T19:29:13.353Z" }, - { url = "https://files.pythonhosted.org/packages/32/b0/a8705065b2dafae007bcae21354e6e2e832e03eb077bb6c8e523c2becb92/ruff-0.13.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6b139f638a80eae7073c691a5dd8d581e0ba319540be97c343d60fb12949c8d0", size = 12421893, upload-time = "2025-10-02T19:29:15.668Z" }, - { url = "https://files.pythonhosted.org/packages/0d/1e/cbe7082588d025cddbb2f23e6dfef08b1a2ef6d6f8328584ad3015b5cebd/ruff-0.13.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6b547def0a40054825de7cfa341039ebdfa51f3d4bfa6a0772940ed351d2746c", size = 12267220, upload-time = "2025-10-02T19:29:17.583Z" }, - { url = "https://files.pythonhosted.org/packages/a5/99/4086f9c43f85e0755996d09bdcb334b6fee9b1eabdf34e7d8b877fadf964/ruff-0.13.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9cc48a3564423915c93573f1981d57d101e617839bef38504f85f3677b3a0a3e", size = 13177818, upload-time = "2025-10-02T19:29:19.943Z" }, - { url = "https://files.pythonhosted.org/packages/9b/de/7b5db7e39947d9dc1c5f9f17b838ad6e680527d45288eeb568e860467010/ruff-0.13.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1a993b17ec03719c502881cb2d5f91771e8742f2ca6de740034433a97c561989", size = 13618715, upload-time = "2025-10-02T19:29:22.527Z" }, - { url = "https://files.pythonhosted.org/packages/28/d3/bb25ee567ce2f61ac52430cf99f446b0e6d49bdfa4188699ad005fdd16aa/ruff-0.13.3-py3-none-win32.whl", hash = "sha256:f14e0d1fe6460f07814d03c6e32e815bff411505178a1f539a38f6097d3e8ee3", size = 12334488, upload-time = "2025-10-02T19:29:24.782Z" }, - { url = "https://files.pythonhosted.org/packages/cf/49/12f5955818a1139eed288753479ba9d996f6ea0b101784bb1fe6977ec128/ruff-0.13.3-py3-none-win_amd64.whl", hash = "sha256:621e2e5812b691d4f244638d693e640f188bacbb9bc793ddd46837cea0503dd2", size = 13455262, upload-time = "2025-10-02T19:29:26.882Z" }, - { url = "https://files.pythonhosted.org/packages/fe/72/7b83242b26627a00e3af70d0394d68f8f02750d642567af12983031777fc/ruff-0.13.3-py3-none-win_arm64.whl", hash = "sha256:9e9e9d699841eaf4c2c798fa783df2fabc680b72059a02ca0ed81c460bc58330", size = 12538484, upload-time = "2025-10-02T19:29:28.951Z" }, + { url = "https://files.pythonhosted.org/packages/23/18/ee54b7ae1e121be7a28ea6da4b67564ebb0530e183a54415ab7e3bcd2c4e/ruff-0.15.22-py3-none-linux_armv6l.whl", hash = "sha256:44423e73493737f5e7c5b41d475483898ff37afcdae38bc3da5085e29af1c2d8", size = 10781258, upload-time = "2026-07-16T15:13:19.452Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d2/2520cb14761ddbeaf57642a76942fc36adcbdbe53b4532241995f6fc485c/ruff-0.15.22-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b82c6482946e9eda7ff2e091d25b8bad3f718684e1916d41bd56873cee05b697", size = 10999477, upload-time = "2026-07-16T15:13:23.318Z" }, + { url = "https://files.pythonhosted.org/packages/c9/10/74e53572aa758dfaa678c2a2646b5c5515d884b7ca56be4d2ce03ca4b560/ruff-0.15.22-py3-none-macosx_11_0_arm64.whl", hash = "sha256:11c1c715af53a09f714e011106bffc419751ec8232fcb5da42173284ea3fec6f", size = 10466716, upload-time = "2026-07-16T15:13:26.162Z" }, + { url = "https://files.pythonhosted.org/packages/1e/cc/44eaaf0844e028182f2d0a8f2190d0f359159aed0a9e5ab861d892f1ae2a/ruff-0.15.22-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:742a29cf29bddb7c8327895d6a10e0e6c5b38a96dd407af9b5d0857f809c0576", size = 10892644, upload-time = "2026-07-16T15:13:29.229Z" }, + { url = "https://files.pythonhosted.org/packages/9f/21/8edf559014d2b0f82beea19cfb713993ad802ccda16868769979c6090a84/ruff-0.15.22-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72af58b951b0ae395935ae79763dc349bc0eb706319d28f7a33ad2cfb3cfc178", size = 10576719, upload-time = "2026-07-16T15:13:32.35Z" }, + { url = "https://files.pythonhosted.org/packages/bf/1e/3a13abd392a3b50b62e5938a831f9ab6e588358cacad5c18545b716d2182/ruff-0.15.22-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62d425005c1835eb24e2ee4161cb90e8db263415f4a71c8c72c33abaa6c0c224", size = 11376494, upload-time = "2026-07-16T15:13:35.958Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3e/422d3d95bcf04dd78e1aeac22184d4f9a8fb2c01865d39d44618484a0317/ruff-0.15.22-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8b9b3f8779a4f08c969defc3c8c35abffaa757e601ed5ae66d6d1db6519969a", size = 12208370, upload-time = "2026-07-16T15:13:39.185Z" }, + { url = "https://files.pythonhosted.org/packages/1e/91/5d065a0e0a02bf4813f5119ad278462eed081d2b832eb7c021ade0ec9e65/ruff-0.15.22-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1e0dd1b2e4d3d585f897a0d137cbf4eaf6223bef4e8ce34d6bb12556c5f9249e", size = 11581098, upload-time = "2026-07-16T15:13:42.132Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f9/a0d4871d12fae702eb1f41b686caf05f1f8b124dc6db6f784f53d74918fa/ruff-0.15.22-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:365523eb91d9224e1bcb03b022fbf0facb8f9e23792a2c53d9d4b3924bdbdebb", size = 11399422, upload-time = "2026-07-16T15:13:45.2Z" }, + { url = "https://files.pythonhosted.org/packages/18/80/c843a5176cddbceb0b7e8dd41cf9993490796c1c469348d384f5a5c13c56/ruff-0.15.22-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:fabfd168afdf29fee5be98b831efa9683c94d7c5a3b58b9ce5a2e38444589a74", size = 11381683, upload-time = "2026-07-16T15:13:48.46Z" }, + { url = "https://files.pythonhosted.org/packages/d4/00/8485de0ae92239438a36cfc51350db9b9e85c9ebdfaea91b18e422706662/ruff-0.15.22-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:225dbf095a87f1d9f90f5fd7924d2613ee452a75a4308c63a8f50f761787aa7c", size = 10850295, upload-time = "2026-07-16T15:13:51.655Z" }, + { url = "https://files.pythonhosted.org/packages/fa/91/24977ec2ec72eaf15e4394ace2959fdff2dd1e14f03e005e838023407169/ruff-0.15.22-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1877d63b9d24ed278744f1523fd11b85540566d54641f97c566d7d9dc5ca5296", size = 10579640, upload-time = "2026-07-16T15:13:54.79Z" }, + { url = "https://files.pythonhosted.org/packages/9c/47/9b51216951974df1f263ac19da550d34252e0ed7218c25f10c5ef9ed7517/ruff-0.15.22-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a1606c510bd7215680d32efab38965f7cdec3ef69f5170a3f4791404ffdd5262", size = 11105077, upload-time = "2026-07-16T15:13:57.915Z" }, + { url = "https://files.pythonhosted.org/packages/c2/47/20e9d4a3b8016778acea5fc32bb50d35d207500a17ddb529ffa6996feef8/ruff-0.15.22-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:630479b18625f5ffc373f77603a22a9f8ac0acd7ff0501178b5db28ec71e9c64", size = 11490980, upload-time = "2026-07-16T15:14:01.032Z" }, + { url = "https://files.pythonhosted.org/packages/4d/76/3f72d8fc38c1cb77b38c56a70da9d0c17700cc1cc50f9649c9d3c8f5ba71/ruff-0.15.22-py3-none-win32.whl", hash = "sha256:e5ba0e4a13fd14abbed2a77b517a3911290c6c6c59ef67784328d1668fab76cf", size = 10789165, upload-time = "2026-07-16T15:14:04.16Z" }, + { url = "https://files.pythonhosted.org/packages/cb/46/4965251734c2b6fcdca1b1b187d20bcac3af0ee5b083b89c910bb961ce3a/ruff-0.15.22-py3-none-win_amd64.whl", hash = "sha256:9be63ba1eb936acd2d1342fb8337c356353706fce233b2a15a09a97037e6acde", size = 11938297, upload-time = "2026-07-16T15:14:07.316Z" }, + { url = "https://files.pythonhosted.org/packages/57/c9/e69b1ff4c8b69093ef08b8919ab767af0569666865b39c30a8795d88d3c6/ruff-0.15.22-py3-none-win_arm64.whl", hash = "sha256:e1168075b72158510839f250027659cdd78476f40507dd517892304c41318661", size = 11298172, upload-time = "2026-07-16T15:14:10.51Z" }, ] [[package]] -name = "tomli" -version = "2.4.1" +name = "socksio" +version = "1.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/5c/48a7d9495be3d1c651198fd99dbb6ce190e2274d0f28b9051307bdec6b85/socksio-1.0.0.tar.gz", hash = "sha256:f88beb3da5b5c38b9890469de67d0cb0f9d494b78b106ca1845f96c10b91c4ac", size = 19055, upload-time = "2020-04-17T15:50:34.664Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, - { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, - { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, - { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, - { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, - { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, - { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, - { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, - { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, - { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, - { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, - { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, - { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, - { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, - { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, - { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, - { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, - { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, - { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, - { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, - { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, - { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, - { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, - { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, - { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, - { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, - { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, - { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, - { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, - { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, - { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, - { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, - { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, - { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, - { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, - { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, - { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, - { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, - { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, - { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, - { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, - { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, - { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, - { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, - { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, - { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, + { url = "https://files.pythonhosted.org/packages/37/c3/6eeb6034408dac0fa653d126c9204ade96b819c936e136c5e8a6897eee9c/socksio-1.0.0-py3-none-any.whl", hash = "sha256:95dc1f15f9b34e8d7b16f06d74b8ccf48f609af32ab33c608d08761c5dcbb1f3", size = 12763, upload-time = "2020-04-17T15:50:31.878Z" }, ] [[package]] name = "typing-extensions" -version = "4.15.0" +version = "4.16.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" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] [[package]] name = "urllib3" -version = "2.7.0" +version = "2.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, + { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, ]