diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index d035936..d78bbd3 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -9,13 +9,20 @@ Two identity paths: `X-Wallet-Address` (wallet-based) and `X-Operator-Token` (cr ## 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. Response includes `linked_wallets[]` and `resolved_operator`. +- `assess` / `aassess` — identity gate with policy (paid). Accepts `operator_token` for non-wallet agents. Response includes `linked_wallets[]` and `resolved_operator`. Optional `resolve_signer: { address, network }` opts into server-side wallet-signer-match — the response then carries a `signer_match` block describing whether the supplied signer wallet resolves to the same operator as the claimed `address`. - `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. Accepts optional `idempotency_key` (payment intent id / tx hash) so retries don't inflate transaction_count. +- `telemetry_signer_match` / `atelemetry_signer_match` — fire-and-forget POST to `/v1/telemetry/signer-match`; commerce gate uses this to report `pass` / `wallet_signer_mismatch` / `wallet_auth_requires_wallet_signing` verdicts. + +## Errors + observability + +Typed error subclasses of `AgentScoreError` so callers can `except` on the specific class without parsing `err.code`: `PaymentRequiredError` (402), `TokenExpiredError` (401 token_expired — exposes parsed `verify_url` / `session_id` / `poll_secret` / `poll_url` / `next_steps` / `agent_memory` instance attributes), `InvalidCredentialError` (401 invalid_credential), `QuotaExceededError` (429 quota_exceeded — don't retry), `RateLimitedError` (429 rate_limited — retry after Retry-After), `TimeoutError` (httpx.TimeoutException — note: subclasses `AgentScoreError`, not the builtin; import explicitly from `agentscore.errors` to disambiguate). All non-timeout `httpx.HTTPError` (ConnectError, ProtocolError, NetworkError, etc.) wrap to `AgentScoreError(code="network_error", status_code=0)` for parity with node-sdk. + +`assess()` / `aassess()` responses include an optional `quota` field captured from `X-Quota-Limit` / `X-Quota-Used` / `X-Quota-Reset` response headers, so callers can monitor approach-to-cap proactively before hitting 429. ## Architecture diff --git a/README.md b/README.md index cd4f51d..8dd93e6 100644 --- a/README.md +++ b/README.md @@ -147,18 +147,60 @@ 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: +`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. + +### Typed error classes + +For status-code-specific recovery, the SDK raises typed subclasses of `AgentScoreError`. All inherit from `AgentScoreError` so existing `except AgentScoreError` still catches them. + +| Class | Triggered by | What it adds | +|---|---|---| +| `PaymentRequiredError` | HTTP 402 | The endpoint is not enabled for this account | +| `TokenExpiredError` | HTTP 401 with `error.code = "token_expired"` | Parsed body fields on the instance: `verify_url`, `session_id`, `poll_secret`, `poll_url`, `next_steps`, `agent_memory` — recover without re-parsing `details` | +| `InvalidCredentialError` | HTTP 401 with `error.code = "invalid_credential"` | Permanent — switch tokens or restart | +| `QuotaExceededError` | HTTP 429 with `error.code = "quota_exceeded"` | Account-level cap reached; don't retry | +| `RateLimitedError` | HTTP 429 with `error.code = "rate_limited"` | Per-second sliding-window cap; retry after `Retry-After` | +| `TimeoutError` | `httpx.TimeoutException` (connect/read/write/pool timeout) | Distinct from generic network errors. Note: subclasses `AgentScoreError`, **not** the builtin `TimeoutError` — import explicitly from `agentscore.errors` to disambiguate. | + +All non-timeout `httpx.HTTPError` (ConnectError, ProtocolError, NetworkError, etc.) are wrapped as `AgentScoreError(code="network_error", status_code=0)`. ```python +from agentscore import ( + AgentScore, AgentScoreError, TokenExpiredError, QuotaExceededError, +) +from agentscore.errors import TimeoutError as AgentScoreTimeoutError + try: client.assess("0xabc...", policy={"require_kyc": True}) +except TokenExpiredError as e: + print("Verify at:", e.verify_url, "poll with:", e.poll_secret) +except QuotaExceededError as e: + print("Account quota reached — surface to user; don't retry.") +except AgentScoreTimeoutError: + print("Network timeout — retry with backoff.") 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")) + print(e.code, e.message) +``` + +## Quota observability + +`assess()` (and `aassess()`) responses include an optional `quota` field captured from `X-Quota-Limit` / `X-Quota-Used` / `X-Quota-Reset` response headers. Use it to monitor approach-to-cap proactively (warn at 80%, alert at 95%) before a 429: + +```python +result = client.assess("0xabc...", policy={"require_kyc": True}) +quota = result.get("quota") +if quota and quota["limit"] and quota["used"]: + pct = (quota["used"] / quota["limit"]) * 100 + if pct > 80: + print(f"AgentScore quota at {pct:.1f}% — resets {quota['reset']}") ``` +`quota` is absent when the API doesn't emit the headers (Enterprise / unlimited tiers). + +## Telemetry + +`telemetry_signer_match(payload)` and `atelemetry_signer_match(payload)` are fire-and-forget POSTs to `/v1/telemetry/signer-match` so AgentScore can track aggregate signer-binding behavior across merchants. Used internally by `agentscore-commerce`'s gate; available directly for custom integrations that perform their own wallet-signer-match checks. + ## Documentation - [API Reference](https://docs.agentscore.sh) diff --git a/agentscore/__init__.py b/agentscore/__init__.py index a8c77a0..f61cb77 100644 --- a/agentscore/__init__.py +++ b/agentscore/__init__.py @@ -1,7 +1,15 @@ from importlib.metadata import version as _pkg_version from agentscore.client import AgentScore -from agentscore.errors import AgentScoreError +from agentscore.errors import ( + AgentScoreError, + InvalidCredentialError, + PaymentRequiredError, + QuotaExceededError, + RateLimitedError, + TimeoutError, + TokenExpiredError, +) from agentscore.test_mode import AGENTSCORE_TEST_ADDRESSES, is_agentscore_test_address from agentscore.types import ( AccountVerification, @@ -21,12 +29,15 @@ Network, NextStepsAction, OperatorVerification, + QuotaInfo, Reputation, ReputationResponse, ReputationStatus, + ResolveSigner, SessionCreateRequest, SessionCreateResponse, SessionPollResponse, + SignerMatch, VerificationLevel, WalletAuthRequiresSigningBody, WalletSignerMismatchBody, @@ -52,15 +63,24 @@ "DenialCode", "EntityType", "Grade", + "InvalidCredentialError", "Network", "NextStepsAction", "OperatorVerification", + "PaymentRequiredError", + "QuotaExceededError", + "QuotaInfo", + "RateLimitedError", "Reputation", "ReputationResponse", "ReputationStatus", + "ResolveSigner", "SessionCreateRequest", "SessionCreateResponse", "SessionPollResponse", + "SignerMatch", + "TimeoutError", + "TokenExpiredError", "VerificationLevel", "WalletAuthRequiresSigningBody", "WalletSignerMismatchBody", diff --git a/agentscore/client.py b/agentscore/client.py index 0738043..47857e8 100644 --- a/agentscore/client.py +++ b/agentscore/client.py @@ -8,7 +8,15 @@ import httpx -from agentscore.errors import AgentScoreError +from agentscore.errors import ( + AgentScoreError, + InvalidCredentialError, + PaymentRequiredError, + QuotaExceededError, + RateLimitedError, + TimeoutError, + TokenExpiredError, +) logger = logging.getLogger("agentscore") @@ -24,6 +32,99 @@ def _retry_after_seconds(response: httpx.Response) -> float: return 1.0 +def _extract_quota(response: httpx.Response) -> dict[str, Any] | None: + """Parse ``X-Quota-Limit``, ``X-Quota-Used``, ``X-Quota-Reset`` from response headers. + + Returns ``None`` when none of the three are present (Enterprise / unlimited tiers). + Numeric fields fall back to ``None`` if the header is malformed; reset stays as a + string ('never' or ISO-8601 timestamp). + """ + headers = response.headers + if not hasattr(headers, "get"): + return None + limit = headers.get("x-quota-limit") + used = headers.get("x-quota-used") + reset = headers.get("x-quota-reset") + if limit is None and used is None and reset is None: + return None + return {"limit": _parse_quota_number(limit), "used": _parse_quota_number(used), "reset": reset} + + +def _parse_quota_number(raw: str | None) -> int | None: + if raw is None: + return None + try: + return int(raw) + except (TypeError, ValueError): + return None + + +def _do_sync(send_fn: Callable[[], httpx.Response]) -> httpx.Response: + """Execute the sync request, wrapping every httpx-layer failure in a typed AgentScoreError. + + ``httpx.TimeoutException`` (and subclasses: ConnectTimeout / ReadTimeout / WriteTimeout / + PoolTimeout) becomes our :class:`TimeoutError`. Every other ``httpx.HTTPError`` (ConnectError, + NetworkError, ProtocolError, etc.) becomes :class:`AgentScoreError` with ``code='network_error'`` + and ``status_code=0`` — parity with the node-sdk catch-all. + """ + try: + return send_fn() + except httpx.TimeoutException as exc: + raise TimeoutError(str(exc)) from exc + except httpx.HTTPError as exc: + raise AgentScoreError("network_error", str(exc), 0) from exc + + +async def _do_async(send_fn: Callable[[], Awaitable[httpx.Response]]) -> httpx.Response: + """Async variant of :func:`_do_sync`.""" + try: + return await send_fn() + except httpx.TimeoutException as exc: + raise TimeoutError(str(exc)) from exc + except httpx.HTTPError as exc: + raise AgentScoreError("network_error", str(exc), 0) from exc + + +def _build_error_from_response(response: httpx.Response) -> AgentScoreError: + """Map a non-2xx ``httpx.Response`` to the right typed :class:`AgentScoreError` subclass. + + Reads the body to extract ``error.code`` for discrimination + the rest for ``details``. + Falls through to a generic :class:`AgentScoreError` for codes the SDK doesn't have a + dedicated subclass for. + """ + code = "unknown_error" + message = response.text + details: dict[str, Any] = {} + + try: + body = response.json() + if isinstance(body, dict): + error = body.get("error", {}) + if isinstance(error, dict): + code = error.get("code", code) + message = error.get("message", message) + # Preserve everything except the parsed `error` block so consumers can read + # verify_url, linked_wallets, reasons, etc. for granular denial recovery. + details = {k: v for k, v in body.items() if k != "error"} + except ValueError: + # Body wasn't JSON or didn't have the expected shape — keep defaults. + pass + + if response.status_code == 402: + return PaymentRequiredError(message, details) + if response.status_code == 401: + if code == "token_expired": + return TokenExpiredError(message, details) + if code == "invalid_credential": + return InvalidCredentialError(message, details) + if response.status_code == 429: + if code == "quota_exceeded": + return QuotaExceededError(message, details) + if code == "rate_limited": + return RateLimitedError(message, details) + return AgentScoreError(code, message, response.status_code, details) + + if TYPE_CHECKING: from collections.abc import Awaitable, Callable @@ -36,6 +137,7 @@ def _retry_after_seconds(response: httpx.Response) -> float: DecisionPolicy, Network, ReputationResponse, + ResolveSigner, SessionCreateResponse, SessionPollResponse, ) @@ -88,48 +190,45 @@ def _get_async_client(self) -> httpx.AsyncClient: 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() + response = _do_sync(send_fn) if response.status_code == 429: time.sleep(_retry_after_seconds(response)) - response = send_fn() + response = _do_sync(send_fn) return self._handle_response(response) + def _send_sync_with_response(self, send_fn: Callable[[], httpx.Response]) -> tuple[Any, httpx.Response]: + """Issue a request, retry once on 429, then return ``(parsed_body, response)``. + + Variant of :meth:`_send_sync` that exposes the raw ``httpx.Response`` so callers + (e.g. :meth:`assess`) can read response headers like ``X-Quota-*``. + """ + response = _do_sync(send_fn) + if response.status_code == 429: + time.sleep(_retry_after_seconds(response)) + response = _do_sync(send_fn) + return self._handle_response(response), response + async def _send_async(self, send_fn: Callable[[], Awaitable[httpx.Response]]) -> Any: """Async variant of :meth:`_send_sync`.""" - response = await send_fn() + response = await _do_async(send_fn) if response.status_code == 429: await asyncio.sleep(_retry_after_seconds(response)) - response = await send_fn() + response = await _do_async(send_fn) return self._handle_response(response) - def _handle_response(self, response: httpx.Response) -> Any: + async def _send_async_with_response( + self, send_fn: Callable[[], Awaitable[httpx.Response]] + ) -> tuple[Any, httpx.Response]: + """Async variant of :meth:`_send_sync_with_response`.""" + response = await _do_async(send_fn) if response.status_code == 429: - retry_after = response.headers.get("retry-after", "1") - raise AgentScoreError( - code="rate_limited", - message=f"Rate limit exceeded. Retry after {retry_after}s", - status_code=429, - ) + await asyncio.sleep(_retry_after_seconds(response)) + response = await _do_async(send_fn) + return self._handle_response(response), response + + def _handle_response(self, response: httpx.Response) -> Any: if response.status_code >= 400: - try: - body = response.json() - 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( - code="unknown_error", - message=response.text, - status_code=response.status_code, - ) from err + raise _build_error_from_response(response) try: return response.json() except ValueError as err: @@ -156,8 +255,14 @@ def assess( refresh: bool | None = None, policy: DecisionPolicy | None = None, operator_token: str | None = None, + resolve_signer: ResolveSigner | None = None, ) -> AssessResponse: - """Assess a wallet or operator (paid, writes score on-the-fly).""" + """Assess a wallet or operator (paid, writes score on-the-fly). + + ``resolve_signer`` opts into server-side wallet-signer-match: when supplied, + the API resolves the signer wallet against the claimed ``address`` and emits + a ``signer_match`` block on the response. See :class:`ResolveSigner`. + """ body: dict[str, Any] = {} if address: body["address"] = address @@ -169,8 +274,14 @@ def assess( body["refresh"] = refresh if policy is not None: body["policy"] = dict(policy) + if resolve_signer is not None: + body["resolve_signer"] = dict(resolve_signer) client = self._get_sync_client() - return self._send_sync(lambda: client.post("/v1/assess", json=body)) + data, response = self._send_sync_with_response(lambda: client.post("/v1/assess", json=body)) + quota = _extract_quota(response) + if quota is not None: + data["quota"] = quota + return data def create_session( self, @@ -282,8 +393,13 @@ async def aassess( refresh: bool | None = None, policy: DecisionPolicy | None = None, operator_token: str | None = None, + resolve_signer: ResolveSigner | None = None, ) -> AssessResponse: - """Assess a wallet or operator (paid, writes score on-the-fly).""" + """Assess a wallet or operator (paid, writes score on-the-fly). + + ``resolve_signer`` opts into server-side wallet-signer-match — async mirror of + :meth:`assess`. + """ body: dict[str, Any] = {} if address: body["address"] = address @@ -295,8 +411,14 @@ async def aassess( body["refresh"] = refresh if policy is not None: body["policy"] = dict(policy) + if resolve_signer is not None: + body["resolve_signer"] = dict(resolve_signer) client = self._get_async_client() - return await self._send_async(lambda: client.post("/v1/assess", json=body)) + data, response = await self._send_async_with_response(lambda: client.post("/v1/assess", json=body)) + quota = _extract_quota(response) + if quota is not None: + data["quota"] = quota + return data async def acreate_session( self, @@ -382,6 +504,27 @@ async def aassociate_wallet( client = self._get_async_client() return await self._send_async(lambda: client.post("/v1/credentials/wallets", json=body)) + def telemetry_signer_match(self, payload: dict[str, Any]) -> None: + """Fire-and-forget telemetry — report a wallet-signer-match verdict. + + Used internally by the commerce gate's ``verify_wallet_signer_match`` helper to track + aggregate signer-binding behavior across merchants. Does not raise; failures are + logged at warning level so persistent telemetry outages are visible in ops logs. + """ + try: + client = self._get_sync_client() + client.post("/v1/telemetry/signer-match", json=payload) + except Exception as err: + logger.warning("telemetry_signer_match failed: %s", err) + + async def atelemetry_signer_match(self, payload: dict[str, Any]) -> None: + """Async variant of :meth:`telemetry_signer_match`.""" + try: + client = self._get_async_client() + await client.post("/v1/telemetry/signer-match", json=payload) + except Exception as err: + logger.warning("atelemetry_signer_match failed: %s", err) + def close(self): if self._sync_client: self._sync_client.close() diff --git a/agentscore/errors.py b/agentscore/errors.py index 7796e28..f0b0edc 100644 --- a/agentscore/errors.py +++ b/agentscore/errors.py @@ -26,3 +26,72 @@ def status(self) -> int: Polyglot codebases can use ``err.status`` regardless of which SDK raised the error. """ return self.status_code + + +class PaymentRequiredError(AgentScoreError): + """HTTP 402 — the endpoint is not enabled for this account.""" + + def __init__(self, message: str, details: dict[str, Any] | None = None) -> None: + super().__init__("payment_required", message, 402, details) + + +class TokenExpiredError(AgentScoreError): + """HTTP 401 with ``error.code = 'token_expired'`` — credential is no longer valid. + + Covers both revoked and TTL-expired credentials; the API deliberately doesn't disclose + which. Body carries an auto-minted verification session — exposed here so callers recover + without re-parsing ``details``. + """ + + def __init__(self, message: str, details: dict[str, Any] | None = None) -> None: + super().__init__("token_expired", message, 401, details) + d = details or {} + self.verify_url: str | None = d.get("verify_url") if isinstance(d.get("verify_url"), str) else None + self.session_id: str | None = d.get("session_id") if isinstance(d.get("session_id"), str) else None + self.poll_secret: str | None = d.get("poll_secret") if isinstance(d.get("poll_secret"), str) else None + self.poll_url: str | None = d.get("poll_url") if isinstance(d.get("poll_url"), str) else None + self.next_steps: Any = d.get("next_steps") + self.agent_memory: Any = d.get("agent_memory") + + +class InvalidCredentialError(AgentScoreError): + """HTTP 401 with ``error.code = 'invalid_credential'`` — operator_token doesn't exist. + + Permanent: no auto-session is issued. Caller should switch tokens or restart. + """ + + def __init__(self, message: str, details: dict[str, Any] | None = None) -> None: + super().__init__("invalid_credential", message, 401, details) + + +class QuotaExceededError(AgentScoreError): + """HTTP 429 with ``error.code = 'quota_exceeded'`` — account-level cap reached. + + Don't retry; the cap won't lift through retry alone. Distinct from per-second + :class:`RateLimitedError`. + """ + + def __init__(self, message: str, details: dict[str, Any] | None = None) -> None: + super().__init__("quota_exceeded", message, 429, details) + + +class RateLimitedError(AgentScoreError): + """HTTP 429 with ``error.code = 'rate_limited'`` — per-second sliding-window cap hit. + + Retry after the interval indicated by the ``Retry-After`` header (typically <= 1s). + """ + + def __init__(self, message: str, details: dict[str, Any] | None = None) -> None: + super().__init__("rate_limited", message, 429, details) + + +class TimeoutError(AgentScoreError): + """Request timed out at the network layer (``httpx.TimeoutException``). + + Distinct from generic network errors so callers branch on retry vs surface-to-user + without parsing message strings. Subclasses :class:`AgentScoreError` (not the builtin + ``TimeoutError``) so existing ``except AgentScoreError`` blocks still catch it. + """ + + def __init__(self, message: str) -> None: + super().__init__("timeout", message, 0) diff --git a/agentscore/types.py b/agentscore/types.py index ddc9211..1c5a577 100644 --- a/agentscore/types.py +++ b/agentscore/types.py @@ -169,12 +169,64 @@ class DecisionPolicy(TypedDict, total=False): allowed_jurisdictions: list[str] +class ResolveSigner(TypedDict): + """Server-side wallet-signer-match request. + + When passed to ``assess()`` / ``aassess()``, the API resolves this signer wallet + against the claimed ``address`` and emits a ``signer_match`` block on the response. + Lets commerce gates collapse the legacy 2 follow-up assess calls (one per wallet) + into the gate's primary assess call. Strictly additive — old clients that don't + pass this field see no ``signer_match`` on the response. + """ + + # Recovered payment-signer wallet. ``None`` indicates the rail carries no wallet + # signature (Stripe SPT, card) — produces + # ``signer_match.kind = "wallet_auth_requires_wallet_signing"``. + address: str | None + # Key-derivation family of the signer wallet. + network: Literal["evm", "solana"] + + +class SignerMatch(TypedDict, total=False): + """Server-side wallet-signer-match verdict. + + Emitted on ``AssessResponse.signer_match`` when the request supplied + ``resolve_signer``. Mirrors the verdict shape commerce SDK gates produce locally; + SDK consumers spread this into 403 bodies verbatim instead of re-deriving via 2 + extra ``/v1/assess`` round trips. + + Fields populated depend on ``kind``. + """ + + # ``pass`` — claimed wallet and signer wallet resolve to the same operator (or are + # byte-equal). ``wallet_signer_mismatch`` — operators differ. + # ``wallet_auth_requires_wallet_signing`` — request supplied ``address: None`` (rail + # has no wallet signer); agent should switch to operator_token auth. + kind: Literal["pass", "wallet_signer_mismatch", "wallet_auth_requires_wallet_signing"] + # Operator the claimed wallet resolves to. ``None`` if unlinked. + claimed_operator: str | None + # Operator the signer wallet resolves to. ``None`` if unlinked. + signer_operator: str | None + # Echoed only on ``wallet_auth_requires_wallet_signing`` — the claimed wallet from + # the request. Helps agents construct the recovery message. + claimed_wallet: str + # Echoed on ``wallet_signer_mismatch`` — the claimed wallet, normalized. + expected_signer: str + # Echoed on ``wallet_signer_mismatch`` — the signer wallet, normalized. + actual_signer: str + # Same-operator linked wallets the agent could re-sign from to satisfy the claim. + # Mirrors the top-level ``linked_wallets`` deny-guard — omitted on ``deny`` verdicts. + linked_wallets: list[str] + # JSON-encoded ``{action, steps, user_message}`` envelope for SDK denial bodies. + # Authoritative copy lives server-side; SDK consumers spread this into their 403 + # body without re-parsing. + agent_instructions: str + + class _AssessResponseRequired(TypedDict): decision: str | None decision_reasons: list[str] identity_method: Literal["wallet", "operator_token"] - on_the_fly: bool - updated_at: str | None class PolicyExplanation(TypedDict, total=False): @@ -186,6 +238,21 @@ class PolicyExplanation(TypedDict, total=False): how_to_remedy: str | None +class QuotaInfo(TypedDict): + """Per-account assess quota observability, captured from ``X-Quota-*`` response headers. + + Populated on the success path. Numeric fields are ``None`` when the API didn't include + the header (Enterprise / unlimited tiers, or when the API is configured without a + per-account quota). + """ + + limit: int | None + used: int | None + # ``X-Quota-Reset`` is an ISO-8601 timestamp, or the literal string "never" for lifetime + # caps. The API emits "never" for tiers without a reset. + reset: str | None + + class AssessResponse(_AssessResponseRequired, total=False): operator_verification: OperatorVerification resolved_operator: str | None @@ -196,6 +263,12 @@ class AssessResponse(_AssessResponseRequired, total=False): verify_url: str policy_result: PolicyResult | None explanation: NotRequired[list[PolicyExplanation]] + # Server-side wallet-signer-match verdict, returned only when the request supplied + # ``resolve_signer``. Empty otherwise. + signer_match: NotRequired[SignerMatch] + # Quota state for this account, captured from response headers on the success path. + # Use to monitor approach-to-cap proactively (warn at 80%, alert at 95%) before 429. + quota: NotRequired[QuotaInfo] class SessionCreateRequest(TypedDict, total=False): diff --git a/pyproject.toml b/pyproject.toml index 5429741..0ecc8bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "agentscore-py" -version = "2.0.2" +version = "2.1.0" description = "Python client for the AgentScore APIs" readme = "README.md" license = "MIT" diff --git a/tests/test_client.py b/tests/test_client.py index 5262217..98fa3fe 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -488,18 +488,27 @@ def test_assess_empty_policy_dict_included_in_body(): @respx.mock def test_timeout_error_raises_agentscore_error(): + from agentscore.errors import TimeoutError as AgentScoreTimeoutError + respx.get(f"{BASE_URL}/v1/reputation/{ADDRESS}").mock(side_effect=httpx.TimeoutException("timed out")) client = AgentScore(api_key=API_KEY) - with pytest.raises(httpx.TimeoutException): + with pytest.raises(AgentScoreTimeoutError) as exc_info: client.get_reputation(ADDRESS) + # TimeoutError subclasses AgentScoreError so existing `except AgentScoreError` blocks still catch it. + assert isinstance(exc_info.value, AgentScoreError) + assert exc_info.value.code == "timeout" @respx.mock def test_connect_error_raises_agentscore_error(): respx.get(f"{BASE_URL}/v1/reputation/{ADDRESS}").mock(side_effect=httpx.ConnectError("connection refused")) client = AgentScore(api_key=API_KEY) - with pytest.raises(httpx.ConnectError): + with pytest.raises(AgentScoreError) as exc_info: client.get_reputation(ADDRESS) + # All httpx-layer errors (Timeout, Connect, Protocol, Network) are wrapped — parity with node-sdk. + # ConnectError specifically maps to network_error; TimeoutException is the only one that becomes TimeoutError. + assert exc_info.value.code == "network_error" + assert exc_info.value.status_code == 0 @respx.mock @@ -1446,16 +1455,24 @@ def test_assess_retries_once_on_429_then_succeeds(monkeypatch): @respx.mock def test_assess_raises_when_429_persists_across_retry(monkeypatch): + from agentscore.errors import RateLimitedError + monkeypatch.setattr("agentscore.client.time.sleep", lambda _: None) + # Real API includes the error.code in the 429 body; mock matches that contract. route = respx.post(f"{BASE_URL}/v1/assess").mock( - return_value=httpx.Response(429, headers={"retry-after": "0"}, json={}), + return_value=httpx.Response( + 429, + headers={"retry-after": "0"}, + json={"error": {"code": "rate_limited", "message": "Rate limit exceeded"}}, + ), ) client = AgentScore(api_key=API_KEY) - with pytest.raises(AgentScoreError) as exc_info: + with pytest.raises(RateLimitedError) 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" + assert isinstance(exc_info.value, AgentScoreError) client.close() @@ -1527,3 +1544,472 @@ async def _no_sleep(_seconds: float) -> None: assert route.call_count == 2 assert exc_info.value.status_code == 429 await client.aclose() + + +# --------------------------------------------------------------------------- +# Typed errors +# --------------------------------------------------------------------------- + + +@respx.mock +def test_payment_required_error_on_402(): + from agentscore.errors import PaymentRequiredError + + respx.post(f"{BASE_URL}/v1/assess").mock( + return_value=httpx.Response( + 402, + json={"error": {"code": "payment_required", "message": "Endpoint not enabled"}}, + ), + ) + client = AgentScore(api_key=API_KEY) + with pytest.raises(PaymentRequiredError) as exc_info: + client.assess(address=ADDRESS) + assert isinstance(exc_info.value, AgentScoreError) + assert exc_info.value.status_code == 402 + assert exc_info.value.code == "payment_required" + client.close() + + +@respx.mock +def test_token_expired_error_exposes_parsed_body_fields(): + from agentscore.errors import TokenExpiredError + + respx.post(f"{BASE_URL}/v1/assess").mock( + return_value=httpx.Response( + 401, + json={ + "error": {"code": "token_expired", "message": "Operator token expired"}, + "verify_url": "https://agentscore.sh/verify/abc", + "session_id": "sess_123", + "poll_secret": "ps_456", + "poll_url": "https://api.agentscore.sh/v1/sessions/sess_123", + "next_steps": {"action": "deliver_verify_url_and_poll"}, + "agent_memory": {"pattern_summary": "remembered"}, + }, + ), + ) + client = AgentScore(api_key=API_KEY) + with pytest.raises(TokenExpiredError) as exc_info: + client.assess(address=ADDRESS) + err = exc_info.value + assert err.code == "token_expired" + assert err.status_code == 401 + assert err.verify_url == "https://agentscore.sh/verify/abc" + assert err.session_id == "sess_123" + assert err.poll_secret == "ps_456" + assert err.poll_url == "https://api.agentscore.sh/v1/sessions/sess_123" + assert err.next_steps == {"action": "deliver_verify_url_and_poll"} + assert err.agent_memory == {"pattern_summary": "remembered"} + client.close() + + +@respx.mock +def test_invalid_credential_error_on_401(): + from agentscore.errors import InvalidCredentialError + + respx.post(f"{BASE_URL}/v1/assess").mock( + return_value=httpx.Response( + 401, + json={"error": {"code": "invalid_credential", "message": "Token unknown"}}, + ), + ) + client = AgentScore(api_key=API_KEY) + with pytest.raises(InvalidCredentialError): + client.assess(address=ADDRESS) + client.close() + + +@respx.mock +def test_quota_exceeded_error_on_429(monkeypatch): + from agentscore.errors import QuotaExceededError + + monkeypatch.setattr("agentscore.client.time.sleep", lambda _: None) + respx.post(f"{BASE_URL}/v1/assess").mock( + return_value=httpx.Response( + 429, + headers={"retry-after": "0"}, + json={"error": {"code": "quota_exceeded", "message": "Account quota exceeded"}}, + ), + ) + client = AgentScore(api_key=API_KEY) + with pytest.raises(QuotaExceededError) as exc_info: + client.assess(address=ADDRESS) + assert exc_info.value.status_code == 429 + assert exc_info.value.code == "quota_exceeded" + client.close() + + +@respx.mock +def test_unknown_4xx_falls_through_to_generic_agentscore_error(): + """Status codes / error.code combinations the SDK doesn't have a typed subclass for fall + through to a generic AgentScoreError so consumers can still inspect status + code.""" + respx.post(f"{BASE_URL}/v1/assess").mock( + return_value=httpx.Response(403, json={"error": {"code": "account_cancelled", "message": "Account cancelled"}}), + ) + client = AgentScore(api_key=API_KEY) + with pytest.raises(AgentScoreError) as exc_info: + client.assess(address=ADDRESS) + # Not a typed subclass — generic AgentScoreError. + assert type(exc_info.value) is AgentScoreError + assert exc_info.value.status_code == 403 + assert exc_info.value.code == "account_cancelled" + client.close() + + +# --------------------------------------------------------------------------- +# Quota header capture +# --------------------------------------------------------------------------- + + +@respx.mock +def test_assess_attaches_quota_field_when_x_quota_headers_present(): + respx.post(f"{BASE_URL}/v1/assess").mock( + return_value=httpx.Response( + 200, + headers={ + "X-Quota-Limit": "1000", + "X-Quota-Used": "780", + "X-Quota-Reset": "2026-06-01T00:00:00Z", + }, + json={ + "decision": "allow", + "decision_reasons": [], + "identity_method": "wallet", + "on_the_fly": False, + "updated_at": None, + }, + ), + ) + client = AgentScore(api_key=API_KEY) + res = client.assess(address=ADDRESS) + assert res.get("quota") == {"limit": 1000, "used": 780, "reset": "2026-06-01T00:00:00Z"} + client.close() + + +@respx.mock +def test_assess_omits_quota_when_no_headers_present(): + respx.post(f"{BASE_URL}/v1/assess").mock( + return_value=httpx.Response( + 200, + json={ + "decision": "allow", + "decision_reasons": [], + "identity_method": "wallet", + "on_the_fly": False, + "updated_at": None, + }, + ), + ) + client = AgentScore(api_key=API_KEY) + res = client.assess(address=ADDRESS) + assert "quota" not in res + client.close() + + +@respx.mock +def test_assess_handles_never_reset_for_unlimited_tiers(): + respx.post(f"{BASE_URL}/v1/assess").mock( + return_value=httpx.Response( + 200, + headers={"X-Quota-Limit": "0", "X-Quota-Used": "0", "X-Quota-Reset": "never"}, + json={ + "decision": "allow", + "decision_reasons": [], + "identity_method": "wallet", + "on_the_fly": False, + "updated_at": None, + }, + ), + ) + client = AgentScore(api_key=API_KEY) + res = client.assess(address=ADDRESS) + assert res.get("quota") == {"limit": 0, "used": 0, "reset": "never"} + client.close() + + +@pytest.mark.asyncio +@respx.mock +async def test_aassess_attaches_quota_field_when_x_quota_headers_present(): + respx.post(f"{BASE_URL}/v1/assess").mock( + return_value=httpx.Response( + 200, + headers={"X-Quota-Limit": "500", "X-Quota-Used": "10", "X-Quota-Reset": "2026-07-01T00:00:00Z"}, + json={ + "decision": "allow", + "decision_reasons": [], + "identity_method": "wallet", + "on_the_fly": False, + "updated_at": None, + }, + ), + ) + client = AgentScore(api_key=API_KEY) + res = await client.aassess(address=ADDRESS) + assert res.get("quota") == {"limit": 500, "used": 10, "reset": "2026-07-01T00:00:00Z"} + await client.aclose() + + +# --------------------------------------------------------------------------- +# telemetry_signer_match +# --------------------------------------------------------------------------- + + +@respx.mock +def test_telemetry_signer_match_posts_payload(): + route = respx.post(f"{BASE_URL}/v1/telemetry/signer-match").mock(return_value=httpx.Response(200)) + client = AgentScore(api_key=API_KEY) + client.telemetry_signer_match({"kind": "pass", "signer": "0xabc", "network": "evm"}) + assert route.call_count == 1 + sent = json.loads(route.calls[0].request.content) + assert sent == {"kind": "pass", "signer": "0xabc", "network": "evm"} + client.close() + + +@respx.mock +def test_telemetry_signer_match_swallows_errors_silently(): + respx.post(f"{BASE_URL}/v1/telemetry/signer-match").mock(return_value=httpx.Response(500)) + client = AgentScore(api_key=API_KEY) + # Should NOT raise. + client.telemetry_signer_match({"kind": "wallet_signer_mismatch"}) + client.close() + + +@pytest.mark.asyncio +@respx.mock +async def test_atelemetry_signer_match_posts_payload(): + route = respx.post(f"{BASE_URL}/v1/telemetry/signer-match").mock(return_value=httpx.Response(200)) + client = AgentScore(api_key=API_KEY) + await client.atelemetry_signer_match({"kind": "pass", "network": "solana"}) + assert route.call_count == 1 + await client.aclose() + + +@pytest.mark.asyncio +@respx.mock +async def test_atelemetry_signer_match_swallows_errors_silently(): + respx.post(f"{BASE_URL}/v1/telemetry/signer-match").mock(side_effect=httpx.ConnectError("dns down")) + client = AgentScore(api_key=API_KEY) + # Should NOT raise. + await client.atelemetry_signer_match({"kind": "wallet_auth_requires_wallet_signing"}) + await client.aclose() + + +# --------------------------------------------------------------------------- +# Async timeout +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +@respx.mock +async def test_aassess_timeout_raises_typed_timeout_error(): + from agentscore.errors import TimeoutError as AgentScoreTimeoutError + + respx.post(f"{BASE_URL}/v1/assess").mock(side_effect=httpx.TimeoutException("read timeout")) + client = AgentScore(api_key=API_KEY) + with pytest.raises(AgentScoreTimeoutError) as exc_info: + await client.aassess(address=ADDRESS) + assert isinstance(exc_info.value, AgentScoreError) + assert exc_info.value.code == "timeout" + await client.aclose() + + +# --------------------------------------------------------------------------- +# Helper-function unit tests +# --------------------------------------------------------------------------- + + +def test_parse_quota_number_handles_non_numeric(): + from agentscore.client import _parse_quota_number + + assert _parse_quota_number(None) is None + assert _parse_quota_number("not-a-number") is None + assert _parse_quota_number("123") == 123 + assert _parse_quota_number("0") == 0 + + +def test_retry_after_seconds_handles_invalid(): + from agentscore.client import _retry_after_seconds + + fake_response = httpx.Response(429, headers={"retry-after": "not-a-number"}) + assert _retry_after_seconds(fake_response) == 1.0 + + +def test_extract_quota_returns_none_when_no_headers(): + from agentscore.client import _extract_quota + + fake_response = httpx.Response(200) + assert _extract_quota(fake_response) is None + + +def test_extract_quota_falls_back_to_none_for_malformed_numeric_headers(): + from agentscore.client import _extract_quota + + fake_response = httpx.Response( + 200, + headers={ + "X-Quota-Limit": "not-numeric", + "X-Quota-Used": "also-not-numeric", + "X-Quota-Reset": "2026-06-01", + }, + ) + quota = _extract_quota(fake_response) + assert quota == {"limit": None, "used": None, "reset": "2026-06-01"} + + +@respx.mock +def test_assess_retry_after_429_then_timeout_raises_typed_timeout_error(monkeypatch): + from agentscore.errors import TimeoutError as AgentScoreTimeoutError + + monkeypatch.setattr("agentscore.client.time.sleep", lambda _: None) + respx.post(f"{BASE_URL}/v1/assess").mock( + side_effect=[ + httpx.Response(429, headers={"retry-after": "0"}, json={}), + httpx.TimeoutException("read timeout"), + ], + ) + client = AgentScore(api_key=API_KEY) + with pytest.raises(AgentScoreTimeoutError): + client.assess(address=ADDRESS) + client.close() + + +@pytest.mark.asyncio +@respx.mock +async def test_aassess_retry_after_429_then_timeout_raises_typed_timeout_error(monkeypatch): + from agentscore.errors import TimeoutError as AgentScoreTimeoutError + + async def _no_sleep(_seconds: float) -> None: + return None + + monkeypatch.setattr("agentscore.client.asyncio.sleep", _no_sleep) + respx.post(f"{BASE_URL}/v1/assess").mock( + side_effect=[ + httpx.Response(429, headers={"retry-after": "0"}, json={}), + httpx.TimeoutException("read timeout"), + ], + ) + client = AgentScore(api_key=API_KEY) + with pytest.raises(AgentScoreTimeoutError): + await client.aassess(address=ADDRESS) + await client.aclose() + + +# --------------------------------------------------------------------------- +# Quota capture on retry path + generic 4xx fallthrough + TokenExpiredError edge cases +# --------------------------------------------------------------------------- + + +@respx.mock +def test_assess_captures_quota_from_retry_response_on_429_then_200(monkeypatch): + """The retry on 429 should capture quota headers from the SUCCESSFUL retry response, + not the discarded 429. Regression guard for the requestWithHeaders retry path.""" + monkeypatch.setattr("agentscore.client.time.sleep", lambda _: None) + respx.post(f"{BASE_URL}/v1/assess").mock( + side_effect=[ + httpx.Response(429, headers={"retry-after": "0"}, json={}), + httpx.Response( + 200, + headers={ + "X-Quota-Limit": "500", + "X-Quota-Used": "321", + "X-Quota-Reset": "2026-07-01T00:00:00Z", + }, + json={ + "decision": "allow", + "decision_reasons": [], + "identity_method": "wallet", + "on_the_fly": False, + "updated_at": None, + }, + ), + ], + ) + client = AgentScore(api_key=API_KEY) + res = client.assess(address=ADDRESS) + assert res.get("quota") == {"limit": 500, "used": 321, "reset": "2026-07-01T00:00:00Z"} + client.close() + + +@respx.mock +def test_unknown_400_invalid_request_falls_through_to_generic_agentscore_error(): + """400 codes the SDK doesn't have a typed subclass for must fall through to a generic + AgentScoreError so consumers can still inspect status + code.""" + from agentscore.errors import ( + InvalidCredentialError, + PaymentRequiredError, + QuotaExceededError, + RateLimitedError, + TokenExpiredError, + ) + from agentscore.errors import TimeoutError as AgentScoreTimeoutError + + respx.post(f"{BASE_URL}/v1/assess").mock( + return_value=httpx.Response(400, json={"error": {"code": "invalid_request", "message": "bad body"}}), + ) + client = AgentScore(api_key=API_KEY) + with pytest.raises(AgentScoreError) as exc_info: + client.assess(address=ADDRESS) + err = exc_info.value + # Must NOT be any typed subclass. + assert not isinstance(err, PaymentRequiredError) + assert not isinstance(err, TokenExpiredError) + assert not isinstance(err, InvalidCredentialError) + assert not isinstance(err, QuotaExceededError) + assert not isinstance(err, RateLimitedError) + assert not isinstance(err, AgentScoreTimeoutError) + # Generic — type is exactly AgentScoreError, not a subclass. + assert type(err) is AgentScoreError + assert err.code == "invalid_request" + assert err.status_code == 400 + client.close() + + +@respx.mock +def test_token_expired_error_fields_undefined_when_api_omits_them(): + """If API returns 401 token_expired with no verify_url / session_id / poll_secret / + next_steps / agent_memory in the body, the instance fields stay None — error is still + a TokenExpiredError (not falling through to generic).""" + from agentscore.errors import TokenExpiredError + + respx.post(f"{BASE_URL}/v1/assess").mock( + return_value=httpx.Response(401, json={"error": {"code": "token_expired", "message": "Expired"}}), + ) + client = AgentScore(api_key=API_KEY) + with pytest.raises(TokenExpiredError) as exc_info: + client.assess(address=ADDRESS) + err = exc_info.value + assert err.verify_url is None + assert err.session_id is None + assert err.poll_secret is None + assert err.poll_url is None + assert err.next_steps is None + assert err.agent_memory is None + client.close() + + +@respx.mock +def test_token_expired_error_ignores_wrong_typed_body_fields(): + """If API returns wrong types for the parsed body fields (e.g. number for verify_url), + the instance fields stay None but the raw value is preserved in `details`.""" + from agentscore.errors import TokenExpiredError + + respx.post(f"{BASE_URL}/v1/assess").mock( + return_value=httpx.Response( + 401, + json={ + "error": {"code": "token_expired", "message": "Expired"}, + "verify_url": 12345, # wrong type + "session_id": ["not", "a", "string"], + }, + ), + ) + client = AgentScore(api_key=API_KEY) + with pytest.raises(TokenExpiredError) as exc_info: + client.assess(address=ADDRESS) + err = exc_info.value + # Strings only — wrong types ignored, instance fields stay None. + assert err.verify_url is None + assert err.session_id is None + # Raw values still in details for inspection. + assert err.details["verify_url"] == 12345 + client.close() diff --git a/uv.lock b/uv.lock index a625f0b..78c2d7c 100644 --- a/uv.lock +++ b/uv.lock @@ -4,7 +4,7 @@ requires-python = ">=3.11" [[package]] name = "agentscore-py" -version = "2.0.2" +version = "2.1.0" source = { editable = "." } dependencies = [ { name = "httpx" }, @@ -425,26 +425,26 @@ wheels = [ [[package]] name = "ty" -version = "0.0.33" +version = "0.0.34" source = { registry = "https://pypi.org/simple" } -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" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/69/e24eefe2c35c0fdbdec9b60e162727af669bb76d64d993d982eb67b24c38/ty-0.0.34.tar.gz", hash = "sha256:a6efe66b0f13c03a65e6c72ec9abfe2792e2fd063c74fa67e2c4930e29d661be", size = 5585933, upload-time = "2026-05-01T23:06:46.388Z" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/83/7b/8b85003d6639ef17a97dcbb31f4511cfe78f1c81a964470db100c8c883e7/ty-0.0.34-py3-none-linux_armv6l.whl", hash = "sha256:9ecc3d14f07a95a6ceb88e07f8e62358dbd37325d3d5bd56da7217ff1fef7fb8", size = 11067094, upload-time = "2026-05-01T23:06:21.133Z" }, + { url = "https://files.pythonhosted.org/packages/d7/25/b0098f65b020b015c40567c763fc66fffbec88b2ba6f584bca1e92f05ebb/ty-0.0.34-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0dccffd8a9d02321cd2dee3249df205e26d62694e741f4eeca36b157fd8b419f", size = 10840909, upload-time = "2026-05-01T23:06:18.409Z" }, + { url = "https://files.pythonhosted.org/packages/e4/55/5e4adcf7d2a1006b844903b27cb81244a9b748d850433a46a6c21776c401/ty-0.0.34-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b0ea47a2998e167ab3b21d2f4b5309a9cf33c297809f6d7e3e753252223174d0", size = 10279378, upload-time = "2026-05-01T23:06:37.962Z" }, + { url = "https://files.pythonhosted.org/packages/4d/91/f537dca0db8fe2558e8ab04d8941d687b384fcc1df5eb9023b2db75ac26c/ty-0.0.34-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b37da00b41a118a459ae56d8947e70651073fb33ebfbceb820e4a10b22d5023", size = 10817423, upload-time = "2026-05-01T23:06:26.247Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c4/55a3ad1da2815af1009bdc1b8c90dc11a364cd314e4b48c5128ba9d38859/ty-0.0.34-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:81cbbb93c2342fe3de43e625d3a9eb149633e9f485e816ebf6395d08685355d8", size = 10851826, upload-time = "2026-05-01T23:06:24.198Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/9c7606af22d73fb43ea4369472d9c66ece11231be73b0efe8e3c61655559/ty-0.0.34-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c5b4dea1594a021289e172582df9cde7089dce14b276fc650e7b212b1772e12", size = 11356318, upload-time = "2026-05-01T23:06:51.139Z" }, + { url = "https://files.pythonhosted.org/packages/20/54/bb423f663721ab4138b216425c6b55eaefd3a068243b24d6d8fe988f4e13/ty-0.0.34-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:030fb00aa2d2a5b5ae9d9183d574e0c82dae80566700a7490c43669d8ece40cd", size = 11902968, upload-time = "2026-05-01T23:06:35.82Z" }, + { url = "https://files.pythonhosted.org/packages/b6/22/01122b21ab6b534a2f618c6bbe5f1f7f49fd56f4b2ec8887cd6d40d08fb3/ty-0.0.34-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ae9555e24e36c63a8218e037a5a63f15579eb6aa94f41017e57cd41d335cfb5", size = 11548860, upload-time = "2026-05-01T23:06:42.155Z" }, + { url = "https://files.pythonhosted.org/packages/d1/50/86008b1392ec64bed1957bbcc7aaa43b466b50dfc91bb131841c21d7c5c3/ty-0.0.34-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99eb23df9ed129fc26d1ab00d6f0b8dfe5253b09c2ac6abdb11523fa70d67f10", size = 11457097, upload-time = "2026-05-01T23:06:53.477Z" }, + { url = "https://files.pythonhosted.org/packages/92/3e/4558b2296963ba99c58d8409c57d7db4f3061b656c3613cb21c02c1ef4c2/ty-0.0.34-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:85de45382016eceae69e104815eb2cfa200787df104002e262a86cbd43ed2c02", size = 10798192, upload-time = "2026-05-01T23:06:40.004Z" }, + { url = "https://files.pythonhosted.org/packages/76/bf/650d24402be2ef678528d60caac1d9477a40fc37e3792ecef07834fd7a4a/ty-0.0.34-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:14cb575fb8fa5131f5129d100cfe23c1575d23faf5dfc5158432749a3e38c9b5", size = 10890390, upload-time = "2026-05-01T23:06:33.076Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ef/ccd2ca13906079f7935fd7e067661b24233017f57d987d51d6a121d85bb5/ty-0.0.34-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c6fc0b69d8450e6910ba9db34572b959b81329a97ae273c391f70e9fb6c1aade", size = 11031564, upload-time = "2026-05-01T23:06:55.812Z" }, + { url = "https://files.pythonhosted.org/packages/ba/2d/d27b72005b6f43599e3bcabab0d7135ac0c230b7a307bb99f9eea02c1cda/ty-0.0.34-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:30dfcec2f0fde3993f4f912ed0e057dcbebc8615299f610a4c2ddb7b5a3e1e06", size = 11553430, upload-time = "2026-05-01T23:06:31.096Z" }, + { url = "https://files.pythonhosted.org/packages/a7/12/20812e1ad930b8d4af70eebf19ad23cff6e31efcfa613ef884531fcdbaa1/ty-0.0.34-py3-none-win32.whl", hash = "sha256:97b77ddf007271b812a313a8f0a14929bc5590958433e1fb83ef585676f53342", size = 10436048, upload-time = "2026-05-01T23:06:49.108Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/afa095c5987868fbda27c0f731146ac8e3d07b357adfa83daccaee5b1a16/ty-0.0.34-py3-none-win_amd64.whl", hash = "sha256:1f543968accb952705134028d1fda8656882787dbbc667ad4d6c3ba23791d604", size = 11462526, upload-time = "2026-05-01T23:06:28.514Z" }, + { url = "https://files.pythonhosted.org/packages/63/8f/bf041a06260d77662c0605e56dacfe90b786bf824cbe1aed238d15fe5e84/ty-0.0.34-py3-none-win_arm64.whl", hash = "sha256:ea09108cbcb16b6b06d7596312b433bf49681e78d30e4dc7fb3c1b248a95e09a", size = 10846945, upload-time = "2026-05-01T23:06:44.428Z" }, ] [[package]]