diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 43d4136..8de0479 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -4,16 +4,18 @@ Python client for the AgentScore trust and reputation API. ## Identity Model +Two identity paths: `X-Wallet-Address` (wallet-based) and `X-Operator-Token` (credential-based). Wallet addresses accept both EVM (`0x...` 40-hex) and Solana (base58, 32–44 chars) formats — network is auto-detected from the address shape. `assess` responses include `resolved_operator` and `linked_wallets[]` (same-operator sibling wallets, normalized per network — EVM lowercased, Solana base58 verbatim; may mix chains for cross-chain operators). `create_session` and `create_credential` responses include an `agent_memory` cross-merchant pattern hint. `create_session` also returns `next_steps.action="deliver_verify_url_and_poll"` + polling instructions. `poll_session` returns `next_steps.action` values: `continue_polling`, `retry_merchant_request_with_operator_token`, `use_stored_operator_token`, `create_new_session`, `verification_failed`, `contact_support`. + ## Methods (sync + async) - `get_reputation` / `aget_reputation` — cached reputation lookup (free) -- `assess` / `aassess` — identity gate with policy (paid). Accepts `operator_token` for non-wallet agents. -- `create_session` / `acreate_session` — create verification session -- `poll_session` / `apoll_session` — poll session status, returns credential when verified -- `create_credential` / `acreate_credential` — create operator credential (24h TTL default) +- `assess` / `aassess` — identity gate with policy (paid). Accepts `operator_token` for non-wallet agents. Response includes `linked_wallets[]` and `resolved_operator`. +- `create_session` / `acreate_session` — create verification session. Returns `agent_memory` + `next_steps`. +- `poll_session` / `apoll_session` — poll session status, returns credential when verified, plus `next_steps.action`. +- `create_credential` / `acreate_credential` — create operator credential (24h TTL default). Response includes `agent_memory`. - `list_credentials` / `alist_credentials` — list active credentials - `revoke_credential` / `arevoke_credential` — revoke a credential -- `associate_wallet` / `aassociate_wallet` — report a signer wallet seen paying under a credential (TEC-189). Accepts optional `idempotency_key` (payment intent id / tx hash) so retries don't inflate transaction_count. +- `associate_wallet` / `aassociate_wallet` — report a signer wallet seen paying under a credential. Accepts optional `idempotency_key` (payment intent id / tx hash) so retries don't inflate transaction_count. ## Architecture @@ -37,6 +39,7 @@ Single-package Python library published to PyPI. ```bash uv sync --all-extras +uv run lefthook install # one-time per clone — wires pre-commit + pre-push uv run ruff check . uv run ruff format . uv run ty check agentscore/ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec48ded..f66dab9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,9 +19,9 @@ jobs: timeout-minutes: 10 steps: - uses: useblacksmith/checkout@v1 - - uses: astral-sh/setup-uv@v7 + - uses: astral-sh/setup-uv@v8.1.0 - run: uv python install 3.12 - - uses: actions/cache@v4 + - uses: actions/cache@v5 with: path: | ~/.cache/uv diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ff15fd6..1843dc5 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -19,7 +19,7 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: astral-sh/setup-uv@v7 + - uses: astral-sh/setup-uv@v8.1.0 - name: Set version from tag run: sed -i "s/^version = .*/version = \"${GITHUB_REF_NAME#v}\"/" pyproject.toml diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 2ff07f1..d1c9605 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -23,11 +23,11 @@ jobs: - name: Install osv-scanner run: | - curl -fsSL https://github.com/google/osv-scanner/releases/download/v2.3.2/osv-scanner_linux_amd64 -o osv-scanner + curl -fsSL https://github.com/google/osv-scanner/releases/download/v2.3.5/osv-scanner_linux_arm64 -o osv-scanner chmod +x osv-scanner - name: Scan dependencies - run: ./osv-scanner --lockfile=uv.lock --format=table || true + run: ./osv-scanner scan source --lockfile=uv.lock --format=table pip-audit: name: Python Audit @@ -35,7 +35,7 @@ jobs: timeout-minutes: 5 steps: - uses: useblacksmith/checkout@v1 - - uses: astral-sh/setup-uv@v7 + - uses: astral-sh/setup-uv@v8.1.0 - uses: actions/setup-python@v6 with: python-version: "3.13" @@ -46,5 +46,5 @@ jobs: - name: Audit dependencies run: | uv export --format requirements-txt --no-hashes > requirements.txt - pip-audit -r requirements.txt --disable-pip --no-deps || true + pip-audit -r requirements.txt --disable-pip --no-deps diff --git a/README.md b/README.md index dcc72d2..bd9202e 100644 --- a/README.md +++ b/README.md @@ -52,17 +52,27 @@ print(result["decision"]) # "allow" | "deny" ### Verification Sessions -Bootstrap identity for first-time agents: +Bootstrap identity for first-time agents. The success body carries structured `next_steps` (with `action: "deliver_verify_url_and_poll"`) and a cross-merchant `agent_memory` hint. Poll responses carry `next_steps.action` from the typed `NextStepsAction` Literal (`continue_polling`, `retry_merchant_request_with_operator_token`, `use_stored_operator_token`, `create_new_session`, `verification_failed`, `contact_support`). ```python session = client.create_session() print(session["verify_url"], session["poll_url"], session["poll_secret"]) +print(session["next_steps"]["action"]) # "deliver_verify_url_and_poll" status = client.poll_session(session["session_id"], session["poll_secret"]) if status["status"] == "verified": print(status["operator_token"]) # "opc_..." — use for future requests + +# Optional pre-association: attach the session to a known wallet or refresh KYC +# for an existing operator credential. +client.create_session(address="0x...") +client.create_session(operator_token="opc_...") # KYC refresh ``` +### Wallet resolution + +`assess()` responses include `resolved_operator` and `linked_wallets` — all same-operator sibling wallets (claimed via SIWE or captured via prior `associate_wallet`). The list may mix EVM addresses (`0x...` lowercased) and Solana addresses (base58, case-preserved) for cross-chain operators; merchants doing wallet-signer-match checks should accept a payment signed by any address in the list, regardless of chain. The `address` parameter on `assess()` and `get_reputation()` accepts either format — network is auto-detected from the address shape. + ### Credential Management ```python @@ -124,6 +134,8 @@ with AgentScore(api_key="as_live_...") as client: | `timeout` | `10.0` | Request timeout (seconds)| | `user_agent` | `None` | Prepended to the default `User-Agent` as `"{user_agent} (agentscore-py/{version})"`. Use to attribute API calls to your app. | +`AgentScoreError.status` is a property aliasing `.status_code` so polyglot codebases can use the same attribute name regardless of which SDK raised the error. + ## Error Handling ```python @@ -135,6 +147,18 @@ except AgentScoreError as e: print(e.code, e.status_code, str(e)) ``` +`AgentScoreError.details` carries the rest of the response body — `verify_url`, `linked_wallets`, `claimed_operator`, `actual_signer`, `expected_signer`, `reasons`, `agent_memory` — so callers can branch on granular denial codes without re-parsing: + +```python +try: + client.assess("0xabc...", policy={"require_kyc": True}) +except AgentScoreError as e: + if e.code == "wallet_signer_mismatch": + print("Re-sign from one of:", e.details.get("linked_wallets")) + elif e.code == "token_expired": + print("Verify at:", e.details.get("verify_url")) +``` + ## Documentation - [API Reference](https://docs.agentscore.sh) diff --git a/agentscore/__init__.py b/agentscore/__init__.py index cf225ae..a8c77a0 100644 --- a/agentscore/__init__.py +++ b/agentscore/__init__.py @@ -2,16 +2,24 @@ from agentscore.client import AgentScore from agentscore.errors import AgentScoreError +from agentscore.test_mode import AGENTSCORE_TEST_ADDRESSES, is_agentscore_test_address from agentscore.types import ( + AccountVerification, + AgentMemoryHint, + AgentMemoryIdentityPaths, AssessResponse, AssociateWalletResponse, + CredentialCreateErrorResponse, CredentialCreateResponse, CredentialItem, CredentialListResponse, + CredentialRevokeResponse, DecisionPolicy, + DenialCode, EntityType, Grade, Network, + NextStepsAction, OperatorVerification, Reputation, ReputationResponse, @@ -20,22 +28,32 @@ SessionCreateResponse, SessionPollResponse, VerificationLevel, + WalletAuthRequiresSigningBody, + WalletSignerMismatchBody, ) __version__ = _pkg_version("agentscore-py") __all__ = [ + "AGENTSCORE_TEST_ADDRESSES", + "AccountVerification", + "AgentMemoryHint", + "AgentMemoryIdentityPaths", "AgentScore", "AgentScoreError", "AssessResponse", "AssociateWalletResponse", + "CredentialCreateErrorResponse", "CredentialCreateResponse", "CredentialItem", "CredentialListResponse", + "CredentialRevokeResponse", "DecisionPolicy", + "DenialCode", "EntityType", "Grade", "Network", + "NextStepsAction", "OperatorVerification", "Reputation", "ReputationResponse", @@ -44,5 +62,8 @@ "SessionCreateResponse", "SessionPollResponse", "VerificationLevel", + "WalletAuthRequiresSigningBody", + "WalletSignerMismatchBody", "__version__", + "is_agentscore_test_address", ] diff --git a/agentscore/client.py b/agentscore/client.py index 4fcac31..c8d7876 100644 --- a/agentscore/client.py +++ b/agentscore/client.py @@ -1,6 +1,8 @@ from __future__ import annotations +import asyncio import logging +import time from importlib.metadata import version as _pkg_version from typing import TYPE_CHECKING, Any @@ -10,16 +12,27 @@ logger = logging.getLogger("agentscore") -# Server truncates idempotency_key at 200 chars; warn the caller past that so two -# distinct payments that share the first 200 chars don't silently dedup. _IDEMPOTENCY_KEY_MAX = 200 +_MAX_RETRY_WAIT_SECONDS = 10.0 + + +def _retry_after_seconds(response: httpx.Response) -> float: + raw = response.headers.get("retry-after", "1") + try: + return min(float(raw), _MAX_RETRY_WAIT_SECONDS) + except (TypeError, ValueError): + return 1.0 + if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + from agentscore.types import ( AssessResponse, AssociateWalletResponse, CredentialCreateResponse, CredentialListResponse, + CredentialRevokeResponse, DecisionPolicy, Network, ReputationResponse, @@ -73,6 +86,22 @@ def _get_async_client(self) -> httpx.AsyncClient: ) return self._async_client + def _send_sync(self, send_fn: Callable[[], httpx.Response]) -> Any: + """Issue a request, retry once on 429 honoring retry-after, then parse.""" + response = send_fn() + if response.status_code == 429: + time.sleep(_retry_after_seconds(response)) + response = send_fn() + return self._handle_response(response) + + async def _send_async(self, send_fn: Callable[[], Awaitable[httpx.Response]]) -> Any: + """Async variant of :meth:`_send_sync`.""" + response = await send_fn() + if response.status_code == 429: + await asyncio.sleep(_retry_after_seconds(response)) + response = await send_fn() + return self._handle_response(response) + def _handle_response(self, response: httpx.Response) -> Any: if response.status_code == 429: retry_after = response.headers.get("retry-after", "1") @@ -84,11 +113,16 @@ def _handle_response(self, response: httpx.Response) -> Any: if response.status_code >= 400: try: body = response.json() - error = body.get("error", {}) + error = body.get("error", {}) if isinstance(body, dict) else {} + # Preserve everything except the parsed `error` block so consumers + # can read verify_url, linked_wallets, reasons, etc. for granular + # denial recovery — exposed via AgentScoreError.details. + details = {k: v for k, v in body.items() if k != "error"} if isinstance(body, dict) else {} raise AgentScoreError( code=error.get("code", "unknown_error"), message=error.get("message", response.text), status_code=response.status_code, + details=details, ) except ValueError as err: raise AgentScoreError( @@ -113,8 +147,7 @@ def get_reputation(self, address: str, chain: str | None = None) -> ReputationRe if chain: params["chain"] = chain client = self._get_sync_client() - response = client.get(f"/v1/reputation/{address}", params=params) - return self._handle_response(response) + return self._send_sync(lambda: client.get(f"/v1/reputation/{address}", params=params)) def assess( self, @@ -137,32 +170,42 @@ def assess( if policy is not None: body["policy"] = dict(policy) client = self._get_sync_client() - response = client.post("/v1/assess", json=body) - return self._handle_response(response) + return self._send_sync(lambda: client.post("/v1/assess", json=body)) def create_session( self, context: str | None = None, product_name: str | None = None, + address: str | None = None, + operator_token: str | None = None, ) -> SessionCreateResponse: - """Create an assessment session for deferred scoring.""" + """Create an assessment session for deferred scoring. + + ``address`` pre-associates the session with a known wallet (EVM ``0x...`` or + Solana base58). ``operator_token`` pre-associates with an existing ``opc_...`` — + e.g. refresh KYC for a credential. + """ body: dict[str, Any] = {} if context is not None: body["context"] = context if product_name is not None: body["product_name"] = product_name + if address is not None: + body["address"] = address + if operator_token is not None: + body["operator_token"] = operator_token client = self._get_sync_client() - response = client.post("/v1/sessions", json=body) - return self._handle_response(response) + return self._send_sync(lambda: client.post("/v1/sessions", json=body)) def poll_session(self, session_id: str, poll_secret: str) -> SessionPollResponse: """Poll a session for its current status and result.""" client = self._get_sync_client() - response = client.get( - f"/v1/sessions/{session_id}", - headers={"X-Poll-Secret": poll_secret}, + return self._send_sync( + lambda: client.get( + f"/v1/sessions/{session_id}", + headers={"X-Poll-Secret": poll_secret}, + ), ) - return self._handle_response(response) def create_credential( self, @@ -176,20 +219,17 @@ def create_credential( if ttl_days is not None: body["ttl_days"] = ttl_days client = self._get_sync_client() - response = client.post("/v1/credentials", json=body) - return self._handle_response(response) + return self._send_sync(lambda: client.post("/v1/credentials", json=body)) def list_credentials(self) -> CredentialListResponse: """List all API credentials.""" client = self._get_sync_client() - response = client.get("/v1/credentials") - return self._handle_response(response) + return self._send_sync(lambda: client.get("/v1/credentials")) - def revoke_credential(self, id: str) -> dict: + def revoke_credential(self, id: str) -> CredentialRevokeResponse: """Revoke an API credential by ID.""" client = self._get_sync_client() - response = client.delete(f"/v1/credentials/{id}") - return self._handle_response(response) + return self._send_sync(lambda: client.delete(f"/v1/credentials/{id}")) def associate_wallet( self, @@ -213,8 +253,8 @@ def associate_wallet( "wallet_address": wallet_address, "network": network, } - # Truthy check (not `is not None`) so empty strings don't ship a useless key — mirrors - # node-sdk's behavior of only forwarding when the key actually has content. + # Truthy check (not `is not None`) so empty strings don't ship a useless key — + # only forward when the key actually has content. if idempotency_key: if len(idempotency_key) > _IDEMPOTENCY_KEY_MAX: logger.warning( @@ -223,8 +263,7 @@ def associate_wallet( ) body["idempotency_key"] = idempotency_key client = self._get_sync_client() - response = client.post("/v1/credentials/wallets", json=body) - return self._handle_response(response) + return self._send_sync(lambda: client.post("/v1/credentials/wallets", json=body)) # --- Async methods --- @@ -234,8 +273,7 @@ async def aget_reputation(self, address: str, chain: str | None = None) -> Reput if chain: params["chain"] = chain client = self._get_async_client() - response = await client.get(f"/v1/reputation/{address}", params=params) - return self._handle_response(response) + return await self._send_async(lambda: client.get(f"/v1/reputation/{address}", params=params)) async def aassess( self, @@ -258,32 +296,42 @@ async def aassess( if policy is not None: body["policy"] = dict(policy) client = self._get_async_client() - response = await client.post("/v1/assess", json=body) - return self._handle_response(response) + return await self._send_async(lambda: client.post("/v1/assess", json=body)) async def acreate_session( self, context: str | None = None, product_name: str | None = None, + address: str | None = None, + operator_token: str | None = None, ) -> SessionCreateResponse: - """Create an assessment session for deferred scoring.""" + """Create an assessment session for deferred scoring. + + ``address`` pre-associates the session with a known wallet (EVM ``0x...`` or + Solana base58). ``operator_token`` pre-associates with an existing ``opc_...`` — + e.g. refresh KYC for a credential. + """ body: dict[str, Any] = {} if context is not None: body["context"] = context if product_name is not None: body["product_name"] = product_name + if address is not None: + body["address"] = address + if operator_token is not None: + body["operator_token"] = operator_token client = self._get_async_client() - response = await client.post("/v1/sessions", json=body) - return self._handle_response(response) + return await self._send_async(lambda: client.post("/v1/sessions", json=body)) async def apoll_session(self, session_id: str, poll_secret: str) -> SessionPollResponse: """Poll a session for its current status and result.""" client = self._get_async_client() - response = await client.get( - f"/v1/sessions/{session_id}", - headers={"X-Poll-Secret": poll_secret}, + return await self._send_async( + lambda: client.get( + f"/v1/sessions/{session_id}", + headers={"X-Poll-Secret": poll_secret}, + ), ) - return self._handle_response(response) async def acreate_credential( self, @@ -297,20 +345,17 @@ async def acreate_credential( if ttl_days is not None: body["ttl_days"] = ttl_days client = self._get_async_client() - response = await client.post("/v1/credentials", json=body) - return self._handle_response(response) + return await self._send_async(lambda: client.post("/v1/credentials", json=body)) async def alist_credentials(self) -> CredentialListResponse: """List all API credentials.""" client = self._get_async_client() - response = await client.get("/v1/credentials") - return self._handle_response(response) + return await self._send_async(lambda: client.get("/v1/credentials")) - async def arevoke_credential(self, id: str) -> dict: + async def arevoke_credential(self, id: str) -> CredentialRevokeResponse: """Revoke an API credential by ID.""" client = self._get_async_client() - response = await client.delete(f"/v1/credentials/{id}") - return self._handle_response(response) + return await self._send_async(lambda: client.delete(f"/v1/credentials/{id}")) async def aassociate_wallet( self, @@ -325,8 +370,8 @@ async def aassociate_wallet( "wallet_address": wallet_address, "network": network, } - # Truthy check (not `is not None`) so empty strings don't ship a useless key — mirrors - # node-sdk's behavior of only forwarding when the key actually has content. + # Truthy check (not `is not None`) so empty strings don't ship a useless key — + # only forward when the key actually has content. if idempotency_key: if len(idempotency_key) > _IDEMPOTENCY_KEY_MAX: logger.warning( @@ -335,8 +380,7 @@ async def aassociate_wallet( ) body["idempotency_key"] = idempotency_key client = self._get_async_client() - response = await client.post("/v1/credentials/wallets", json=body) - return self._handle_response(response) + return await self._send_async(lambda: client.post("/v1/credentials/wallets", json=body)) def close(self): if self._sync_client: diff --git a/agentscore/errors.py b/agentscore/errors.py index 34d6aa8..7796e28 100644 --- a/agentscore/errors.py +++ b/agentscore/errors.py @@ -1,5 +1,28 @@ +from typing import Any + + class AgentScoreError(Exception): - def __init__(self, code: str, message: str, status_code: int): + def __init__( + self, + code: str, + message: str, + status_code: int, + details: dict[str, Any] | None = None, + ): super().__init__(message) self.code = code self.status_code = status_code + # Response-body fields beyond `error.{code,message}` — e.g. verify_url, + # linked_wallets, claimed_operator, actual_signer, reasons. Consumers + # branch on these for granular recovery (see the mcp denial-code rendering + # in the node sibling for the canonical use). Defaults to {} so callers + # constructing this error by hand without a body can omit it. + self.details: dict[str, Any] = details or {} + + @property + def status(self) -> int: + """Alias for ``status_code`` — parity with node-sdk's attribute name. + + Polyglot codebases can use ``err.status`` regardless of which SDK raised the error. + """ + return self.status_code diff --git a/agentscore/test_mode.py b/agentscore/test_mode.py new file mode 100644 index 0000000..f44760b --- /dev/null +++ b/agentscore/test_mode.py @@ -0,0 +1,41 @@ +"""Recognizers for AgentScore reserved test addresses. + +AgentScore's ``/v1/assess`` endpoint recognizes seven EVM addresses +(``0x0000…0001`` through ``0x0000…0007``) as test fixtures with deterministic +policy outcomes — KYC verified, sanctions clear, age gates passing — so dev/test +interactions don't burn real KYC credits and produce predictable results. + +Use this in test suites and dev/staging tooling to label test-mode interactions +distinctly from production traffic. +""" + +from __future__ import annotations + +_TEST_ADDRESSES: frozenset[str] = frozenset( + { + "0x0000000000000000000000000000000000000001", + "0x0000000000000000000000000000000000000002", + "0x0000000000000000000000000000000000000003", + "0x0000000000000000000000000000000000000004", + "0x0000000000000000000000000000000000000005", + "0x0000000000000000000000000000000000000006", + "0x0000000000000000000000000000000000000007", + }, +) + +AGENTSCORE_TEST_ADDRESSES: tuple[str, ...] = tuple(sorted(_TEST_ADDRESSES)) +"""The full list of reserved test addresses, exposed for documentation, completion, +and downstream test fixtures.""" + + +def is_agentscore_test_address(address: str | None) -> bool: + """Recognize one of the seven reserved AgentScore EVM test fixtures. + + Lowercases for comparison so accidentally mixed-case input still matches. + """ + if not address: + return False + return address.lower() in _TEST_ADDRESSES + + +__all__ = ["AGENTSCORE_TEST_ADDRESSES", "is_agentscore_test_address"] diff --git a/agentscore/types.py b/agentscore/types.py index 96852a5..02ce3d7 100644 --- a/agentscore/types.py +++ b/agentscore/types.py @@ -172,7 +172,7 @@ class DecisionPolicy(TypedDict, total=False): class _AssessResponseRequired(TypedDict): decision: str | None decision_reasons: list[str] - identity_method: str + identity_method: Literal["wallet", "operator_token"] on_the_fly: bool updated_at: str | None @@ -189,6 +189,10 @@ class PolicyExplanation(TypedDict, total=False): class AssessResponse(_AssessResponseRequired, total=False): operator_verification: OperatorVerification resolved_operator: str | None + # Wallets linked to the same operator as the resolved identity. Populated on allow + # responses; omitted on denials to avoid leaking the linked set for flagged operators. + # Capped at 100 entries. + linked_wallets: list[str] verify_url: str policy_result: PolicyResult | None explanation: NotRequired[list[PolicyExplanation]] @@ -199,7 +203,21 @@ class SessionCreateRequest(TypedDict, total=False): product_name: str -class SessionCreateResponse(TypedDict): +class SessionCreateNextSteps(TypedDict, total=False): + """Structured action guidance on POST /v1/sessions success. + + action is always ``deliver_verify_url_and_poll`` — tells the agent to share verify_url + with the user and poll poll_url with X-Poll-Secret until an operator_token is issued. + """ + + action: NextStepsAction + poll_interval_seconds: int + poll_secret_header: str + steps: list[str] + user_message: str + + +class _SessionCreateResponseRequired(TypedDict): session_id: str poll_secret: str verify_url: str @@ -207,8 +225,15 @@ class SessionCreateResponse(TypedDict): expires_at: str +class SessionCreateResponse(_SessionCreateResponseRequired, total=False): + # Structured next_steps with action=deliver_verify_url_and_poll. + next_steps: SessionCreateNextSteps + # Cross-merchant memory hint on first session creation. + agent_memory: AgentMemoryHint + + class SessionPollNextSteps(TypedDict, total=False): - action: str + action: NextStepsAction user_message: str header_name: str poll_interval_seconds: int @@ -240,18 +265,55 @@ class CredentialItem(TypedDict): last_used_at: str | None -class CredentialCreateResponse(TypedDict): +class _CredentialCreateResponseRequired(TypedDict): id: str label: str | None credential: str prefix: str created_at: str - expires_at: str | None + expires_at: str + + +class CredentialCreateResponse(_CredentialCreateResponseRequired, total=False): + agent_memory: AgentMemoryHint + + +class AccountVerification(TypedDict, total=False): + """Account-level KYC state surfaced by GET /v1/credentials (same shape the API emits).""" + + kyc_status: str + kyc_verified_at: str | None + jurisdiction: str | None + age_verified: bool + age_bracket: str | None + sanctions_status: str | None + operator_type: str | None class CredentialListResponse(TypedDict): credentials: list[CredentialItem] - account_verification: NotRequired[dict] + account_verification: NotRequired[AccountVerification] + + +class CredentialRevokeResponse(TypedDict): + id: str + revoked: Literal[True] + + +class _CredentialCreateErrorNextSteps(TypedDict): + action: NextStepsAction + + +class CredentialCreateErrorNextSteps(_CredentialCreateErrorNextSteps, total=False): + user_message: str + + +class CredentialCreateErrorResponse(TypedDict): + """409 response body when POST /v1/credentials is called before KYC completes.""" + + error: dict # {"code": "kyc_required", "message": str} + verify_url: str + next_steps: CredentialCreateErrorNextSteps Network = Literal["evm", "solana"] @@ -264,3 +326,160 @@ class AssociateWalletResponse(TypedDict): associated: bool first_seen: bool deduped: NotRequired[bool] + # Cross-merchant pattern hint. Emitted only on the first wallet capture (first_seen=True) + # so merchants can relay it once in a 402 body and LLM-hosted agents persist the pattern + # to long-term memory. Absent on all subsequent captures. + agent_memory: NotRequired[AgentMemoryHint] + + +# --------------------------------------------------------------------------- +# Denial codes +# --------------------------------------------------------------------------- + + +DenialCode = Literal[ + "operator_verification_required", + "compliance_denied", + "compliance_error", + "wallet_not_trusted", + "missing_identity", + "identity_verification_required", + "payment_required", + "api_error", + "kyc_required", + # Wallet-signer binding — claimed X-Wallet-Address must resolve to the same operator + # as the payment signer; wallet-auth is rejected on rails with no wallet signer. + "wallet_signer_mismatch", + "wallet_auth_requires_wallet_signing", + # Credential is no longer valid (revoked or past its TTL — the two cases share this + # code deliberately so the API doesn't leak which one). The 401 body carries an + # auto-minted session so agents recover without holding an API key. + "token_expired", +] +"""Denial codes returned by the gate in 403/402 error bodies. Lets agents pick the right +remediation without natural-language parsing.""" + + +NextStepsAction = Literal[ + "poll_for_credential", + "contact_support", + "retry", + "retry_once_then_contact_support", + "regenerate_payment_credential", + "none", + "done", + "use_operator_token", + "regenerate_payment_from_linked_wallet", + # Gate-emitted probe strategy — try wallet on signing rails, fall back to stored + # opc_..., fall back to session flow. Emitted on bare missing_identity 403s. + "probe_identity_then_session", + # Wallet signer mismatch — re-sign from expected_signer / any linked_wallets entry, + # or drop X-Wallet-Address and retry with X-Operator-Token. + "resign_or_switch_to_operator_token", + # Non-signing rail (Stripe SPT, card) — drop X-Wallet-Address, use X-Operator-Token. + "switch_to_operator_token", + # POST /v1/sessions success — deliver verify_url + poll poll_url. + "deliver_verify_url_and_poll", + # Session poll states. + "continue_polling", + "retry_merchant_request_with_operator_token", + "use_stored_operator_token", + "create_new_session", + "verification_failed", + "complete_kyc_then_retry", +] +"""Recommended agent action encoded in next_steps.action.""" + + +class AgentMemoryIdentityPaths(TypedDict): + wallet: str + operator_token: str + + +class AgentMemoryHint(TypedDict): + """Structured hint emitted on bootstrap denials and POST /v1/sessions success. + + LLM-hosted agents should save this to their long-term memory so future AgentScore encounters + skip the cold-start session flow. Contents are the pattern, not secrets — + do_not_persist_in_memory names bearer credentials that belong in a credential store. + """ + + save_for_future_agentscore_gates: bool + pattern_summary: str + quickstart: str + identity_check_endpoint: str + list_wallets_endpoint: NotRequired[str] + identity_paths: AgentMemoryIdentityPaths + bootstrap: str + do_not_persist_in_memory: list[str] + persist_in_credential_store: list[str] + + +class _WalletSignerMismatchNextStepsRequired(TypedDict): + action: NextStepsAction + + +class WalletSignerMismatchNextSteps(_WalletSignerMismatchNextStepsRequired, total=False): + user_message: str + learn_more_url: str + + +class _WalletSignerMismatchBodyRequired(TypedDict): + error: dict # {"code": "wallet_signer_mismatch", "message": str} + claimed_operator: str + actual_signer_operator: str | None + linked_wallets: list[str] + + +class WalletSignerMismatchBody(_WalletSignerMismatchBodyRequired, total=False): + """403 body for X-Wallet-Address + mismatched-signer rejections. + + Returned when the claimed wallet's operator doesn't match the payment signer's operator. + actual_signer_operator is None if the signer isn't linked to any operator. + + Action copy surfaces via one of two paths: + - ``agent_instructions``: JSON-encoded ``{action, steps, user_message}`` set by the + gate's default marshaller (action is typically ``resign_or_switch_to_operator_token``). + - ``next_steps``: structured object set by merchants who override the gate default + (action may be ``regenerate_payment_from_linked_wallet`` or any NextStepsAction). + + Agents should check for whichever is present. + """ + + expected_signer: str + actual_signer: str + agent_instructions: str + next_steps: WalletSignerMismatchNextSteps + agent_memory: AgentMemoryHint + + +class _WalletAuthRequiresSigningNextStepsRequired(TypedDict): + action: NextStepsAction + + +class WalletAuthRequiresSigningNextSteps(_WalletAuthRequiresSigningNextStepsRequired, total=False): + user_message: str + signer_capable_rails: list[str] + learn_more_url: str + + +class _WalletAuthRequiresSigningBodyRequired(TypedDict): + error: dict # {"code": "wallet_auth_requires_wallet_signing", "message": str} + + +class WalletAuthRequiresSigningBody(_WalletAuthRequiresSigningBodyRequired, total=False): + """403 body for X-Wallet-Address + signer-less rail rejections. + + Returned when X-Wallet-Address is used with a payment rail that has no wallet signer + (SPT, card). Agent should switch to X-Operator-Token for those rails. + + Action copy surfaces via one of two paths: + - ``agent_instructions``: JSON-encoded ``{action, steps, user_message}`` set by the + gate's default marshaller (action is typically ``switch_to_operator_token``). + - ``next_steps``: structured object set by merchants who override the gate default + (action may be ``use_operator_token`` or any NextStepsAction). + """ + + agent_instructions: str + next_steps: WalletAuthRequiresSigningNextSteps + agent_memory: AgentMemoryHint diff --git a/pyproject.toml b/pyproject.toml index df65dd2..4853c67 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "agentscore-py" -version = "1.8.0" +version = "2.0.0" description = "Python client for the AgentScore trust and reputation API" readme = "README.md" license = "MIT" @@ -22,7 +22,7 @@ classifiers = [ ] [project.urls] -Homepage = "https://docs.agentscore.sh" +Homepage = "https://agentscore.sh" Repository = "https://github.com/agentscore/python-sdk" Issues = "https://github.com/agentscore/python-sdk/issues" @@ -34,7 +34,7 @@ dev = [ ] [tool.pytest.ini_options] -addopts = "--cov=agentscore --cov-report=term-missing --cov-fail-under=94" +addopts = "--cov=agentscore --cov-report=term-missing --cov-fail-under=95" [tool.hatch.build.targets.wheel] packages = ["agentscore"] @@ -48,6 +48,7 @@ dev = [ "respx>=0.22.0", "pytest-asyncio>=1.2.0", "python-dotenv>=1.2.1", + "lefthook>=2.1.6", ] [tool.ty.src] diff --git a/tests/test_client.py b/tests/test_client.py index 3a81a7e..a00319c 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -801,6 +801,57 @@ def test_create_session_raises_on_error(): assert exc_info.value.code == "bad_request" +@respx.mock +def test_create_session_forwards_address_and_operator_token(): + """Pre-association lets a session refresh KYC for an existing identity.""" + route = respx.post(f"{BASE_URL}/v1/sessions").mock(return_value=httpx.Response(200, json=SESSION_CREATE_PAYLOAD)) + client = AgentScore(api_key=API_KEY) + client.create_session(address="0xabc", operator_token="opc_xyz") + body = json.loads(route.calls.last.request.content) + assert body["address"] == "0xabc" + assert body["operator_token"] == "opc_xyz" + + +@respx.mock +def test_error_response_populates_details_with_non_error_fields(): + """Non-`error` response-body keys flow into AgentScoreError.details so consumers + can branch on verify_url, linked_wallets, claimed_operator, etc. for granular recovery.""" + respx.post(f"{BASE_URL}/v1/assess").mock( + return_value=httpx.Response( + 403, + json={ + "error": {"code": "wallet_signer_mismatch", "message": "Signer mismatch"}, + "claimed_operator": "op_abc", + "actual_signer": "0xdef", + "linked_wallets": ["0xabc", "0xdef"], + }, + ) + ) + client = AgentScore(api_key=API_KEY) + with pytest.raises(AgentScoreError) as exc_info: + client.assess(address="0xabc") + err = exc_info.value + assert err.code == "wallet_signer_mismatch" + assert err.details["claimed_operator"] == "op_abc" + assert err.details["actual_signer"] == "0xdef" + assert err.details["linked_wallets"] == ["0xabc", "0xdef"] + assert "error" not in err.details # the `error` key is parsed into code/message, not echoed + + +@respx.mock +def test_error_response_with_no_extra_fields_yields_empty_details(): + respx.post(f"{BASE_URL}/v1/assess").mock( + return_value=httpx.Response( + 500, + json={"error": {"code": "internal_error", "message": "Boom"}}, + ) + ) + client = AgentScore(api_key=API_KEY) + with pytest.raises(AgentScoreError) as exc_info: + client.assess(address="0xabc") + assert exc_info.value.details == {} + + # --------------------------------------------------------------------------- # poll_session # --------------------------------------------------------------------------- @@ -1047,6 +1098,18 @@ async def test_acreate_session_with_first_class_fields(): await client.aclose() +@pytest.mark.asyncio +@respx.mock +async def test_acreate_session_forwards_address_and_operator_token(): + route = respx.post(f"{BASE_URL}/v1/sessions").mock(return_value=httpx.Response(200, json=SESSION_CREATE_PAYLOAD)) + client = AgentScore(api_key=API_KEY) + await client.acreate_session(address="0xabc", operator_token="opc_xyz") + body = json.loads(route.calls.last.request.content) + assert body["address"] == "0xabc" + assert body["operator_token"] == "opc_xyz" + await client.aclose() + + # --------------------------------------------------------------------------- # Async: apoll_session # --------------------------------------------------------------------------- @@ -1346,3 +1409,109 @@ async def test_aassociate_wallet_raises_on_402_payment_required(): assert exc_info.value.status_code == 402 assert exc_info.value.code == "payment_required" await client.aclose() + + +# --------------------------------------------------------------------------- +# 429 retry-once parity with node-sdk (sync + async) +# --------------------------------------------------------------------------- + + +@respx.mock +def test_assess_retries_once_on_429_then_succeeds(monkeypatch): + monkeypatch.setattr("agentscore.client.time.sleep", lambda _: None) + route = respx.post(f"{BASE_URL}/v1/assess").mock( + side_effect=[ + httpx.Response(429, headers={"retry-after": "0"}, json={}), + httpx.Response(200, json={"address": ADDRESS, "decision": "allow"}), + ], + ) + client = AgentScore(api_key=API_KEY) + result = client.assess(address=ADDRESS) + assert route.call_count == 2 + assert result["decision"] == "allow" + client.close() + + +@respx.mock +def test_assess_raises_when_429_persists_across_retry(monkeypatch): + monkeypatch.setattr("agentscore.client.time.sleep", lambda _: None) + route = respx.post(f"{BASE_URL}/v1/assess").mock( + return_value=httpx.Response(429, headers={"retry-after": "0"}, json={}), + ) + client = AgentScore(api_key=API_KEY) + with pytest.raises(AgentScoreError) as exc_info: + client.assess(address=ADDRESS) + assert route.call_count == 2 + assert exc_info.value.status_code == 429 + assert exc_info.value.code == "rate_limited" + client.close() + + +@respx.mock +def test_assess_uses_default_wait_when_retry_after_missing(monkeypatch): + captured: list[float] = [] + monkeypatch.setattr("agentscore.client.time.sleep", lambda s: captured.append(s)) + respx.post(f"{BASE_URL}/v1/assess").mock( + side_effect=[ + httpx.Response(429, json={}), + httpx.Response(200, json={"address": ADDRESS, "decision": "allow"}), + ], + ) + client = AgentScore(api_key=API_KEY) + client.assess(address=ADDRESS) + assert captured == [1.0] + client.close() + + +@respx.mock +def test_assess_caps_retry_wait_at_10_seconds(monkeypatch): + captured: list[float] = [] + monkeypatch.setattr("agentscore.client.time.sleep", lambda s: captured.append(s)) + respx.post(f"{BASE_URL}/v1/assess").mock( + side_effect=[ + httpx.Response(429, headers={"retry-after": "9999"}, json={}), + httpx.Response(200, json={"address": ADDRESS, "decision": "allow"}), + ], + ) + client = AgentScore(api_key=API_KEY) + client.assess(address=ADDRESS) + assert captured == [10.0] + client.close() + + +@pytest.mark.asyncio +@respx.mock +async def test_aassess_retries_once_on_429_then_succeeds(monkeypatch): + async def _no_sleep(_seconds: float) -> None: + return None + + monkeypatch.setattr("agentscore.client.asyncio.sleep", _no_sleep) + route = respx.post(f"{BASE_URL}/v1/assess").mock( + side_effect=[ + httpx.Response(429, headers={"retry-after": "0"}, json={}), + httpx.Response(200, json={"address": ADDRESS, "decision": "allow"}), + ], + ) + client = AgentScore(api_key=API_KEY) + result = await client.aassess(address=ADDRESS) + assert route.call_count == 2 + assert result["decision"] == "allow" + await client.aclose() + + +@pytest.mark.asyncio +@respx.mock +async def test_aassess_raises_when_429_persists_across_retry(monkeypatch): + async def _no_sleep(_seconds: float) -> None: + return None + + monkeypatch.setattr("agentscore.client.asyncio.sleep", _no_sleep) + route = respx.post(f"{BASE_URL}/v1/assess").mock( + return_value=httpx.Response(429, headers={"retry-after": "0"}, json={}), + ) + client = AgentScore(api_key=API_KEY) + with pytest.raises(AgentScoreError) as exc_info: + await client.aassess(address=ADDRESS) + assert route.call_count == 2 + assert exc_info.value.status_code == 429 + await client.aclose() diff --git a/tests/test_errors.py b/tests/test_errors.py index 88acff0..ff24ce6 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -34,3 +34,23 @@ def test_unknown_error_code(): err = AgentScoreError(code="unknown_error", message="Something went wrong", status_code=500) assert err.code == "unknown_error" assert err.status_code == 500 + + +def test_details_defaults_to_empty_dict_when_omitted(): + err = AgentScoreError(code="not_found", message="Not found", status_code=404) + assert err.details == {} + + +def test_details_preserves_response_body_fields_for_granular_recovery(): + err = AgentScoreError( + code="wallet_signer_mismatch", + message="Signer mismatch", + status_code=403, + details={ + "claimed_operator": "op_abc", + "actual_signer": "0xdef", + "linked_wallets": ["0xabc", "0xdef"], + }, + ) + assert err.details["claimed_operator"] == "op_abc" + assert err.details["linked_wallets"] == ["0xabc", "0xdef"] diff --git a/tests/test_test_mode.py b/tests/test_test_mode.py new file mode 100644 index 0000000..4c492b2 --- /dev/null +++ b/tests/test_test_mode.py @@ -0,0 +1,32 @@ +"""Tests for is_agentscore_test_address + AGENTSCORE_TEST_ADDRESSES.""" + +from agentscore import AGENTSCORE_TEST_ADDRESSES, is_agentscore_test_address + + +def test_returns_true_for_each_of_seven_reserved_addresses(): + for i in range(1, 8): + addr = "0x" + "0" * 39 + str(i) + assert is_agentscore_test_address(addr) is True + + +def test_matches_case_insensitively(): + assert is_agentscore_test_address("0x0000000000000000000000000000000000000001".upper()) is True + + +def test_returns_false_for_addresses_outside_reserved_range(): + assert is_agentscore_test_address("0x0000000000000000000000000000000000000008") is False + assert is_agentscore_test_address("0xabcabcabcabcabcabcabcabcabcabcabcabcabca") is False + + +def test_returns_false_for_none_and_empty(): + assert is_agentscore_test_address(None) is False + assert is_agentscore_test_address("") is False + + +def test_exports_exactly_seven_addresses(): + assert len(AGENTSCORE_TEST_ADDRESSES) == 7 + + +def test_every_exported_address_passes_recognizer(): + for addr in AGENTSCORE_TEST_ADDRESSES: + assert is_agentscore_test_address(addr) is True diff --git a/tests/test_types.py b/tests/test_types.py new file mode 100644 index 0000000..0f7df7b --- /dev/null +++ b/tests/test_types.py @@ -0,0 +1,115 @@ +"""Type-presence checks for denial codes, memory hints, and wallet-signer body types. + +The real assertion is that the file type-checks under ty; pytest just exercises +the runtime shape so CI has something to run. +""" + +from __future__ import annotations + +from typing import get_args + +from agentscore import ( + AgentMemoryHint, + AgentScore, + AgentScoreError, + DenialCode, + NextStepsAction, + SessionCreateResponse, + WalletAuthRequiresSigningBody, + WalletSignerMismatchBody, +) + + +def test_denial_code_includes_new_values() -> None: + codes = get_args(DenialCode) + assert "wallet_signer_mismatch" in codes + assert "wallet_auth_requires_wallet_signing" in codes + assert "token_expired" in codes + # Backward-compat: prior codes still present. + assert "operator_verification_required" in codes + assert "compliance_denied" in codes + + +def test_next_steps_action_includes_new_values() -> None: + actions = get_args(NextStepsAction) + # Probe strategy (gate-emitted missing_identity). + assert "probe_identity_then_session" in actions + assert "resign_or_switch_to_operator_token" in actions + assert "switch_to_operator_token" in actions + assert "deliver_verify_url_and_poll" in actions + # Session poll states. + assert "continue_polling" in actions + assert "retry_merchant_request_with_operator_token" in actions + assert "use_stored_operator_token" in actions + # Backward-compat: prior actions still present. + assert "use_operator_token" in actions + assert "regenerate_payment_from_linked_wallet" in actions + + +def test_wallet_signer_mismatch_body_shape() -> None: + body: WalletSignerMismatchBody = { + "error": {"code": "wallet_signer_mismatch", "message": "signer does not match claimed wallet"}, + "claimed_operator": "op_abc", + "actual_signer_operator": None, + "expected_signer": "0x1111", + "actual_signer": "0x2222", + "linked_wallets": ["0x1111", "0x3333"], + "next_steps": {"action": "regenerate_payment_from_linked_wallet", "user_message": "sign with linked"}, + } + assert body["error"]["code"] == "wallet_signer_mismatch" + assert len(body["linked_wallets"]) == 2 + + +def test_wallet_auth_requires_signing_body_shape() -> None: + body: WalletAuthRequiresSigningBody = { + "error": {"code": "wallet_auth_requires_wallet_signing", "message": "SPT has no signer"}, + "next_steps": {"action": "use_operator_token", "signer_capable_rails": ["tempo", "x402"]}, + } + assert body["error"]["code"] == "wallet_auth_requires_wallet_signing" + assert "tempo" in body["next_steps"]["signer_capable_rails"] + + +def test_agent_memory_hint_shape() -> None: + memory: AgentMemoryHint = { + "save_for_future_agentscore_gates": True, + "pattern_summary": "AgentScore is a cross-merchant identity layer", + "quickstart": "https://docs.agentscore.sh/agent-commerce-quickstart", + "identity_check_endpoint": "https://api.agentscore.sh/v1/credentials", + "list_wallets_endpoint": "https://api.agentscore.sh/v1/credentials/wallets", + "identity_paths": { + "wallet": "send X-Wallet-Address when paying from a linked wallet", + "operator_token": "send X-Operator-Token for any rail", + }, + "bootstrap": "follow the session/verify flow if you have neither", + "do_not_persist_in_memory": ["operator_token", "poll_secret"], + "persist_in_credential_store": ["operator_token"], + } + assert memory["save_for_future_agentscore_gates"] is True + assert "X-Wallet-Address" in memory["identity_paths"]["wallet"] + + +def test_session_create_response_accepts_agent_memory() -> None: + res: SessionCreateResponse = { + "session_id": "sess_abc", + "poll_secret": "poll_abc", + "verify_url": "https://agentscore.sh/verify?session=sess_abc", + "poll_url": "https://api.agentscore.sh/v1/sessions/sess_abc", + "expires_at": "2026-04-24T00:00:00Z", + "agent_memory": { + "save_for_future_agentscore_gates": True, + "pattern_summary": "p", + "quickstart": "q", + "identity_check_endpoint": "e", + "identity_paths": {"wallet": "w", "operator_token": "ot"}, + "bootstrap": "b", + "do_not_persist_in_memory": [], + "persist_in_credential_store": [], + }, + } + assert res["agent_memory"]["save_for_future_agentscore_gates"] is True + + +def test_sdk_exports_new_symbols() -> None: + # Importability check — covered above via imports, but also guard the AgentScore class itself. + assert AgentScore is not None + assert AgentScoreError is not None diff --git a/uv.lock b/uv.lock index f7ddb8a..8926070 100644 --- a/uv.lock +++ b/uv.lock @@ -4,7 +4,7 @@ requires-python = ">=3.11" [[package]] name = "agentscore-py" -version = "1.8.0" +version = "2.0.0" source = { editable = "." } dependencies = [ { name = "httpx" }, @@ -19,6 +19,7 @@ dev = [ [package.dev-dependencies] dev = [ + { name = "lefthook" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "python-dotenv" }, @@ -39,6 +40,7 @@ provides-extras = ["dev"] [package.metadata.requires-dev] dev = [ + { name = "lefthook", specifier = ">=2.1.6" }, { name = "pytest-asyncio", specifier = ">=1.2.0" }, { name = "pytest-cov", specifier = ">=6.0" }, { name = "python-dotenv", specifier = ">=1.2.1" }, @@ -238,13 +240,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "lefthook" +version = "2.1.6" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/1c/95addeea6b681f02cd44e40d8ce970973783f7b48081af88d3831c4f6da6/lefthook-2.1.6-py3-none-macosx_10_15_x86_64.whl", hash = "sha256:6d4608d0bb9dbcf10d333132973941c21bc7cde31a328658611e2066eec26d7e", size = 5439518, upload-time = "2026-04-16T07:34:18.136Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d4/2c645051bed898f1ad377e7c2e611e0f857502ca76d052db0f8333bb668d/lefthook-2.1.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fa6395917dc510c2622f31b6d3992c5506ff2a7569cd9d321712ec29689229b3", size = 4964898, upload-time = "2026-04-16T07:34:13.497Z" }, + { url = "https://files.pythonhosted.org/packages/19/ea/15b0384c1227ad172af0c18f21f7e00c570ccce086af471ac32edc2cb507/lefthook-2.1.6-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:016295036dd1d94af7ea6604247f0c47102559fb12f97ffcfe7880e8d1ce7a1f", size = 4791370, upload-time = "2026-04-16T07:34:15.04Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c0/3d43c9e1a08fa79d53f0c6eb808fb0ae9becf29bc7bd89cd757470af040e/lefthook-2.1.6-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:6c00cd91d553e064a2f71c35d2497b4350d1efdf18dd8f2c7ce2b1d6e8e2e147", size = 5367784, upload-time = "2026-04-16T07:34:10.414Z" }, + { url = "https://files.pythonhosted.org/packages/e6/99/cad1e694989964a8a79b34fdd18e6642bc631e280c06a9341565b5861e3b/lefthook-2.1.6-py3-none-win_amd64.whl", hash = "sha256:e571c48a227f51e5b8aec117c550c6d2ad1f74034e87afd5b0c98cbef319ad3e", size = 5516604, upload-time = "2026-04-16T07:34:12.061Z" }, + { url = "https://files.pythonhosted.org/packages/85/e9/286657a2c7efbb8701380c4c8a08c54d7f58938a4610d660c463fa074ee3/lefthook-2.1.6-py3-none-win_arm64.whl", hash = "sha256:985b88d908067a6d48a0e89cbc20c18b6a68a3fc4d44387f8ac1c39b85f0f18b", size = 4867769, upload-time = "2026-04-16T07:34:16.757Z" }, +] + [[package]] name = "packaging" -version = "26.1" +version = "26.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/df/de/0d2b39fb4af88a0258f3bac87dfcbb48e73fbdea4a2ed0e2213f9a4c2f9a/packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de", size = 215519, upload-time = "2026-04-14T21:12:49.362Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" }, + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] [[package]] @@ -331,27 +346,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.11" +version = "0.15.12" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e4/8d/192f3d7103816158dfd5ea50d098ef2aec19194e6cbccd4b3485bdb2eb2d/ruff-0.15.11.tar.gz", hash = "sha256:f092b21708bf0e7437ce9ada249dfe688ff9a0954fc94abab05dcea7dcd29c33", size = 4637264, upload-time = "2026-04-16T18:46:26.58Z" } +sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/02/1e/6aca3427f751295ab011828e15e9bf452200ac74484f1db4be0197b8170b/ruff-0.15.11-py3-none-linux_armv6l.whl", hash = "sha256:e927cfff503135c558eb581a0c9792264aae9507904eb27809cdcff2f2c847b7", size = 10607943, upload-time = "2026-04-16T18:46:05.967Z" }, - { url = "https://files.pythonhosted.org/packages/e7/26/1341c262e74f36d4e84f3d6f4df0ac68cd53331a66bfc5080daa17c84c0b/ruff-0.15.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7a1b5b2938d8f890b76084d4fa843604d787a912541eae85fd7e233398bbb73e", size = 10988592, upload-time = "2026-04-16T18:46:00.742Z" }, - { url = "https://files.pythonhosted.org/packages/03/71/850b1d6ffa9564fbb6740429bad53df1094082fe515c8c1e74b6d8d05f18/ruff-0.15.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d4176f3d194afbdaee6e41b9ccb1a2c287dba8700047df474abfbe773825d1cb", size = 10338501, upload-time = "2026-04-16T18:46:03.723Z" }, - { url = "https://files.pythonhosted.org/packages/f2/11/cc1284d3e298c45a817a6aadb6c3e1d70b45c9b36d8d9cce3387b495a03a/ruff-0.15.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b17c886fb88203ced3afe7f14e8d5ae96e9d2f4ccc0ee66aa19f2c2675a27e4", size = 10670693, upload-time = "2026-04-16T18:46:41.941Z" }, - { url = "https://files.pythonhosted.org/packages/ce/9e/f8288b034ab72b371513c13f9a41d9ba3effac54e24bfb467b007daee2ca/ruff-0.15.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:49fafa220220afe7758a487b048de4c8f9f767f37dfefad46b9dd06759d003eb", size = 10416177, upload-time = "2026-04-16T18:46:21.717Z" }, - { url = "https://files.pythonhosted.org/packages/85/71/504d79abfd3d92532ba6bbe3d1c19fada03e494332a59e37c7c2dabae427/ruff-0.15.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2ab8427e74a00d93b8bda1307b1e60970d40f304af38bccb218e056c220120d", size = 11221886, upload-time = "2026-04-16T18:46:15.086Z" }, - { url = "https://files.pythonhosted.org/packages/43/5a/947e6ab7a5ad603d65b474be15a4cbc6d29832db5d762cd142e4e3a74164/ruff-0.15.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:195072c0c8e1fc8f940652073df082e37a5d9cb43b4ab1e4d0566ab8977a13b7", size = 12075183, upload-time = "2026-04-16T18:46:07.944Z" }, - { url = "https://files.pythonhosted.org/packages/9f/a1/0b7bb6268775fdd3a0818aee8efd8f5b4e231d24dd4d528ced2534023182/ruff-0.15.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a0996d486af3920dec930a2e7daed4847dfc12649b537a9335585ada163e9e", size = 11516575, upload-time = "2026-04-16T18:46:31.687Z" }, - { url = "https://files.pythonhosted.org/packages/30/c3/bb5168fc4d233cc06e95f482770d0f3c87945a0cd9f614b90ea8dc2f2833/ruff-0.15.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bef2cb556d509259f1fe440bb9cd33c756222cf0a7afe90d15edf0866702431", size = 11306537, upload-time = "2026-04-16T18:46:36.988Z" }, - { url = "https://files.pythonhosted.org/packages/e4/92/4cfae6441f3967317946f3b788136eecf093729b94d6561f963ed810c82e/ruff-0.15.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:030d921a836d7d4a12cf6e8d984a88b66094ccb0e0f17ddd55067c331191bf19", size = 11296813, upload-time = "2026-04-16T18:46:24.182Z" }, - { url = "https://files.pythonhosted.org/packages/43/26/972784c5dde8313acde8ac71ba8ac65475b85db4a2352a76c9934361f9bc/ruff-0.15.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0e783b599b4577788dbbb66b9addcef87e9a8832f4ce0c19e34bf55543a2f890", size = 10633136, upload-time = "2026-04-16T18:46:39.802Z" }, - { url = "https://files.pythonhosted.org/packages/5b/53/3985a4f185020c2f367f2e08a103032e12564829742a1b417980ce1514a0/ruff-0.15.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ae90592246625ba4a34349d68ec28d4400d75182b71baa196ddb9f82db025ef5", size = 10424701, upload-time = "2026-04-16T18:46:10.381Z" }, - { url = "https://files.pythonhosted.org/packages/d3/57/bf0dfb32241b56c83bb663a826133da4bf17f682ba8c096973065f6e6a68/ruff-0.15.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1f111d62e3c983ed20e0ca2e800f8d77433a5b1161947df99a5c2a3fb60514f0", size = 10873887, upload-time = "2026-04-16T18:46:29.157Z" }, - { url = "https://files.pythonhosted.org/packages/02/05/e48076b2a57dc33ee8c7a957296f97c744ca891a8ffb4ffb1aaa3b3f517d/ruff-0.15.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:06f483d6646f59eaffba9ae30956370d3a886625f511a3108994000480621d1c", size = 11404316, upload-time = "2026-04-16T18:46:19.462Z" }, - { url = "https://files.pythonhosted.org/packages/88/27/0195d15fe7a897cbcba0904792c4b7c9fdd958456c3a17d2ea6093716a9a/ruff-0.15.11-py3-none-win32.whl", hash = "sha256:476a2aa56b7da0b73a3ee80b6b2f0e19cce544245479adde7baa65466664d5f3", size = 10655535, upload-time = "2026-04-16T18:46:12.47Z" }, - { url = "https://files.pythonhosted.org/packages/3a/5e/c927b325bd4c1d3620211a4b96f47864633199feed60fa936025ab27e090/ruff-0.15.11-py3-none-win_amd64.whl", hash = "sha256:8b6756d88d7e234fb0c98c91511aae3cd519d5e3ed271cae31b20f39cb2a12a3", size = 11779692, upload-time = "2026-04-16T18:46:17.268Z" }, - { url = "https://files.pythonhosted.org/packages/63/b6/aeadee5443e49baa2facd51131159fd6301cc4ccfc1541e4df7b021c37dd/ruff-0.15.11-py3-none-win_arm64.whl", hash = "sha256:063fed18cc1bbe0ee7393957284a6fe8b588c6a406a285af3ee3f46da2391ee4", size = 11032614, upload-time = "2026-04-16T18:46:34.487Z" }, + { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" }, + { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" }, + { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" }, + { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" }, + { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" }, + { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" }, + { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" }, + { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" }, + { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" }, + { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" }, + { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, ] [[package]] @@ -410,26 +425,26 @@ wheels = [ [[package]] name = "ty" -version = "0.0.32" +version = "0.0.33" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/85/7e/2aa791c9ae7b8cd5024cd4122e92267f664ca954cea3def3211919fa3c1f/ty-0.0.32.tar.gz", hash = "sha256:8743174c5f920f6700a4a0c9de140109189192ba16226884cd50095b43b8a45c", size = 5522294, upload-time = "2026-04-20T19:29:01.626Z" } +sdist = { url = "https://files.pythonhosted.org/packages/84/44/9478c50c266826c1bf30d1692e589755bffa8f1c0a3eb7af8a346c255991/ty-0.0.33.tar.gz", hash = "sha256:46d63bda07403322cb6c28ccfdd5536be916e13df725c29f7ccd0a21f06bd9e8", size = 5559373, upload-time = "2026-04-28T10:45:13.18Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/62/eb/1075dc6a49d7acbe2584ae4d5b410c41b1f177a5adcc567e09eca4c69000/ty-0.0.32-py3-none-linux_armv6l.whl", hash = "sha256:dacbc2f6cd698d488ae7436838ff929570455bf94bfa4d9fe57a630c552aff83", size = 10902959, upload-time = "2026-04-20T19:28:31.907Z" }, - { url = "https://files.pythonhosted.org/packages/33/d2/c35fc8bc66e98d1ee9b0f8ed319bf743e450e1f1e997574b178fab75670f/ty-0.0.32-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:914bbc4f605ce2a9e2a78982e28fae1d3359a169d141f9dc3b4c7749cd5eca81", size = 10726172, upload-time = "2026-04-20T19:28:44.765Z" }, - { url = "https://files.pythonhosted.org/packages/96/32/c827da3ca480456fb02d8cea68a2609273b6c220fea0be9a4c8d8470b86e/ty-0.0.32-py3-none-macosx_11_0_arm64.whl", hash = "sha256:4787ac9fe1f86b1f3133f5c6732adbe2df5668b50c679ac6e2d98cd284da812f", size = 10163701, upload-time = "2026-04-20T19:28:27.005Z" }, - { url = "https://files.pythonhosted.org/packages/ba/9e/2734478fbdb90c160cb2813a3916a16a2af5c1e231f87d635f6131d781fb/ty-0.0.32-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8ea0a728af99fe40dd744cba6441a2404f80b7f4bde17aa6da393810af5ea57", size = 10656220, upload-time = "2026-04-20T19:29:03.814Z" }, - { url = "https://files.pythonhosted.org/packages/44/9f/0007da2d35e424debe7e9f86ffbc1ab7f60983cfbc5f0411324ab2de5292/ty-0.0.32-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2850561f9b018ae33d7e5bbfa0ac414d3c518513edcffe43877dc9801446b9c5", size = 10696086, upload-time = "2026-04-20T19:28:46.829Z" }, - { url = "https://files.pythonhosted.org/packages/3b/5e/ce5fd4ec803222ae3e69a76d2a2db2eed55e19f5b131702b9789ef45f93d/ty-0.0.32-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b5fa2fb3c614349ee211d36476b49d88c5ef79a687cdb91b2872ad023b94d2f8", size = 11184800, upload-time = "2026-04-20T19:28:42.57Z" }, - { url = "https://files.pythonhosted.org/packages/6c/46/ebcf67a5999421331214aac51a7464db42de2be15bbe929c612a3ed0b039/ty-0.0.32-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2b89969307ab2417d41c9be8059dd79feea577234e1e10d35132f5495e0d42c6", size = 11718718, upload-time = "2026-04-20T19:28:36.433Z" }, - { url = "https://files.pythonhosted.org/packages/18/2c/2141c86ed0ce0962b45cefb658a95e734f59759d47f20afdcd9c732910a1/ty-0.0.32-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b59868ede9b1d69a088f0d695df52a0061f95fa7baa1d5e0dc6fc9cf06e1334", size = 11346369, upload-time = "2026-04-20T19:28:48.967Z" }, - { url = "https://files.pythonhosted.org/packages/7a/da/ed6f772339cf29bd9a46def9d6db5084689eb574ee4d150ff704224c1ed8/ty-0.0.32-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8300caf35345498e9b9b03e550bba03cee8f5f5f8ab4c83c3b1ff1b7403b7d3a", size = 11280714, upload-time = "2026-04-20T19:28:51.516Z" }, - { url = "https://files.pythonhosted.org/packages/da/9b/c6813987edf4816a40e0c8e408b555f97d3f267c7b3a1688c8bbdf65609c/ty-0.0.32-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:583c7094f4574b02f724db924f98b804d1387a0bd9405ecb5e078cc0f47fbcfb", size = 10638806, upload-time = "2026-04-20T19:28:29.651Z" }, - { url = "https://files.pythonhosted.org/packages/4e/d4/0cefcbd2ad0f3d51762ccf58e652ec7da146eb6ae34f87228f6254bbb8be/ty-0.0.32-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e44ebe1bb4143a5628bc4db67ac0dfebe14594af671e4ee66f6f2e983da56501", size = 10726106, upload-time = "2026-04-20T19:29:06.3Z" }, - { url = "https://files.pythonhosted.org/packages/32/ad/2c8a97f91f06311f4367400f7d13534bbda2522c73c99a3e4c0757dff9b8/ty-0.0.32-py3-none-musllinux_1_2_i686.whl", hash = "sha256:06f17ada3e069cba6148342ef88e9929156beca8473e8d4f101b68f66c75643e", size = 10872951, upload-time = "2026-04-20T19:28:34.077Z" }, - { url = "https://files.pythonhosted.org/packages/ba/68/42293f9248106dd51875120971a5cc6ea315c2c4dcfb8e59aa063aa0af26/ty-0.0.32-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e96e60fa556cec04f15d7ea62d2ceee5982bd389233e961ab9fd42304e278175", size = 11363334, upload-time = "2026-04-20T19:28:54.036Z" }, - { url = "https://files.pythonhosted.org/packages/df/92/be9abf4d3e589ad5023e2ea965b93e204ec856420d46adf73c5c36c04678/ty-0.0.32-py3-none-win32.whl", hash = "sha256:2ff2ebb4986b24aebcf1444db7db5ca41b36086040e95eea9f8fb851c11e805c", size = 10260689, upload-time = "2026-04-20T19:28:56.541Z" }, - { url = "https://files.pythonhosted.org/packages/14/61/dc86acea899349d2579cb8419aecedd83dc504d7d6a10df65eef546c8300/ty-0.0.32-py3-none-win_amd64.whl", hash = "sha256:ba7284a4a954b598c1b31500352b3ec1f89bff533825592b5958848226fdc7ee", size = 11255371, upload-time = "2026-04-20T19:28:39.917Z" }, - { url = "https://files.pythonhosted.org/packages/43/01/beffec56d71ca25b343ede63adb076456b5b3e211f1c066452a44cd120b3/ty-0.0.32-py3-none-win_arm64.whl", hash = "sha256:7e10aadbdbda989a7d567ee6a37f8b98d4d542e31e3b190a2879fd581f75d658", size = 10658087, upload-time = "2026-04-20T19:28:59.286Z" }, + { url = "https://files.pythonhosted.org/packages/e9/24/e287388c63a19191be26b32ff4dbd06029834068150ebe2532939bc4c851/ty-0.0.33-py3-none-linux_armv6l.whl", hash = "sha256:94d0a9d2234261a8911396d59e506b5923fe0971dbda43b9dcea287936887fcc", size = 11021308, upload-time = "2026-04-28T10:45:43.34Z" }, + { url = "https://files.pythonhosted.org/packages/00/ca/ba1eed819895bd239fba8ee35dfcd5fcb266c203b0914a17a59579096bb5/ty-0.0.33-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e4a2b5ba078f90de342f56b5f7979bb77c9b9b1d8625a041352ffc6ee93c4073", size = 10777272, upload-time = "2026-04-28T10:45:32.905Z" }, + { url = "https://files.pythonhosted.org/packages/25/a8/c3131d37b44b3fea1d6654a1c929a0cd0873822f77a90482b8ec28f6fbbd/ty-0.0.33-py3-none-macosx_11_0_arm64.whl", hash = "sha256:84ff5707825e9af9668d2bcf66975f93e520a63b524ab494e3a8265735be2563", size = 10201078, upload-time = "2026-04-28T10:45:23.374Z" }, + { url = "https://files.pythonhosted.org/packages/7b/db/d8e37ff0045810cc65e1ff36aa0da0a2253c05659787ac987df8a16c7897/ty-0.0.33-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e375285736f57886868e7af0b11c7b0ec5b6543fa15e7ad2a714fed9f077d4e0", size = 10732347, upload-time = "2026-04-28T10:45:21.444Z" }, + { url = "https://files.pythonhosted.org/packages/e0/1a/20e83a412506a918e4684fc67b567cf7cc13b105470b3428cb23c3d5aa13/ty-0.0.33-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5680f6350c3b4e46b8bff6d7bb132366ea239463d6cad4892725d06046e65464", size = 10808238, upload-time = "2026-04-28T10:45:38.565Z" }, + { url = "https://files.pythonhosted.org/packages/5d/4b/d0a39f4464dc6cb4cc2c159473ce216bd1846bfb684c0323a3cb36dce5c6/ty-0.0.33-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5535538bad8d0f7e62bcdff02197cdb30e41451d80b35d27e17d128f2e1dc5d", size = 11288348, upload-time = "2026-04-28T10:45:08.419Z" }, + { url = "https://files.pythonhosted.org/packages/35/7e/f1745e0f9583363d7a83d9a4990fc244f76ecc30840ddad83dc16a33c52d/ty-0.0.33-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:da196c42bbbc069e1e21e3e52107c061aa9660352dae57a41930690b56e2c02d", size = 11789907, upload-time = "2026-04-28T10:45:19.064Z" }, + { url = "https://files.pythonhosted.org/packages/a5/71/25f39f46a12d662859d45bc648555d0661044eb43db6b5648c9947487da9/ty-0.0.33-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9281672921ef6d4460e03146b5e6c18cb1a3e3a3b8a1a88f6f33226d05a469b7", size = 11500774, upload-time = "2026-04-28T10:45:48.012Z" }, + { url = "https://files.pythonhosted.org/packages/94/ec/136959ecbb7c71cb90537f5aea441c73f4ab24612868a6ecdc9d7444d32d/ty-0.0.33-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82c1b8f303f82da64e878108e764be3ecbcd7c9903ac0a7f7031614ed00b97ab", size = 11360314, upload-time = "2026-04-28T10:45:05.402Z" }, + { url = "https://files.pythonhosted.org/packages/cf/95/32809575c222f00beed498cb728e9290a0f5009f930025381bb7253b2206/ty-0.0.33-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:efe3af412c9ff67bce5fa37d0a2b0d8555c24072b145a5bac6c79637f1c83abe", size = 10707785, upload-time = "2026-04-28T10:45:10.836Z" }, + { url = "https://files.pythonhosted.org/packages/13/89/c8e9531f7aa4a093359e15fa32c8e1277fbbe90d16894d7c6032d29f4b34/ty-0.0.33-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:aeec29c91ea768601747da546c3efc20b72c2fb1bd52bcc786a5c6eeff51d27b", size = 10834987, upload-time = "2026-04-28T10:45:40.738Z" }, + { url = "https://files.pythonhosted.org/packages/31/16/9835fbcf5338af1a1917bd28fdb8a7193c210b83f243aa286fa9f79cb3ad/ty-0.0.33-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a535977c52bbb5f7e96b8b70a6ad375ad077f4a9ff2492508ea3816a2b403819", size = 10968968, upload-time = "2026-04-28T10:45:30.26Z" }, + { url = "https://files.pythonhosted.org/packages/36/69/64c76aabc1bc70c7f24b686cd93c3407f8ea430905e395f59bf9603ef571/ty-0.0.33-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1d732facf39fcb221ba279d469c5040d37883e964f123b1563888efd34818180", size = 11458077, upload-time = "2026-04-28T10:45:45.971Z" }, + { url = "https://files.pythonhosted.org/packages/91/84/fae27b0c4718776a298690d31ca4cc1995f2e3e1c63a7b59e84c41498e9a/ty-0.0.33-py3-none-win32.whl", hash = "sha256:d90960b574428dc252f85e8598ec5fcb7f619794196b2fc95a90da075ed4681c", size = 10345364, upload-time = "2026-04-28T10:45:16.836Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a0/a2938b23ae3e1a09a2d7c189e2ac5f7113676bae4e0e23948b568e18e5f8/ty-0.0.33-py3-none-win_amd64.whl", hash = "sha256:c1c3aec62c44de610c6e95f0a4e97ac3dbc07934bfdbf1fd90d758c9ff72f48e", size = 11342470, upload-time = "2026-04-28T10:45:26.455Z" }, + { url = "https://files.pythonhosted.org/packages/ab/62/7fb948aace38d2f6329261bb33c035a8484549c74f1db28649c7a4c6fed9/ty-0.0.33-py3-none-win_arm64.whl", hash = "sha256:0d44f99ba1b441e55e2aa301b2ac0a21112784931b46a5f66f4ea9efe5620d97", size = 10742673, upload-time = "2026-04-28T10:45:35.555Z" }, ] [[package]]