From fd3980cbe6e9505862649eba4a16a0742ab9b77a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=BCseyin=20Deniz?= Date: Tue, 2 Jun 2026 20:47:13 +0300 Subject: [PATCH 01/14] feat(base): preserve REST error bodies (reason_code + reason) in _api_call Backend reason codes (FQ-, P-, T-, RF-) now surface in Result.fail messages instead of being swallowed as generic HTTP status errors. Mirrors TypeScript SDK commit bbabf19. --- src/dexalot_sdk/core/base.py | 40 ++++++++++ tests/unit/core/test_base.py | 138 +++++++++++++++++++++++++++++++++++ 2 files changed, 178 insertions(+) diff --git a/src/dexalot_sdk/core/base.py b/src/dexalot_sdk/core/base.py index deb7062..598dfc3 100644 --- a/src/dexalot_sdk/core/base.py +++ b/src/dexalot_sdk/core/base.py @@ -828,6 +828,46 @@ async def _do_request(): else: return cast(aiohttp.ClientResponse, await _do_request()) + async def _api_call(self, method: str, url: str, **kwargs: Any) -> Any: + """Make a REST API call and return its parsed JSON body. + + On failure, lifts backend ``reasonCode`` + ``reason`` from the + response body (when present) into the raised exception's message. + The Dexalot REST API encodes failures as + ``{"reasonCode": "FQ-015", "reason": "..."}`` (also tolerates the + snake_case ``reason_code`` and the ``message`` alias that some + endpoints emit). Without this lift the raw aiohttp message + collapses everything to ``"Request failed with status code N"``, + which swallows the backend-level reason and makes user-visible + ``Result.fail`` strings useless for diagnosis. + + Network-level failures (no response) and non-HTTP exceptions are + propagated unchanged. + """ + async with await self._make_http_request(method, url, **kwargs) as response: + if response.status >= 400: + body: Any = None + try: + body = await response.json(content_type=None) + except Exception: + body = None + if isinstance(body, dict): + reason_code = body.get("reasonCode") or body.get("reason_code") + reason = body.get("reason") or body.get("message") + if isinstance(reason_code, str): + tail = ( + reason + if isinstance(reason, str) + else f"Request failed with status code {response.status}" + ) + raise RuntimeError(f"{reason_code}: {tail}") + if isinstance(reason, str): + raise RuntimeError(reason) + # Empty body, non-dict body, or parse failure — fall through + # to the generic aiohttp error. + response.raise_for_status() + return await response.json(content_type=None) + def _find_chain_for_provider(self, w3: AsyncWeb3) -> str | None: """ Find which chain a provider belongs to (for backwards compatibility). diff --git a/tests/unit/core/test_base.py b/tests/unit/core/test_base.py index f7499d7..ac37132 100644 --- a/tests/unit/core/test_base.py +++ b/tests/unit/core/test_base.py @@ -2871,3 +2871,141 @@ async def test_rpc_call_with_failover_no_provider_manager_raises(self, client): client._provider_manager = None with pytest.raises(RuntimeError, match="Provider manager is required"): await client._rpc_call_with_failover("Avalanche", "eth.get_block", "latest") + + # -------------------------------------------------------------------- # + # _api_call — REST error body preservation # + # -------------------------------------------------------------------- # + # The Dexalot REST API encodes failures as + # ``{"reasonCode": "...", "reason": "..."}`` (also tolerates the + # snake_case ``reason_code`` and the ``message`` alias that some + # endpoints emit). ``_api_call`` must lift these into the raised + # exception so callers' ``Result.fail`` strings surface the backend + # reason instead of collapsing to ``"... HTTP status N"``. + + @staticmethod + def _http_error_response(status: int, body): + """Build a context-manager mock that yields a response with the given body.""" + + async def _json(content_type=None): + if isinstance(body, Exception): + raise body + return body + + mock_resp = AsyncMock() + mock_resp.status = status + mock_resp.json = _json + + if status >= 400: + import aiohttp + + def _raise(): + raise aiohttp.ClientResponseError( + request_info=MagicMock(), + history=(), + status=status, + message=f"Request failed with status code {status}", + ) + + mock_resp.raise_for_status = MagicMock(side_effect=_raise) + else: + mock_resp.raise_for_status = MagicMock() + + mock_cm = AsyncMock() + mock_cm.__aenter__.return_value = mock_resp + mock_cm.__aexit__.return_value = None + return mock_cm + + async def test_api_call_lifts_reason_code_and_reason(self, client): + """_api_call surfaces backend reasonCode + reason from response body.""" + cm = self._http_error_response( + 400, {"reasonCode": "FQ-015", "reason": "insufficient liquidity"} + ) + with patch.object(client, "_make_http_request", AsyncMock(return_value=cm)): + with pytest.raises(RuntimeError, match=r"^FQ-015: insufficient liquidity$"): + await client._api_call("get", "https://api/x") + + async def test_api_call_lifts_snake_case_aliases(self, client): + """``reason_code`` + ``message`` aliases are also lifted.""" + cm = self._http_error_response( + 400, {"reason_code": "T-TMDQ-01", "message": "amount too small"} + ) + with patch.object(client, "_make_http_request", AsyncMock(return_value=cm)): + with pytest.raises(RuntimeError, match=r"^T-TMDQ-01: amount too small$"): + await client._api_call("get", "https://api/x") + + async def test_api_call_reason_code_without_reason_uses_generic_tail(self, client): + """When reasonCode is set but reason is missing, fall back to a generic tail.""" + cm = self._http_error_response(500, {"reasonCode": "P-OK01"}) + with patch.object(client, "_make_http_request", AsyncMock(return_value=cm)): + with pytest.raises(RuntimeError, match=r"^P-OK01: Request failed with status code 500$"): + await client._api_call("get", "https://api/x") + + async def test_api_call_reason_alone_without_reason_code(self, client): + """Reason alone (no reasonCode) is used as the full message.""" + cm = self._http_error_response(400, {"reason": "something else"}) + with patch.object(client, "_make_http_request", AsyncMock(return_value=cm)): + with pytest.raises(RuntimeError, match=r"^something else$"): + await client._api_call("get", "https://api/x") + + async def test_api_call_message_alias_alone(self, client): + """``message`` alias alone (no reasonCode) is used as the full message.""" + cm = self._http_error_response(400, {"message": "plain reason from message field"}) + with patch.object(client, "_make_http_request", AsyncMock(return_value=cm)): + with pytest.raises(RuntimeError, match=r"^plain reason from message field$"): + await client._api_call("get", "https://api/x") + + async def test_api_call_empty_body_falls_through_to_aiohttp_error(self, client): + """Empty ``{}`` body falls through to the generic aiohttp error.""" + import aiohttp + + cm = self._http_error_response(500, {}) + with patch.object(client, "_make_http_request", AsyncMock(return_value=cm)): + with pytest.raises(aiohttp.ClientResponseError): + await client._api_call("get", "https://api/x") + + async def test_api_call_non_dict_body_falls_through(self, client): + """HTML / non-JSON-object body falls through to the generic aiohttp error.""" + import aiohttp + + cm = self._http_error_response(502, "502 Bad Gateway") + with patch.object(client, "_make_http_request", AsyncMock(return_value=cm)): + with pytest.raises(aiohttp.ClientResponseError): + await client._api_call("get", "https://api/x") + + async def test_api_call_body_parse_failure_falls_through(self, client): + """When body parsing raises, fall through to the generic aiohttp error.""" + import aiohttp + + cm = self._http_error_response(502, ValueError("not json")) + with patch.object(client, "_make_http_request", AsyncMock(return_value=cm)): + with pytest.raises(aiohttp.ClientResponseError): + await client._api_call("get", "https://api/x") + + async def test_api_call_network_error_propagates(self, client): + """Network-level failure (no response) propagates unchanged.""" + import aiohttp + + with patch.object( + client, + "_make_http_request", + AsyncMock(side_effect=aiohttp.ClientConnectionError("Network Error")), + ): + with pytest.raises(aiohttp.ClientConnectionError, match="Network Error"): + await client._api_call("get", "https://api/x") + + async def test_api_call_non_http_error_propagates(self, client): + """Non-HTTP exceptions raised inside the request propagate unchanged.""" + with patch.object( + client, + "_make_http_request", + AsyncMock(side_effect=RuntimeError("generic non-http failure")), + ): + with pytest.raises(RuntimeError, match="generic non-http failure"): + await client._api_call("get", "https://api/x") + + async def test_api_call_success_returns_json(self, client): + """Happy path: returns parsed JSON body.""" + cm = self._http_error_response(200, [{"id": 1}]) + with patch.object(client, "_make_http_request", AsyncMock(return_value=cm)): + data = await client._api_call("get", "https://api/x") + assert data == [{"id": 1}] From 1157c1c7fc5a28d650a00ce66c40c69c356d54d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=BCseyin=20Deniz?= Date: Tue, 2 Jun 2026 20:55:01 +0300 Subject: [PATCH 02/14] feat(base): extend get_deployment with env/contract_type/return_abi filters Backward-compatible. No-args call still resolves with defaults. Cache key includes filter params so variants don't collide. Mirrors TypeScript SDK commit 14265ad. --- src/dexalot_sdk/core/base.py | 138 ++++++--------- tests/unit/core/test_base.py | 335 ++++++++++++++--------------------- 2 files changed, 194 insertions(+), 279 deletions(-) diff --git a/src/dexalot_sdk/core/base.py b/src/dexalot_sdk/core/base.py index 598dfc3..3cece33 100644 --- a/src/dexalot_sdk/core/base.py +++ b/src/dexalot_sdk/core/base.py @@ -1,5 +1,4 @@ import asyncio -import copy import json import logging import os @@ -1715,94 +1714,73 @@ async def get_tokens(self) -> Result[list]: return Result.fail(error_msg) @async_ttl_cached(_STATIC_CACHE) - async def get_deployment(self) -> Result[dict]: - """Fetch contract deployment configuration (addresses and ABIs) from the API. - - Populates and returns ``self.deployments`` with entries for - ``TradePairs``, ``PortfolioMain``, ``PortfolioSub``, and ``MainnetRFQ``. - Also wires up on-chain contract instances (``trade_pairs_contract``, - ``portfolio_sub_contract``, ``portfolio_main_avax_contract``). + async def get_deployment( + self, + *, + env: str | None = None, + contract_type: str = "All", + return_abi: bool = True, + ) -> Result[list[Any]]: + """Fetch deployment configuration from the REST API. + + Backward-compatible: no-args call still resolves successfully and + uses defaults (``env=config.parent_env``, ``contract_type='All'``, + ``return_abi=True``). The backend expects lowercase query param + names (``contracttype``, ``returnabi``). + + Cached for 1 hour (static cache tier). The cache key includes + all three resolved params so filter variants do not collide on + the same cache slot. Note: - Cached for 1 hour (static cache tier). + This method no longer mutates ``self.deployments`` or the + contract handles. Those are populated by + ``initialize_client`` / ``reinitialize`` via the internal + ``_fetch_deployments`` helper. Mirrors TypeScript SDK + commit 14265ad. + + Args: + env: Parent environment (e.g. ``'fuji-multi'``). Defaults to + ``self.parent_env``. + contract_type: One of ``'All'``, ``'Portfolio'``, + ``'TradePairs'``, ``'MainnetRFQ'``, ``'PortfolioMain'``, + ``'PortfolioSub'``, ``'OrderBooks'``. Defaults to ``'All'``. + return_abi: Whether the backend should include ABI payloads. + Defaults to ``True``. Returns: - Result containing the ``deployments`` dictionary on success, or an - error message on failure. + ``Result.ok(list)`` of raw deployment entries on success, + ``Result.fail(msg)`` on REST or network failure. """ + resolved_env = env if env is not None else self.parent_env + if not self._cache_enabled: - # Bypass cache by clearing it for this call - key: tuple[Any, ...] = ("get_deployment", (self,), frozenset()) - _STATIC_CACHE._store.pop(key, None) + # Bypass cache by clearing this call's slot + cache_key: tuple[Any, ...] = ( + "get_deployment", + self.api_base_url, + (), + frozenset( + { + "env": resolved_env, + "contract_type": contract_type, + "return_abi": return_abi, + }.items() + ), + ) + _STATIC_CACHE._store.pop(cache_key, None) try: - # Ensure environments are fetched first (needed for w3_l1, w3_connected_chain) - if not self.chain_config: - envs_result = await self.get_environments() - if not envs_result.success: - return Result.fail(f"Failed to fetch environments: {envs_result.error}") - - # Always fetch fresh from API (cache decorator handles TTL) - # Rebuild deployments dict from API - deploy_url = f"{self.api_base_url}{ENDPOINT_TRADING_DEPLOYMENT}" - - # Initialize deployments structure if not already initialized - if not self.deployments: - self.deployments = { - "TradePairs": {}, - "PortfolioMain": {}, - "PortfolioSub": {}, - "MainnetRFQ": {}, - } - else: - # Clear existing deployments to rebuild fresh - self.deployments = { - "TradePairs": {}, - "PortfolioMain": {}, - "PortfolioSub": {}, - "MainnetRFQ": {}, - } - - # Fetch all contract types in parallel - await asyncio.gather( - self._fetch_contract_deployment(deploy_url, "TradePairs"), - self._fetch_contract_deployment(deploy_url, "Portfolio"), - self._fetch_contract_deployment(deploy_url, "MainnetRFQ"), + data = await self._api_call( + "get", + f"{self.api_base_url}{ENDPOINT_TRADING_DEPLOYMENT}", + params={ + "env": resolved_env, + "contracttype": contract_type, + "returnabi": "true" if return_abi else "false", + }, ) - - return Result.ok(self.deployments) + return Result.ok(data) except Exception as e: error_msg = self._sanitize_error(e, "getting deployment") return Result.fail(error_msg) - - def _apply_deployment_state(self, deployments: dict[str, Any]) -> None: - """Restore deployment mappings and contract handles from cached data.""" - self.deployments = copy.deepcopy(deployments) - if self.w3_l1 and self.deployments.get("TradePairs"): - trade_pairs = self.deployments["TradePairs"] - if trade_pairs.get("address") and trade_pairs.get("abi") is not None: - self.trade_pairs_contract = self.w3_l1.eth.contract( - address=trade_pairs["address"], abi=trade_pairs["abi"] - ) - if self.w3_l1 and self.deployments.get("PortfolioSub"): - portfolio_sub = self.deployments["PortfolioSub"] - if portfolio_sub.get("address") and portfolio_sub.get("abi") is not None: - self.portfolio_sub_contract = self.w3_l1.eth.contract( - address=portfolio_sub["address"], abi=portfolio_sub["abi"] - ) - if self.w3_connected_chain and self.deployments.get("PortfolioMain", {}).get("Avalanche"): - portfolio_main = self.deployments["PortfolioMain"]["Avalanche"] - if portfolio_main.get("address") and portfolio_main.get("abi") is not None: - self.portfolio_main_avax_contract = self.w3_connected_chain.eth.contract( - address=portfolio_main["address"], abi=portfolio_main["abi"] - ) - - async def _rehydrate_cached_get_deployment(self, cached: Result[dict]) -> None: - """Restore deployment state when ``get_deployment`` is served from cache.""" - if not cached.success or cached.data is None: - return - if not self.chain_config or not self.w3_l1: - envs_result = await self.get_environments() - if not envs_result.success: - return - self._apply_deployment_state(cached.data) diff --git a/tests/unit/core/test_base.py b/tests/unit/core/test_base.py index ac37132..252bce5 100644 --- a/tests/unit/core/test_base.py +++ b/tests/unit/core/test_base.py @@ -638,71 +638,47 @@ async def test_get_chains_error(self, client): assert "getting chains" in result.error.lower() or "test error" in result.error.lower() async def test_get_deployment(self, client): - """Test get_deployment.""" - # Mock environments call (needed for get_deployment) - mock_env_resp = [ - { - "env": "fuji-multi-subnet", - "chainid": 432204, - "rpc": "https://subnet.example.com", - } + """No-args ``get_deployment`` returns the raw REST list and uses defaults.""" + mock_deploy_resp = [ + {"env": "fuji-multi-subnet", "contracttype": "TradePairs", "address": "0xTP"}, + {"env": "fuji-multi-subnet", "contracttype": "PortfolioSub", "address": "0xPS"}, + {"env": "fuji-multi-avax", "contracttype": "MainnetRFQ", "address": "0xRFQ"}, ] - mock_deploy_resp_tp = [{"env": "fuji-multi-subnet", "address": "0xTP", "abi": {"abi": []}}] - mock_deploy_resp_port = [ - {"env": "fuji-multi-subnet", "address": "0xPS", "abi": {"abi": []}} - ] - mock_deploy_resp_rfq = [{"env": "fuji-multi-avax", "address": "0xRFQ", "abi": {"abi": []}}] def side_effect(url, params=None, **kwargs): mock_resp = AsyncMock() + mock_resp.status = 200 mock_resp.raise_for_status = MagicMock() - if "environments" in url: - mock_resp.json.return_value = mock_env_resp - elif "deployment" in url: - ctype = params.get("contracttype") - if ctype == "TradePairs": - mock_resp.json.return_value = mock_deploy_resp_tp - elif ctype == "Portfolio": - mock_resp.json.return_value = mock_deploy_resp_port - elif ctype == "MainnetRFQ": - mock_resp.json.return_value = mock_deploy_resp_rfq - + mock_resp.json.return_value = mock_deploy_resp mock_cm = AsyncMock() mock_cm.__aenter__.return_value = mock_resp return mock_cm client._mock_session.get.side_effect = side_effect - client.w3_l1 = MagicMock() - client.w3_l1.eth.contract = MagicMock(return_value=MagicMock()) res = await client.get_deployment() assert res.success - assert "TradePairs" in res.data - assert "PortfolioSub" in res.data - assert "MainnetRFQ" in res.data + assert res.data == mock_deploy_resp + # Default filters propagated to query string + call_kwargs = client._mock_session.get.call_args.kwargs + assert call_kwargs["params"] == { + "env": client.parent_env, + "contracttype": "All", + "returnabi": "true", + } async def test_get_deployment_error(self, client): - """Test get_deployment error handling.""" - # Mock environments call to succeed - mock_env_resp = [ - { - "env": "fuji-multi-subnet", - "chainid": 432204, - "rpc": "https://subnet.example.com", - } - ] + """REST failures bubble through as ``Result.fail``.""" def side_effect(url, params=None, **kwargs): mock_resp = AsyncMock() - if "environments" in url: - mock_resp.json.return_value = mock_env_resp - mock_resp.raise_for_status = MagicMock() - elif "deployment" in url: - # Make deployment fetch raise an exception - def raise_error(): - raise Exception("Test error fetching deployments") + mock_resp.status = 500 + mock_resp.json.return_value = {} # empty body — falls through + + def raise_error(): + raise Exception("Test error fetching deployments") - mock_resp.raise_for_status = raise_error + mock_resp.raise_for_status = raise_error mock_cm = AsyncMock() mock_cm.__aenter__.return_value = mock_resp return mock_cm @@ -2611,46 +2587,37 @@ async def failing_get_environments(): assert "failed to fetch environments" in result.error.lower() async def test_get_deployment_cache_disabled(self, client): - """Test get_deployment with cache disabled.""" + """``get_deployment`` clears its own cache slot when caching is disabled.""" from dexalot_sdk.core.base import _STATIC_CACHE # Disable cache client._cache_enabled = False - # Add something to cache - key = ("get_deployment", (client,), frozenset()) + # Pre-seed the cache slot for the no-args call so we can verify it gets cleared. + # Cache-key shape mirrors the one built inside ``get_deployment``. + key = ( + "get_deployment", + client.api_base_url, + (), + frozenset( + { + "env": client.parent_env, + "contract_type": "All", + "return_abi": True, + }.items() + ), + ) _STATIC_CACHE._store[key] = "cached_data" - # Mock API responses - mock_env_resp = [ - { - "env": "fuji-multi-subnet", - "chainid": 432204, - "rpc": "https://subnet.example.com", - } - ] - mock_deploy_resp_tp = [{"env": "fuji-multi-subnet", "address": "0xTP", "abi": {"abi": []}}] - mock_deploy_resp_port = [ - {"env": "fuji-multi-subnet", "address": "0xPS", "abi": {"abi": []}} - ] - mock_deploy_resp_rfq = [{"env": "fuji-multi-avax", "address": "0xRFQ", "abi": {"abi": []}}] - + # NB: with _cache_enabled=False, the decorator bypasses the cache + # entirely (no read, no write). The body of ``get_deployment`` + # still pops the resolved key for defence-in-depth, so the + # sentinel must disappear. def side_effect(url, params=None, **kwargs): mock_resp = AsyncMock() + mock_resp.status = 200 mock_resp.raise_for_status = MagicMock() - if "environments" in url: - mock_resp.json.return_value = mock_env_resp - elif "deployment" in url: - if params and params.get("contracttype") == "TradePairs": - mock_resp.json.return_value = mock_deploy_resp_tp - elif params and params.get("contracttype") == "PortfolioMain": - mock_resp.json.return_value = mock_deploy_resp_port - elif params and params.get("contracttype") == "MainnetRFQ": - mock_resp.json.return_value = mock_deploy_resp_rfq - else: - mock_resp.json.return_value = [] - else: - mock_resp.json.return_value = [] + mock_resp.json.return_value = [] mock_cm = AsyncMock() mock_cm.__aenter__.return_value = mock_resp return mock_cm @@ -2659,58 +2626,10 @@ def side_effect(url, params=None, **kwargs): result = await client.get_deployment() - # Verify cache was cleared + # Verify the sentinel cache entry was cleared assert key not in _STATIC_CACHE._store assert result.success - async def test_get_deployment_empty_deployments_init(self, client): - """Test get_deployment when self.deployments is empty/None to verify initialization.""" - # Set deployments to empty - client.deployments = None - - # Mock API responses - mock_env_resp = [ - { - "env": "fuji-multi-subnet", - "chainid": 432204, - "rpc": "https://subnet.example.com", - } - ] - mock_deploy_resp_tp = [{"env": "fuji-multi-subnet", "address": "0xTP", "abi": {"abi": []}}] - mock_deploy_resp_port = [ - {"env": "fuji-multi-subnet", "address": "0xPS", "abi": {"abi": []}} - ] - mock_deploy_resp_rfq = [{"env": "fuji-multi-avax", "address": "0xRFQ", "abi": {"abi": []}}] - - def side_effect(url, params=None, **kwargs): - mock_resp = AsyncMock() - mock_resp.raise_for_status = MagicMock() - if "environments" in url: - mock_resp.json.return_value = mock_env_resp - elif "deployment" in url: - if params and params.get("contracttype") == "TradePairs": - mock_resp.json.return_value = mock_deploy_resp_tp - elif params and params.get("contracttype") == "PortfolioMain": - mock_resp.json.return_value = mock_deploy_resp_port - elif params and params.get("contracttype") == "MainnetRFQ": - mock_resp.json.return_value = mock_deploy_resp_rfq - else: - mock_resp.json.return_value = [] - else: - mock_resp.json.return_value = [] - mock_cm = AsyncMock() - mock_cm.__aenter__.return_value = mock_resp - return mock_cm - - client._mock_session.get.side_effect = side_effect - - result = await client.get_deployment() - - # Verify deployments was initialized - assert client.deployments is not None - assert "TradePairs" in client.deployments - assert result.success - async def test_rehydrate_cached_get_environments_ignores_failed_or_empty_results(self, client): """Cached environment rehydration should skip failed and empty results.""" from dexalot_sdk.utils.result import Result @@ -2723,69 +2642,8 @@ async def test_rehydrate_cached_get_environments_ignores_failed_or_empty_results await client._rehydrate_cached_get_environments(Result.ok(None)) assert client.chain_config == {"keep": {"chain_id": 1}} - def test_apply_deployment_state_restores_contract_handles(self, client): - """Deployment rehydration should rebuild all contract handles when providers exist.""" - client.w3_l1 = MagicMock() - client.w3_connected_chain = MagicMock() - trade_pairs_contract = MagicMock() - portfolio_sub_contract = MagicMock() - portfolio_main_contract = MagicMock() - client.w3_l1.eth.contract.side_effect = [trade_pairs_contract, portfolio_sub_contract] - client.w3_connected_chain.eth.contract.return_value = portfolio_main_contract - - deployments = { - "TradePairs": {"address": "0xTP", "abi": []}, - "PortfolioSub": {"address": "0xPS", "abi": []}, - "PortfolioMain": {"Avalanche": {"address": "0xPM", "abi": []}}, - } - - client._apply_deployment_state(deployments) - - assert client.deployments == deployments - assert client.trade_pairs_contract is trade_pairs_contract - assert client.portfolio_sub_contract is portfolio_sub_contract - assert client.portfolio_main_avax_contract is portfolio_main_contract - - async def test_rehydrate_cached_get_deployment_fetches_environments_first(self, client): - """Deployment rehydration should bootstrap env state before rebuilding contracts.""" - from dexalot_sdk.utils.result import Result - - client.chain_config = {} - client.w3_l1 = None - - with ( - patch.object( - client, "get_environments", new=AsyncMock(return_value=Result.ok([])) - ) as envs, - patch.object(client, "_apply_deployment_state") as apply_state, - ): - await client._rehydrate_cached_get_deployment(Result.ok({"TradePairs": {}})) - - envs.assert_awaited_once() - apply_state.assert_called_once_with({"TradePairs": {}}) - - async def test_rehydrate_cached_get_deployment_skips_apply_when_env_bootstrap_fails( - self, client - ): - """Deployment rehydration should bail out if env restoration fails.""" - from dexalot_sdk.utils.result import Result - - client.chain_config = {} - client.w3_l1 = None - - with ( - patch.object( - client, "get_environments", new=AsyncMock(return_value=Result.fail("env down")) - ) as envs, - patch.object(client, "_apply_deployment_state") as apply_state, - ): - await client._rehydrate_cached_get_deployment(Result.ok({"TradePairs": {}})) - - envs.assert_awaited_once() - apply_state.assert_not_called() - # ------------------------------------------------------------------ - # camelCase transform fallbacks + get_chains / get_deployments env-fail + # camelCase transform fallbacks + get_chains env-fail # ------------------------------------------------------------------ def test_transform_environment_camelcase_chain_id_and_env_type(self, client): @@ -2840,18 +2698,6 @@ async def test_get_chains_env_fail_propagates(self, client): assert not result.success assert "env error" in result.error - async def test_get_deployments_env_fail_propagates(self, client): - """get_deployment returns Result.fail when get_environments fails (chain_config empty).""" - client.chain_config = {} # ensure get_environments is called - with patch.object( - client, - "get_environments", - new=AsyncMock(return_value=MagicMock(success=False, error="env down")), - ): - result = await client.get_deployment() - assert not result.success - assert "env down" in result.error - async def test_connect_python314_connector_has_no_enable_cleanup_closed(self, client, mock_env): """On Python >= 3.14 the TCPConnector is created without enable_cleanup_closed.""" client._session = None # force a new session to be created @@ -3009,3 +2855,94 @@ async def test_api_call_success_returns_json(self, client): with patch.object(client, "_make_http_request", AsyncMock(return_value=cm)): data = await client._api_call("get", "https://api/x") assert data == [{"id": 1}] + + # -------------------------------------------------------------------- # + # get_deployment — env / contract_type / return_abi filters # + # -------------------------------------------------------------------- # + # The Dexalot REST deployment endpoint takes optional + # env / contracttype / returnabi filters. The SDK accepts them via + # keyword-only args and resolves defaults (env=parent_env, + # contract_type='All', return_abi=True). The cache key includes all + # three so filter variants do not collide on the same static-cache + # slot. + + async def test_get_deployment_defaults_call_rest_endpoint(self, client): + """No-args ``get_deployment`` calls REST with default filters.""" + api_spy = AsyncMock(return_value=[{"env": "fuji-multi", "contracttype": "All"}]) + with patch.object(client, "_api_call", api_spy): + result = await client.get_deployment() + + assert result.success + assert result.data == [{"env": "fuji-multi", "contracttype": "All"}] + api_spy.assert_awaited_once() + _, kwargs = api_spy.call_args + assert kwargs["params"] == { + "env": client.parent_env, + "contracttype": "All", + "returnabi": "true", + } + + async def test_get_deployment_partial_opts_default_remaining(self, client): + """Partial opts (only ``env``) fall back to defaults for the rest.""" + api_spy = AsyncMock(return_value=[]) + with patch.object(client, "_api_call", api_spy): + await client.get_deployment(env="fuji-multi-subnet") + + _, kwargs = api_spy.call_args + assert kwargs["params"] == { + "env": "fuji-multi-subnet", + "contracttype": "All", + "returnabi": "true", + } + + async def test_get_deployment_full_opts_propagate(self, client): + """All three opts are forwarded to the REST query string.""" + api_spy = AsyncMock(return_value=[]) + with patch.object(client, "_api_call", api_spy): + result = await client.get_deployment( + env="fuji-multi-avax", + contract_type="Portfolio", + return_abi=False, + ) + + assert result.success + assert result.data == [] + _, kwargs = api_spy.call_args + assert kwargs["params"] == { + "env": "fuji-multi-avax", + "contracttype": "Portfolio", + "returnabi": "false", + } + + async def test_get_deployment_distinct_cache_slots_per_filter_combo(self, client): + """Distinct (env, contract_type, return_abi) combos use distinct cache slots.""" + api_spy = AsyncMock(return_value=[]) + with patch.object(client, "_api_call", api_spy): + await client.get_deployment(env="fuji-multi-avax") + await client.get_deployment(env="production-multi-avax") + await client.get_deployment(env="fuji-multi-avax", contract_type="Portfolio") + await client.get_deployment(env="fuji-multi-avax", return_abi=False) + + # Four distinct calls — no cache collision + assert api_spy.await_count == 4 + + async def test_get_deployment_repeated_identical_call_is_cached(self, client): + """Repeated identical calls hit the static cache and skip REST.""" + api_spy = AsyncMock(return_value=[]) + with patch.object(client, "_api_call", api_spy): + await client.get_deployment(env="fuji-multi-avax") + await client.get_deployment(env="fuji-multi-avax") + + assert api_spy.await_count == 1 + + async def test_get_deployment_rest_failure_returns_sanitized_fail(self, client): + """REST failures are caught and returned as Result.fail.""" + with patch.object( + client, + "_api_call", + AsyncMock(side_effect=RuntimeError("FQ-015: insufficient liquidity")), + ): + result = await client.get_deployment(env="fuji-multi-avax") + + assert not result.success + assert "FQ-015" in result.error or "insufficient liquidity" in result.error.lower() From ebb84ed8f5bb45bec3aa5111b2bcf67375bc390c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=BCseyin=20Deniz?= Date: Tue, 2 Jun 2026 21:23:24 +0300 Subject: [PATCH 03/14] feat(transfer): add get_token_usd_prices for portfolio USD valuation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Returns token-symbol → USD-price map from the public /info/usd-prices endpoint. Cached at Semi-Static tier (15m). No auth required. The backend currently emits a flat ``dict[str, str]`` map (string prices, including scientific notation for very small values); the SDK coerces to ``float`` and silently drops entries it cannot interpret. An array-of- objects fallback (``[{"symbol", "price"}, ...]``) is also accepted in case the backend shape ever changes. Cache key is namespaced by ``env`` so testnet and mainnet clients in the same process never collide. The ``env`` query parameter is forwarded for parity with the TypeScript SDK; the backend determines the network from the API host today and ignores it, but the SDK is forward-compatible. Also fixes a latent cache-instance-identity bug in ``_configure_caches``: the function was reassigning the module-level cache globals when a custom TTL was supplied, but subscriber modules (``transfer.py`` / ``swap.py`` / ``clob.py``) capture those globals by reference at module load time. Reassigning would leave the cache the decorator writes to disconnected from the cache test fixtures clear, producing order-dependent test failures. ``_configure_caches`` now mutates the existing ``MemoryCache.ttl`` in place. Mirrors TypeScript SDK commit ea9f4a4. --- src/dexalot_sdk/constants.py | 4 + src/dexalot_sdk/core/base.py | 25 +++-- src/dexalot_sdk/core/transfer.py | 118 ++++++++++++++++++++ tests/unit/core/test_transfer.py | 179 +++++++++++++++++++++++++++++++ 4 files changed, 316 insertions(+), 10 deletions(-) diff --git a/src/dexalot_sdk/constants.py b/src/dexalot_sdk/constants.py index 3b50092..18cb30e 100644 --- a/src/dexalot_sdk/constants.py +++ b/src/dexalot_sdk/constants.py @@ -47,6 +47,10 @@ def ws_api_url_for_rest_base(rest_api_base_url: str | None) -> str: # /api/ tree on the backend (not duplicated under /privapi/). ENDPOINT_TRADING_CANDLE_CHUNK = "/api/trading/candle-chunk" ENDPOINT_STATS_MARKET_SNAPSHOT = "/api/stats/market-snapshot" +# Public info tree (no auth, no `env` query at the backend — the host +# determines the network — but the SDK still forwards `env` for parity +# with the TypeScript SDK and cache-key namespacing on the client). +ENDPOINT_INFO_USD_PRICES = "/api/info/usd-prices" # Default Values DEFAULT_DECIMALS = 18 diff --git a/src/dexalot_sdk/core/base.py b/src/dexalot_sdk/core/base.py index 3cece33..3bb9400 100644 --- a/src/dexalot_sdk/core/base.py +++ b/src/dexalot_sdk/core/base.py @@ -289,24 +289,29 @@ def _initialize_data_structures(self): self.portfolio_sub_contract = None def _configure_caches(self): - """Configure module-level caches with custom TTL if provided.""" + """Configure module-level caches with custom TTL if provided. + + Mutates the existing ``MemoryCache`` instances in place rather + than rebinding the globals. Subscriber modules (``transfer.py``, + ``swap.py``, ``clob.py``) import these by name at module-load + time, so the decorators they apply capture stale references if + the globals are reassigned later — leaving the cache the + decorator writes to disconnected from the cache test fixtures + clear. In-place mutation preserves identity across all + importers. + """ self._cache_enabled = self.config.enable_cache if not self._cache_enabled: return - global _STATIC_CACHE, _SEMI_STATIC_CACHE, _BALANCE_CACHE, _ORDERBOOK_CACHE if self.config.cache_ttl_static != 3600: - _STATIC_CACHE = MemoryCache(ttl_seconds=self.config.cache_ttl_static, max_size=128) + _STATIC_CACHE.ttl = self.config.cache_ttl_static if self.config.cache_ttl_semi_static != 900: - _SEMI_STATIC_CACHE = MemoryCache( - ttl_seconds=self.config.cache_ttl_semi_static, max_size=256 - ) + _SEMI_STATIC_CACHE.ttl = self.config.cache_ttl_semi_static if self.config.cache_ttl_balance != 10: - _BALANCE_CACHE = MemoryCache(ttl_seconds=self.config.cache_ttl_balance, max_size=512) + _BALANCE_CACHE.ttl = self.config.cache_ttl_balance if self.config.cache_ttl_orderbook != 1: - _ORDERBOOK_CACHE = MemoryCache( - ttl_seconds=self.config.cache_ttl_orderbook, max_size=256 - ) + _ORDERBOOK_CACHE.ttl = self.config.cache_ttl_orderbook def _setup_rate_limiters(self): """Set up rate limiters if enabled.""" diff --git a/src/dexalot_sdk/core/transfer.py b/src/dexalot_sdk/core/transfer.py index 8cef738..4695fbd 100644 --- a/src/dexalot_sdk/core/transfer.py +++ b/src/dexalot_sdk/core/transfer.py @@ -1,9 +1,11 @@ import asyncio +import math from typing import Any, cast from ..constants import ( BRIDGE_ID_ICM, BRIDGE_ID_LZ, + ENDPOINT_INFO_USD_PRICES, ENDPOINT_TRADING_TOKENS, GAS_BUFFER, ICM_CHAINS, @@ -1636,3 +1638,119 @@ async def _estimate_gas(): return w3.to_hex(tx_hash) return w3.to_hex(tx_hash) + + # ---------------------------------------------------------------------- + # USD price endpoints (public /api/info/...) + # ---------------------------------------------------------------------- + + @staticmethod + def _coerce_usd_price(raw: Any) -> float | None: + """Coerce a single raw price value into a finite non-negative float. + + Returns ``None`` for anything we cannot confidently interpret as a + price (non-string/non-number, empty string, NaN, ±Infinity, + negative). The backend currently emits decimal strings (including + scientific notation e.g. ``"1.04662e-7"``), so :func:`float` is + the right primitive — but we tolerate plain numbers too in case + the shape ever flips. + """ + if isinstance(raw, bool): + # bool is a subclass of int — disallow explicitly to avoid + # treating ``True``/``False`` as 1/0 prices. + return None + if isinstance(raw, int | float): + n = float(raw) + if not math.isfinite(n) or n < 0: + return None + return n + if not isinstance(raw, str): + return None + trimmed = raw.strip() + if trimmed == "": + return None + try: + n = float(trimmed) + except (ValueError, TypeError): + return None + if not math.isfinite(n) or n < 0: + return None + return n + + @track_method("transfer") + async def get_token_usd_prices(self, env: str | None = None) -> Result[dict[str, float]]: + """Fetch current USD prices for every Dexalot-listed token. + + Public endpoint, no signed auth required. Cached for 15 minutes + (semi-static cache tier). + + The backend currently emits the price map as a flat + ``dict[str, str]`` (string prices, including scientific notation + for very small values); the SDK coerces to ``float`` and silently + drops entries it cannot interpret. An array-of-objects fallback + (``[{"symbol", "price"}, ...]``) is also accepted in case the + backend shape ever changes. + + The ``env`` query parameter is forwarded for parity with the + TypeScript SDK and to namespace the cache key per network; the + backend itself currently determines the network from the API host + and does not consult the parameter. + + Args: + env: Optional environment label override. Defaults to + ``self.parent_env``. + + Returns: + ``Result.ok({symbol: price})`` on success, + ``Result.fail(msg)`` on network failure or unexpected response + shape. + """ + target_env = env if env is not None else self.parent_env + return cast( + Result[dict[str, float]], + await self._get_token_usd_prices_cached(target_env), + ) + + @async_ttl_cached(_SEMI_STATIC_CACHE) + async def _get_token_usd_prices_cached(self, env: str) -> Result[dict[str, float]]: + """Internal cached implementation of get_token_usd_prices.""" + try: + data = await self._api_call( + "get", + f"{self.api_base_url}{ENDPOINT_INFO_USD_PRICES}", + params={"env": env}, + ) + out: dict[str, float] = {} + if isinstance(data, list): + # Forward-compat: array-of-objects shape. + for row in data: + if not isinstance(row, dict): + continue + raw_symbol = row.get("symbol") + if not isinstance(raw_symbol, str): + continue + symbol = raw_symbol.strip() + if not symbol: + continue + price = self._coerce_usd_price(row.get("price")) + if price is None: + continue + out[symbol] = price + return Result.ok(out) + if isinstance(data, dict): + # Current shape: flat ``Record`` map. + for symbol, raw in data.items(): + if not isinstance(symbol, str): + continue + trimmed_sym = symbol.strip() + if not trimmed_sym: + continue + price = self._coerce_usd_price(raw) + if price is None: + continue + out[trimmed_sym] = price + return Result.ok(out) + return Result.fail( + f"Unexpected USD prices response shape: expected object or array, got {type(data).__name__}." + ) + except Exception as e: + return Result.fail(self._sanitize_error(e, "fetching token USD prices")) diff --git a/tests/unit/core/test_transfer.py b/tests/unit/core/test_transfer.py index 623ad71..a013511 100644 --- a/tests/unit/core/test_transfer.py +++ b/tests/unit/core/test_transfer.py @@ -2213,3 +2213,182 @@ async def test_ensure_allowance_no_account(self, client): w3 = self.create_w3() with pytest.raises(ValueError, match="Account is required"): await client._ensure_allowance(w3, "0xToken", "0xSpender", 1000) + + # ---------------------------------------------------------------------- + # get_token_usd_prices — public REST endpoint /api/info/usd-prices + # ---------------------------------------------------------------------- + # Mirrors TypeScript SDK commit ea9f4a4. Backend returns a flat + # ``dict[str, str]`` map (prices are numeric strings, scientific notation + # supported). SDK coerces to floats and silently drops malformed rows. + # An array-of-objects shape is also tolerated as forward-compat. + + async def test_get_token_usd_prices_flat_map_success(self, client): + """get_token_usd_prices parses flat ``dict[str, str]`` map into floats.""" + api_spy = AsyncMock( + return_value={ + "ETH": "1976.908750955006", + "ALOT": "0.041165219801", + "COQ": "1.04662e-7", + } + ) + with patch.object(client, "_api_call", api_spy): + result = await client.get_token_usd_prices() + + assert result.success + assert result.data == { + "ETH": 1976.908750955006, + "ALOT": 0.041165219801, + "COQ": 1.04662e-7, + } + + async def test_get_token_usd_prices_forwards_env_query(self, client): + """``env`` arg overrides default and is forwarded as a query param.""" + api_spy = AsyncMock(return_value={"ETH": "1.0"}) + with patch.object(client, "_api_call", api_spy): + await client.get_token_usd_prices(env="production-multi") + + args, kwargs = api_spy.call_args + assert kwargs["params"] == {"env": "production-multi"} + # Endpoint path must be the hyphenated /api/info/usd-prices + assert "/api/info/usd-prices" in args[1] + + async def test_get_token_usd_prices_defaults_env_to_parent_env(self, client): + """When ``env`` is None, fall back to ``self.parent_env``.""" + api_spy = AsyncMock(return_value={}) + with patch.object(client, "_api_call", api_spy): + await client.get_token_usd_prices() + + _, kwargs = api_spy.call_args + assert kwargs["params"] == {"env": client.parent_env} + + async def test_get_token_usd_prices_array_of_objects_fallback(self, client): + """Array-of-objects shape ``[{symbol, price}, ...]`` is also accepted.""" + api_spy = AsyncMock( + return_value=[ + {"symbol": "ETH", "price": "1976.91"}, + {"symbol": "ALOT", "price": 0.04}, + ] + ) + with patch.object(client, "_api_call", api_spy): + result = await client.get_token_usd_prices() + + assert result.success + assert result.data == {"ETH": 1976.91, "ALOT": 0.04} + + async def test_get_token_usd_prices_drops_malformed_rows(self, client): + """Empty symbol, missing symbol, NaN, negative, non-numeric — silently dropped.""" + api_spy = AsyncMock( + return_value={ + "ETH": "1.0", + "": "5.0", # empty symbol + " ": "9.0", # whitespace symbol + "BAD": "not-a-number", + "NEG": "-1.5", + "NAN": "NaN", + "INF": "Infinity", + "NONE_VAL": None, + "EMPTY": "", + "WHITESPACE": " ", + "GOOD": "2.5", + } + ) + with patch.object(client, "_api_call", api_spy): + result = await client.get_token_usd_prices() + + assert result.success + assert result.data == {"ETH": 1.0, "GOOD": 2.5} + + async def test_get_token_usd_prices_array_drops_malformed_rows(self, client): + """Array shape: missing/empty symbol or bad price silently dropped.""" + api_spy = AsyncMock( + return_value=[ + {"symbol": "ETH", "price": "1.0"}, + {"symbol": "", "price": "5.0"}, # empty symbol + {"price": "9.0"}, # missing symbol + {"symbol": "BAD"}, # missing price + {"symbol": "BAD2", "price": "abc"}, + None, + "not-a-dict", + {"symbol": "GOOD", "price": 2.5}, + ] + ) + with patch.object(client, "_api_call", api_spy): + result = await client.get_token_usd_prices() + + assert result.success + assert result.data == {"ETH": 1.0, "GOOD": 2.5} + + async def test_get_token_usd_prices_accepts_numeric_price(self, client): + """Pure numeric prices (already coerced) are accepted.""" + api_spy = AsyncMock(return_value={"ETH": 1.5, "ALOT": 0.04}) + with patch.object(client, "_api_call", api_spy): + result = await client.get_token_usd_prices() + assert result.success + assert result.data == {"ETH": 1.5, "ALOT": 0.04} + + async def test_get_token_usd_prices_numeric_nan_dropped(self, client): + """Numeric NaN/Infinity/negative values are silently dropped.""" + api_spy = AsyncMock( + return_value={ + "GOOD": 1.5, + "NAN": float("nan"), + "INF": float("inf"), + "NEG_INF": float("-inf"), + "NEG": -1.0, + } + ) + with patch.object(client, "_api_call", api_spy): + result = await client.get_token_usd_prices() + assert result.success + assert result.data == {"GOOD": 1.5} + + async def test_get_token_usd_prices_unexpected_shape_fails(self, client): + """Non-dict, non-array response returns ``Result.fail``.""" + api_spy = AsyncMock(return_value="unexpected-string-body") + with patch.object(client, "_api_call", api_spy): + result = await client.get_token_usd_prices() + assert not result.success + assert "USD prices response shape" in result.error + + async def test_get_token_usd_prices_api_error(self, client): + """REST failure is caught and surfaced as Result.fail.""" + api_spy = AsyncMock(side_effect=RuntimeError("network down")) + with patch.object(client, "_api_call", api_spy): + result = await client.get_token_usd_prices() + assert not result.success + + async def test_get_token_usd_prices_cached_per_env(self, client): + """Cache key namespaced by env so testnet/mainnet do not collide.""" + client._cache_enabled = True + api_spy = AsyncMock(return_value={"ETH": "1.0"}) + with patch.object(client, "_api_call", api_spy): + await client.get_token_usd_prices(env="fuji-multi") + await client.get_token_usd_prices(env="fuji-multi") # cache hit + await client.get_token_usd_prices(env="production-multi") # distinct slot + + assert api_spy.await_count == 2 + + async def test_get_token_usd_prices_cache_bypass_when_disabled(self, client): + """When _cache_enabled is False, every call hits REST.""" + client._cache_enabled = False + api_spy = AsyncMock(return_value={"ETH": "1.0"}) + with patch.object(client, "_api_call", api_spy): + await client.get_token_usd_prices(env="fuji-multi") + await client.get_token_usd_prices(env="fuji-multi") + assert api_spy.await_count == 2 + + def test_coerce_usd_price_rejects_bool(self): + """``True``/``False`` must not be coerced to 1/0 prices.""" + from dexalot_sdk.core.transfer import TransferClient + + assert TransferClient._coerce_usd_price(True) is None + assert TransferClient._coerce_usd_price(False) is None + + async def test_get_token_usd_prices_drops_non_string_keys(self, client): + """Flat-map keys that are not strings are skipped.""" + # Backend never emits these but be defensive against pathological proxies. + api_spy = AsyncMock(return_value={123: "1.0", "GOOD": "2.0"}) + with patch.object(client, "_api_call", api_spy): + result = await client.get_token_usd_prices() + assert result.success + assert result.data == {"GOOD": 2.0} From ea8ae386e8b32716b3c3ce8bb1ab02768bddc8a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=BCseyin=20Deniz?= Date: Tue, 2 Jun 2026 21:26:15 +0300 Subject: [PATCH 04/14] feat(transfer): add price history methods (daily + hourly) get_token_price_history and get_token_hourly_price_history return ascending-time-ordered ``list[PricePoint]`` from the public ``/api/info/token-usd-price-history`` and ``/api/info/token-usd-price-history-hourly`` endpoints. Static-tier cached (1 h TTL) with path-namespaced keys so daily and hourly never collide; past prices don't change. Both methods delegate to a shared ``_fetch_price_history`` helper that normalizes the raw ``{date: ISO-8601-string, price: stringified-decimal}`` rows (descending by date on the wire) into ascending unix-seconds + numeric ``PricePoint`` list, tolerates ``ts``/``timestamp``/``time`` numeric aliases (values ``>= 1e12`` treated as milliseconds), and silently drops malformed rows. Optional ``from_ts``/``to_ts`` window is forwarded to the backend (ignored today, forward-compat) and additionally applied client-side so the caller's range contract holds. The Python signature uses keyword-only ``from_ts``/``to_ts`` instead of the TypeScript ``opts: {from, to}`` shape to avoid the ``from`` keyword conflict in Python. New ``PricePoint`` frozen dataclass exported from ``dexalot_sdk.core.transfer`` (kept in the module that produces it, matching the existing ``ResolvedChain`` convention in ``base.py``). Mirrors TypeScript SDK commit 63b2e61. --- src/dexalot_sdk/constants.py | 8 ++ src/dexalot_sdk/core/transfer.py | 202 ++++++++++++++++++++++++++++++- tests/unit/core/test_transfer.py | 186 ++++++++++++++++++++++++++++ 3 files changed, 395 insertions(+), 1 deletion(-) diff --git a/src/dexalot_sdk/constants.py b/src/dexalot_sdk/constants.py index 18cb30e..e99f78e 100644 --- a/src/dexalot_sdk/constants.py +++ b/src/dexalot_sdk/constants.py @@ -51,6 +51,14 @@ def ws_api_url_for_rest_base(rest_api_base_url: str | None) -> str: # determines the network — but the SDK still forwards `env` for parity # with the TypeScript SDK and cache-key namespacing on the client). ENDPOINT_INFO_USD_PRICES = "/api/info/usd-prices" +# Daily and hourly USD price history per token. Backend ignores +# `from`/`to`/`env` query params today (host determines network, range +# is fixed window) but the SDK forwards them for forward-compat and +# cache-key namespacing; if a caller supplies `from_ts`/`to_ts` the +# response is additionally filtered client-side so the contract remains +# useful when the backend gains range support. +ENDPOINT_INFO_PRICE_HISTORY = "/api/info/token-usd-price-history" +ENDPOINT_INFO_HOURLY_PRICE_HISTORY = "/api/info/token-usd-price-history-hourly" # Default Values DEFAULT_DECIMALS = 18 diff --git a/src/dexalot_sdk/core/transfer.py b/src/dexalot_sdk/core/transfer.py index 4695fbd..6e7ecd3 100644 --- a/src/dexalot_sdk/core/transfer.py +++ b/src/dexalot_sdk/core/transfer.py @@ -1,10 +1,14 @@ import asyncio import math +from dataclasses import dataclass +from datetime import datetime from typing import Any, cast from ..constants import ( BRIDGE_ID_ICM, BRIDGE_ID_LZ, + ENDPOINT_INFO_HOURLY_PRICE_HISTORY, + ENDPOINT_INFO_PRICE_HISTORY, ENDPOINT_INFO_USD_PRICES, ENDPOINT_TRADING_TOKENS, GAS_BUFFER, @@ -20,7 +24,23 @@ ) from ..utils.observability import track_method from ..utils.result import Result -from .base import _BALANCE_CACHE, _SEMI_STATIC_CACHE, DexalotBaseClient +from .base import _BALANCE_CACHE, _SEMI_STATIC_CACHE, _STATIC_CACHE, DexalotBaseClient + + +@dataclass(frozen=True) +class PricePoint: + """One USD price observation in a price-history series. + + Returned by :meth:`TransferClient.get_token_price_history` (daily) and + :meth:`TransferClient.get_token_hourly_price_history` (hourly). The + backend ships rows as ``{date: ISO-8601-string, price: stringified-decimal}`` + sorted descending by date; the SDK normalizes to ascending unix-seconds + + numeric price so callers can chart, filter, or interpolate without + re-parsing. + """ + + timestamp: int # unix seconds (UTC) + price: float class TransferClient(DexalotBaseClient): @@ -1754,3 +1774,183 @@ async def _get_token_usd_prices_cached(self, env: str) -> Result[dict[str, float ) except Exception as e: return Result.fail(self._sanitize_error(e, "fetching token USD prices")) + + # ---------------------------------------------------------------------- + # USD price history (daily + hourly, public /api/info/...) + # ---------------------------------------------------------------------- + + @staticmethod + def _coerce_timestamp_seconds(raw: Any) -> int | None: + """Coerce a raw numeric / numeric-string timestamp to unix seconds. + + Values ``>= 1e12`` are treated as milliseconds and divided by 1000 + (a 32-bit second-precision unix epoch maxes out at ``2^31 ≈ 2.1e9``, + so 1e12 is a safe boundary). + """ + if isinstance(raw, bool): + return None + if isinstance(raw, int | float): + n = float(raw) + elif isinstance(raw, str): + trimmed = raw.strip() + if trimmed == "": + return None + try: + n = float(trimmed) + except ValueError: + return None + else: + return None + if not math.isfinite(n) or n < 0: + return None + if n >= 1e12: + n = math.floor(n / 1000) + return int(math.floor(n)) + + @staticmethod + def _extract_history_timestamp(row: dict[str, Any]) -> int | None: + """Pull a timestamp out of one raw price-history row. + + Backend ships ``date`` as ISO-8601; we additionally accept the + numeric aliases ``ts`` / ``timestamp`` / ``time`` (value treated + as milliseconds when ``>= 1e12``) so the contract is stable if + the shape ever flips. Returns unix seconds (UTC) or ``None``. + """ + raw_date = row.get("date") + if isinstance(raw_date, str) and raw_date.strip(): + try: + # Accept ``...Z`` and timezone-naive ISO strings. Python + # 3.11+ ``fromisoformat`` handles ``Z`` directly via the + # explicit ``+00:00`` replacement. + dt = datetime.fromisoformat(raw_date.replace("Z", "+00:00")) + except ValueError: + dt = None + if dt is not None: + return int(dt.timestamp()) + for key in ("ts", "timestamp", "time"): + if key in row: + coerced = TransferClient._coerce_timestamp_seconds(row[key]) + if coerced is not None: + return coerced + return None + + @track_method("transfer") + async def get_token_price_history( + self, + token: str, + *, + from_ts: int | None = None, + to_ts: int | None = None, + ) -> Result[list[PricePoint]]: + """Daily USD price history for one token. + + Returns an ascending-time ordered ``list[PricePoint]`` from the + public ``/api/info/token-usd-price-history`` endpoint. Past + prices do not change, so results are cached in the static tier + (1 hour TTL); the cache key is path-namespaced so daily and + hourly never collide. + + The optional ``from_ts`` / ``to_ts`` window is in unix seconds. + The backend currently ignores it (the host fixes the network and + the lookback) but the SDK forwards both for forward-compat and + additionally filters the returned series client-side so the + caller's range contract holds regardless of backend behavior. + + No authentication required (public endpoint). + """ + return await self._fetch_price_history( + ENDPOINT_INFO_PRICE_HISTORY, token, from_ts, to_ts + ) + + @track_method("transfer") + async def get_token_hourly_price_history( + self, + token: str, + *, + from_ts: int | None = None, + to_ts: int | None = None, + ) -> Result[list[PricePoint]]: + """Hourly USD price history for one token. + + Same contract as :meth:`get_token_price_history` (ascending-time + ``list[PricePoint]``, static-tier 1 h cache, optional + ``from_ts``/``to_ts`` window applied client-side) but routes + through the hourly endpoint. Useful when more granular series + is needed than the daily variant — backend currently returns the + trailing ~24 h at 3-hour granularity. + + No authentication required (public endpoint). + """ + return await self._fetch_price_history( + ENDPOINT_INFO_HOURLY_PRICE_HISTORY, token, from_ts, to_ts + ) + + async def _fetch_price_history( + self, + path: str, + token: str, + from_ts: int | None, + to_ts: int | None, + ) -> Result[list[PricePoint]]: + """Shared validation + cache delegation for daily / hourly history. + + Validates the token symbol up-front (avoids polluting the cache + with validation failures) then delegates to the cached helper. + """ + token_result = validate_token_symbol(token, "token") + if not token_result.success: + return cast(Result[list[PricePoint]], token_result) + sym = self._normalize_user_token(token) + return cast( + Result[list[PricePoint]], + await self._fetch_price_history_cached(path, sym, from_ts, to_ts), + ) + + @async_ttl_cached(_STATIC_CACHE) + async def _fetch_price_history_cached( + self, + path: str, + token: str, + from_ts: int | None, + to_ts: int | None, + ) -> Result[list[PricePoint]]: + """Internal cached implementation of price-history fetch. + + Cache key includes ``(path, token, from_ts, to_ts)`` so daily + and hourly never collide on the same slot even with identical + ``(token, from_ts, to_ts)`` tuples. + """ + try: + params: dict[str, Any] = {"token": token} + if from_ts is not None: + params["from"] = from_ts + if to_ts is not None: + params["to"] = to_ts + data = await self._api_call( + "get", + f"{self.api_base_url}{path}", + params=params, + ) + if not isinstance(data, list): + return Result.fail( + f"Unexpected price history response shape: expected array, got {type(data).__name__}." + ) + points: list[PricePoint] = [] + for row in data: + if not isinstance(row, dict): + continue + ts = self._extract_history_timestamp(row) + if ts is None: + continue + price = self._coerce_usd_price(row.get("price")) + if price is None: + continue + if from_ts is not None and ts < from_ts: + continue + if to_ts is not None and ts > to_ts: + continue + points.append(PricePoint(timestamp=ts, price=price)) + points.sort(key=lambda p: p.timestamp) + return Result.ok(points) + except Exception as e: + return Result.fail(self._sanitize_error(e, "fetching token price history")) diff --git a/tests/unit/core/test_transfer.py b/tests/unit/core/test_transfer.py index a013511..38e775e 100644 --- a/tests/unit/core/test_transfer.py +++ b/tests/unit/core/test_transfer.py @@ -2392,3 +2392,189 @@ async def test_get_token_usd_prices_drops_non_string_keys(self, client): result = await client.get_token_usd_prices() assert result.success assert result.data == {"GOOD": 2.0} + + # ---------------------------------------------------------------------- + # get_token_price_history / get_token_hourly_price_history + # ---------------------------------------------------------------------- + # Mirrors TypeScript SDK commit 63b2e61. + + async def test_get_token_price_history_normalizes_and_sorts(self, client): + """ISO dates → unix seconds, prices → float, sorted ascending.""" + from dexalot_sdk.core.transfer import PricePoint + + api_spy = AsyncMock( + return_value=[ + {"date": "2026-06-02T00:00:00Z", "price": "10.5"}, + {"date": "2026-06-01T00:00:00Z", "price": "9.0"}, + {"date": "2026-05-31T00:00:00Z", "price": "8.25"}, + ] + ) + with patch.object(client, "_api_call", api_spy): + result = await client.get_token_price_history("ALOT") + + assert result.success + assert isinstance(result.data[0], PricePoint) + # Ascending by timestamp + assert [p.timestamp for p in result.data] == sorted( + p.timestamp for p in result.data + ) + assert result.data[0].price == 8.25 + assert result.data[-1].price == 10.5 + + async def test_get_token_price_history_endpoint_path(self, client): + """Daily method hits the daily endpoint with ``token`` param.""" + api_spy = AsyncMock(return_value=[]) + with patch.object(client, "_api_call", api_spy): + await client.get_token_price_history("ALOT") + args, kwargs = api_spy.call_args + assert "/api/info/token-usd-price-history" in args[1] + assert "hourly" not in args[1] + assert kwargs["params"]["token"] == "ALOT" + + async def test_get_token_hourly_price_history_endpoint_path(self, client): + """Hourly method hits the hourly endpoint with ``token`` param.""" + api_spy = AsyncMock(return_value=[]) + with patch.object(client, "_api_call", api_spy): + await client.get_token_hourly_price_history("ALOT") + args, kwargs = api_spy.call_args + assert args[1].endswith("/api/info/token-usd-price-history-hourly") + assert kwargs["params"]["token"] == "ALOT" + + async def test_get_token_price_history_forwards_from_and_to(self, client): + """``from_ts``/``to_ts`` forwarded as ``from``/``to`` query params.""" + api_spy = AsyncMock(return_value=[]) + with patch.object(client, "_api_call", api_spy): + await client.get_token_price_history( + "ALOT", from_ts=1700000000, to_ts=1700864000 + ) + _, kwargs = api_spy.call_args + assert kwargs["params"] == { + "token": "ALOT", + "from": 1700000000, + "to": 1700864000, + } + + async def test_get_token_price_history_client_side_filter(self, client): + """Backend ignores ``from``/``to``; SDK filters client-side.""" + # 2026-06-01 00:00 UTC = 1780272000 + # 2026-06-02 00:00 UTC = 1780358400 + # 2026-06-03 00:00 UTC = 1780444800 + api_spy = AsyncMock( + return_value=[ + {"date": "2026-06-01T00:00:00Z", "price": "9.0"}, + {"date": "2026-06-02T00:00:00Z", "price": "10.0"}, + {"date": "2026-06-03T00:00:00Z", "price": "11.0"}, + ] + ) + with patch.object(client, "_api_call", api_spy): + result = await client.get_token_price_history( + "ALOT", from_ts=1780358400, to_ts=1780358400 + ) + assert result.success + # Only the middle row falls inside [from, to] inclusively + assert len(result.data) == 1 + assert result.data[0].price == 10.0 + + async def test_get_token_price_history_drops_malformed_rows(self, client): + """Invalid date / NaN price / non-dict rows silently dropped.""" + api_spy = AsyncMock( + return_value=[ + {"date": "2026-06-01T00:00:00Z", "price": "9.0"}, + {"date": "not-a-date", "price": "10.0"}, + {"date": "2026-06-02T00:00:00Z", "price": "abc"}, + {"date": "2026-06-03T00:00:00Z", "price": "-1.0"}, + {"price": "5.0"}, # missing date + {"date": "2026-06-04T00:00:00Z"}, # missing price + None, + "not-a-dict", + ] + ) + with patch.object(client, "_api_call", api_spy): + result = await client.get_token_price_history("ALOT") + assert result.success + assert len(result.data) == 1 + assert result.data[0].price == 9.0 + + async def test_get_token_price_history_rejects_non_array_response(self, client): + """A non-list response surfaces as Result.fail with a shape hint.""" + api_spy = AsyncMock(return_value={"unexpected": "object"}) + with patch.object(client, "_api_call", api_spy): + result = await client.get_token_price_history("ALOT") + assert not result.success + assert "price history response shape" in result.error + + async def test_get_token_price_history_invalid_token_returns_fail(self, client): + """Invalid token symbol fails validation before any REST call.""" + api_spy = AsyncMock(return_value=[]) + with patch.object(client, "_api_call", api_spy): + result = await client.get_token_price_history("") + assert not result.success + api_spy.assert_not_called() + + async def test_get_token_price_history_api_error(self, client): + """REST failure is caught and surfaced as Result.fail.""" + api_spy = AsyncMock(side_effect=RuntimeError("network boom")) + with patch.object(client, "_api_call", api_spy): + result = await client.get_token_price_history("ALOT") + assert not result.success + + async def test_price_history_cache_key_distinguishes_daily_vs_hourly(self, client): + """Daily and hourly methods do not share the same cache slot.""" + client._cache_enabled = True + api_spy = AsyncMock(return_value=[]) + with patch.object(client, "_api_call", api_spy): + await client.get_token_price_history("ALOT") + await client.get_token_price_history("ALOT") # cache hit + await client.get_token_hourly_price_history("ALOT") # distinct slot + assert api_spy.await_count == 2 + + async def test_price_history_cache_key_distinguishes_opts(self, client): + """Different (token, from_ts, to_ts) tuples use distinct cache slots.""" + client._cache_enabled = True + api_spy = AsyncMock(return_value=[]) + with patch.object(client, "_api_call", api_spy): + await client.get_token_price_history("ALOT") + await client.get_token_price_history("ALOT", from_ts=100) + await client.get_token_price_history("ALOT", from_ts=100, to_ts=200) + await client.get_token_price_history("ETH") + assert api_spy.await_count == 4 + + async def test_get_token_price_history_accepts_ts_numeric_alias(self, client): + """A row with no ``date`` but a numeric ``ts`` is accepted.""" + api_spy = AsyncMock( + return_value=[ + {"ts": 1700000000, "price": "5.0"}, + {"timestamp": "1700000100", "price": "6.0"}, + {"time": 1700000200000, "price": "7.0"}, # milliseconds + ] + ) + with patch.object(client, "_api_call", api_spy): + result = await client.get_token_price_history("ALOT") + assert result.success + assert len(result.data) == 3 + assert result.data[0].timestamp == 1700000000 + assert result.data[1].timestamp == 1700000100 + assert result.data[2].timestamp == 1700000200 # 1.7e12 / 1000 + + def test_coerce_timestamp_seconds_edge_cases(self): + """Direct coverage for the timestamp coercer's branches.""" + from dexalot_sdk.core.transfer import TransferClient + + # bool → None + assert TransferClient._coerce_timestamp_seconds(True) is None + # whitespace-only string → None + assert TransferClient._coerce_timestamp_seconds(" ") is None + # non-numeric string → None + assert TransferClient._coerce_timestamp_seconds("not-a-number") is None + # non-string/non-number → None + assert TransferClient._coerce_timestamp_seconds([1, 2]) is None + # negative → None + assert TransferClient._coerce_timestamp_seconds(-1) is None + # NaN → None + assert TransferClient._coerce_timestamp_seconds(float("nan")) is None + # milliseconds boundary + assert TransferClient._coerce_timestamp_seconds(1700000000000) == 1700000000 + # numeric string is accepted + assert TransferClient._coerce_timestamp_seconds("1700000000") == 1700000000 + # plain number passthrough + assert TransferClient._coerce_timestamp_seconds(1700000000) == 1700000000 From eaf45a64274a95de790f6c11457b28d8bc508493 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=BCseyin=20Deniz?= Date: Tue, 2 Jun 2026 21:31:36 +0300 Subject: [PATCH 05/14] feat(transfer): add get_combined_transfers for unified history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed GET returning canonical ``list[Transfer]`` (snake_case fields, status/action_type/bridge lifted from numeric enums) with kind/from_ts/to_ts/limit/offset filters from the signed REST endpoint ``/api/trading/signed/transferscombined`` (NOT under ``/privapi/`` — empirically confirmed via OPTIONS preflight: 404 vs 204). Balance-tier cached (10 s TTL) per ``(address, kind, from_ts, to_ts, limit, offset)``. Backend pagination uses ``itemsperpage`` / ``pageno``; the SDK exposes the more conventional ``limit`` / ``offset`` signature and translates internally (``pageno = (offset // limit) + 1``). ``kind`` is forwarded to the backend as ``symbol``; ``from_ts`` / ``to_ts`` as ``periodfrom`` / ``periodto``. Response shape is ``{count, rows}`` with a bare-list fallback for forward-compat. ``quantity`` / ``fee`` arrive as already-display-decimal numeric strings from the backend (the official frontend reads them straight through Big.js — no decimals divide); the SDK coerces to ``float`` via the shared ``_coerce_usd_price`` helper. Unknown ``action_type`` / ``status`` enums and rows missing required fields are silently dropped so a single bad row does not poison the page. Unknown ``bridge`` enum falls back to ``"NATIVE"`` to match the frontend's display-only treatment. To support a signed call outside the CLOB surface, ``_get_auth_headers`` is lifted from ``CLOBClient`` to ``DexalotBaseClient``. The CLOB call sites are unchanged; the duplicate body is removed and a pointer comment kept. The lift also matches the TypeScript SDK structure where ``_getAuthHeaders`` lives on a shared base. New ``Transfer`` / ``TransferStatus`` / ``TransferActionType`` / ``TransferBridge`` types exported from ``dexalot_sdk.core.transfer``. Mirrors TypeScript SDK commit f7a9945. --- src/dexalot_sdk/constants.py | 6 + src/dexalot_sdk/core/base.py | 35 +++ src/dexalot_sdk/core/clob.py | 34 +-- src/dexalot_sdk/core/transfer.py | 341 ++++++++++++++++++++++++++++- tests/unit/core/test_transfer.py | 354 +++++++++++++++++++++++++++++++ 5 files changed, 740 insertions(+), 30 deletions(-) diff --git a/src/dexalot_sdk/constants.py b/src/dexalot_sdk/constants.py index e99f78e..93d6356 100644 --- a/src/dexalot_sdk/constants.py +++ b/src/dexalot_sdk/constants.py @@ -59,6 +59,12 @@ def ws_api_url_for_rest_base(rest_api_base_url: str | None) -> str: # useful when the backend gains range support. ENDPOINT_INFO_PRICE_HISTORY = "/api/info/token-usd-price-history" ENDPOINT_INFO_HOURLY_PRICE_HISTORY = "/api/info/token-usd-price-history-hourly" +# Unified transfer history (deposits + withdrawals + p2p + gas) under the +# `/api/trading/signed/` mountpoint. Requires the `x-signature` header. +# The backend route does not exist under `/privapi/...` — confirmed +# empirically (404 on /privapi/trading/signed/transferscombined, +# 204 OPTIONS on /api/trading/signed/transferscombined). +ENDPOINT_TRADING_COMBINED_TRANSFERS = "/api/trading/signed/transferscombined" # Default Values DEFAULT_DECIMALS = 18 diff --git a/src/dexalot_sdk/core/base.py b/src/dexalot_sdk/core/base.py index 3bb9400..3ba8e69 100644 --- a/src/dexalot_sdk/core/base.py +++ b/src/dexalot_sdk/core/base.py @@ -4,6 +4,7 @@ import os import re import sys +import time from dataclasses import dataclass from functools import lru_cache from typing import Any, cast @@ -683,6 +684,40 @@ def _normalize_user_pair(self, pair: str) -> str: return normalize_trading_pair_for_sdk(pair) + def _get_auth_headers(self) -> dict[str, str]: + """Generate authentication headers for signed endpoints. + + Lifted from ``CLOBClient`` so non-CLOB surfaces (transfer history, + future signed endpoints) can use the same helper without depending + on the CLOB mixin. + + When ``config.timestamped_auth`` is True, the signed message is + ``f"dexalot{ts}"`` (millisecond timestamp) and an ``x-timestamp`` + header is included alongside ``x-signature``. This prevents + replay attacks but requires backend support — default is + ``False`` until the backend confirms timestamp window validation. + See ``docs/python-sdk-remediation-plan.md`` C-2. + """ + if not self.account: + raise Exception("Private key not configured.") + + from eth_account.messages import encode_defunct + + addr = cast(str, cast(Any, self.account).address) + + if self.config.timestamped_auth: + ts = int(time.time() * 1000) + message = encode_defunct(text=f"dexalot{ts}") + signature = self.account.sign_message(message).signature.hex() + return { + "x-signature": f"{addr}:0x{signature}", + "x-timestamp": str(ts), + } + + message = encode_defunct(text="dexalot") + signature = self.account.sign_message(message).signature.hex() + return {"x-signature": f"{addr}:0x{signature}"} + def resolve_chain_reference( self, chain_reference: str | int, include_dexalot_l1: bool = False ) -> Result[ResolvedChain]: diff --git a/src/dexalot_sdk/core/clob.py b/src/dexalot_sdk/core/clob.py index a82a94a..64bc645 100644 --- a/src/dexalot_sdk/core/clob.py +++ b/src/dexalot_sdk/core/clob.py @@ -1,5 +1,4 @@ import asyncio -import time from collections.abc import Callable from typing import Any, SupportsInt, cast @@ -819,34 +818,11 @@ async def cancel_all_orders(self) -> Result[dict]: return cast(Result[dict], await self.cancel_list_orders(order_ids)) - def _get_auth_headers(self) -> dict[str, str]: - """Generates authentication headers for signed endpoints. - - When config.timestamped_auth is True, the signed message is f"dexalot{ts}" - (millisecond timestamp) and an x-timestamp header is included alongside - x-signature. This prevents replay attacks but requires backend support — - default is False until the backend confirms timestamp window validation. - See docs/python-sdk-remediation-plan.md C-2. - """ - if not self.account: - raise Exception("Private key not configured.") - - from eth_account.messages import encode_defunct - - addr = cast(str, cast(Any, self.account).address) - - if self.config.timestamped_auth: - ts = int(time.time() * 1000) - message = encode_defunct(text=f"dexalot{ts}") - signature = self.account.sign_message(message).signature.hex() - return { - "x-signature": f"{addr}:0x{signature}", - "x-timestamp": str(ts), - } - - message = encode_defunct(text="dexalot") - signature = self.account.sign_message(message).signature.hex() - return {"x-signature": f"{addr}:0x{signature}"} + # ``_get_auth_headers`` lives on ``DexalotBaseClient`` so non-CLOB + # signed endpoints (transfer history) can share the same helper. + # See ``DexalotBaseClient._get_auth_headers`` for the canonical + # implementation, including the timestamped-auth path gated on + # ``config.timestamped_auth``. def _coerce_order_numeric(self, value: object) -> float | None: """Convert order numeric fields to ``float`` when possible.""" diff --git a/src/dexalot_sdk/core/transfer.py b/src/dexalot_sdk/core/transfer.py index 6e7ecd3..d61648e 100644 --- a/src/dexalot_sdk/core/transfer.py +++ b/src/dexalot_sdk/core/transfer.py @@ -2,7 +2,7 @@ import math from dataclasses import dataclass from datetime import datetime -from typing import Any, cast +from typing import Any, Literal, cast from ..constants import ( BRIDGE_ID_ICM, @@ -10,6 +10,7 @@ ENDPOINT_INFO_HOURLY_PRICE_HISTORY, ENDPOINT_INFO_PRICE_HISTORY, ENDPOINT_INFO_USD_PRICES, + ENDPOINT_TRADING_COMBINED_TRANSFERS, ENDPOINT_TRADING_TOKENS, GAS_BUFFER, ICM_CHAINS, @@ -26,6 +27,30 @@ from ..utils.result import Result from .base import _BALANCE_CACHE, _SEMI_STATIC_CACHE, _STATIC_CACHE, DexalotBaseClient +# --------------------------------------------------------------------------- +# Shared types returned by transfer client methods +# --------------------------------------------------------------------------- +# Kept in this module (next to the methods that produce them) to follow the +# existing SDK convention — e.g. ``ResolvedChain`` lives next to the chain +# resolver in ``base.py``. + +TransferStatus = Literal["COMPLETED", "INFLIGHT", "DELAYED"] + +TransferActionType = Literal[ + "WITHDRAWN", + "DEPOSITED", + "SENT", + "RECEIVED", + "RECOVERED", + "ADD_GAS", + "REMOVE_GAS", + "AUTO_FILL", + "WITHDRAW_PENDING", + "DEPOSIT_PENDING", +] + +TransferBridge = Literal["NATIVE", "LAYER0", "CELER", "ICM"] + @dataclass(frozen=True) class PricePoint: @@ -43,6 +68,71 @@ class PricePoint: price: float +@dataclass(frozen=True) +class Transfer: + """One row of unified transfer history returned by ``get_combined_transfers``. + + Each row aggregates a deposit / withdrawal / gas top-up / portfolio + P2P send-or-receive / bridge recovery involving the connected wallet. + The backend ships rows as ``DBTransfer`` (snake_case + numeric enums); + the SDK lifts the numeric ``status`` / ``action_type`` / ``bridge`` + enums to human-readable strings and parses ``quantity`` / ``fee`` Big + decimal strings to ``float``. ``quantity`` / ``fee`` are already + display-decimal at the backend — there is no wei → human conversion + to apply. + """ + + action_type: TransferActionType + status: TransferStatus + symbol: str + quantity: float + fee: float + trader_address: str + bridge: TransferBridge + bridge_url: str + nonce: int + source_env: str + source_chain_id: int + source_tx: str + source_ts: int + target_env: str + target_chain_id: int + target_tx: str + target_ts: int + + +# Numeric enum → human-readable label lookup tables for the +# ``transferscombined`` REST response. Mirror the +# ``TRANSFER_ACTION_TYPE`` / ``TRANSFER_STATUS`` / ``BRIDGES`` enums +# the official Dexalot frontend uses to render the same rows; keeping +# them in lockstep avoids drift if the backend ever adds new variants. +_TRANSFER_ACTION_TYPE_LABELS: dict[int, TransferActionType] = { + 0: "WITHDRAWN", + 1: "DEPOSITED", + 5: "SENT", + 6: "RECEIVED", + 7: "RECOVERED", + 8: "ADD_GAS", + 9: "REMOVE_GAS", + 10: "AUTO_FILL", + 11: "WITHDRAW_PENDING", + 12: "DEPOSIT_PENDING", +} + +_TRANSFER_STATUS_LABELS: dict[int, TransferStatus] = { + 0: "COMPLETED", + 1: "INFLIGHT", + 2: "DELAYED", +} + +_TRANSFER_BRIDGE_LABELS: dict[int, TransferBridge] = { + -1: "NATIVE", + 0: "LAYER0", + 1: "CELER", + 2: "ICM", +} + + class TransferClient(DexalotBaseClient): async def _get_provider_for_chain(self, chain: str): """ @@ -1954,3 +2044,252 @@ async def _fetch_price_history_cached( return Result.ok(points) except Exception as e: return Result.fail(self._sanitize_error(e, "fetching token price history")) + + # ---------------------------------------------------------------------- + # Combined transfer history (signed /api/trading/signed/transferscombined) + # ---------------------------------------------------------------------- + + def _normalize_transfer(self, raw: Any) -> Transfer | None: + """Normalize one raw ``DBTransfer``-shaped row into a ``Transfer``. + + Returns ``None`` for any row that is missing required fields or + carries an unknown ``action_type`` / ``status`` enum — the + public method silently drops these so a single bad row does not + poison the page. + + ``bridge`` falls back to ``"NATIVE"`` for unknown enum values + because the frontend treats unrecognised bridges the same way + (display-only label, no behavior depends on the precise id), + and the row is otherwise valid. + """ + if not isinstance(raw, dict): + return None + + action_raw = raw.get("action_type") + if not isinstance(action_raw, int) or isinstance(action_raw, bool): + return None + action_type = _TRANSFER_ACTION_TYPE_LABELS.get(action_raw) + if action_type is None: + return None + + status_raw = raw.get("status") + if not isinstance(status_raw, int) or isinstance(status_raw, bool): + return None + status = _TRANSFER_STATUS_LABELS.get(status_raw) + if status is None: + return None + + symbol_raw = raw.get("symbol") + if not isinstance(symbol_raw, str) or not symbol_raw: + return None + + quantity = self._coerce_usd_price(raw.get("quantity")) + if quantity is None: + return None + + # Fee defaults to 0 if missing or unparseable — many native / + # portfolio-internal legs report ``"0"`` and some omit the field. + fee = self._coerce_usd_price(raw.get("fee")) + if fee is None: + fee = 0.0 + + trader_address_raw = raw.get("traderaddress") + trader_address = trader_address_raw if isinstance(trader_address_raw, str) else "" + + bridge_raw = raw.get("bridge") + if isinstance(bridge_raw, int) and not isinstance(bridge_raw, bool): + bridge = _TRANSFER_BRIDGE_LABELS.get(bridge_raw, "NATIVE") + else: + bridge = "NATIVE" + + bridge_url_raw = raw.get("bridge_url") + bridge_url = bridge_url_raw if isinstance(bridge_url_raw, str) else "" + + nonce_raw = raw.get("nonce") + nonce = nonce_raw if isinstance(nonce_raw, int) and not isinstance(nonce_raw, bool) else -1 + + source_env_raw = raw.get("source_env") + source_env = source_env_raw if isinstance(source_env_raw, str) else "" + + source_chain_id_raw = raw.get("source_chain_id") + source_chain_id = ( + source_chain_id_raw + if isinstance(source_chain_id_raw, int) and not isinstance(source_chain_id_raw, bool) + else 0 + ) + + source_tx_raw = raw.get("source_tx") + source_tx = source_tx_raw if isinstance(source_tx_raw, str) else "" + + source_ts = self._coerce_timestamp_seconds(raw.get("source_ts")) or 0 + + target_env_raw = raw.get("target_env") + target_env = target_env_raw if isinstance(target_env_raw, str) else "" + + target_chain_id_raw = raw.get("target_chain_id") + target_chain_id = ( + target_chain_id_raw + if isinstance(target_chain_id_raw, int) and not isinstance(target_chain_id_raw, bool) + else 0 + ) + + target_tx_raw = raw.get("target_tx") + target_tx = target_tx_raw if isinstance(target_tx_raw, str) else "" + + target_ts = self._coerce_timestamp_seconds(raw.get("target_ts")) or 0 + + return Transfer( + action_type=action_type, + status=status, + symbol=symbol_raw, + quantity=quantity, + fee=fee, + trader_address=trader_address, + bridge=bridge, + bridge_url=bridge_url, + nonce=nonce, + source_env=source_env, + source_chain_id=source_chain_id, + source_tx=source_tx, + source_ts=source_ts, + target_env=target_env, + target_chain_id=target_chain_id, + target_tx=target_tx, + target_ts=target_ts, + ) + + @track_method("transfer") + async def get_combined_transfers( + self, + *, + kind: str | None = None, + from_ts: int | None = None, + to_ts: int | None = None, + limit: int = 100, + offset: int = 0, + ) -> Result[list[Transfer]]: + """Paginated history of every deposit, withdrawal, gas top-up, + portfolio P2P send/receive, and bridge recovery involving the + connected wallet. + + Routes through the signed REST endpoint + ``/api/trading/signed/transferscombined``; ``x-signature`` is + attached via :meth:`_get_auth_headers`. Returns canonical + :class:`Transfer` rows with snake_case fields and human-readable + ``action_type`` / ``status`` / ``bridge`` labels lifted from the + backend's numeric enums. + + Backend pagination uses ``itemsperpage`` / ``pageno`` (NOT + ``limit`` / ``offset``); the SDK accepts the more conventional + ``limit`` / ``offset`` signature and translates internally + (``pageno = (offset // limit) + 1``). + + Cached for 10 seconds (balance tier) per + ``(address, kind, from_ts, to_ts, limit, offset)`` tuple — + distinct signers and distinct filter combinations never share a + cache slot. Returned ``quantity`` / ``fee`` are already display- + decimal — no wei→human conversion is applied because the backend + has already done it. + + Args: + kind: Optional token symbol filter (forwarded to backend as + ``symbol``). Normalised through :meth:`_normalize_user_token`. + from_ts: Optional unix-seconds lower bound (forwarded as + ``periodfrom``). + to_ts: Optional unix-seconds upper bound (forwarded as + ``periodto``). + limit: Page size (forwarded as ``itemsperpage``, default 100). + offset: Row offset (translated to ``pageno`` = ``offset // limit + 1``, + default 0 → ``pageno=1``). + """ + if not self.account: + return Result.fail("get_combined_transfers requires a configured wallet") + + try: + address = cast(str, cast(Any, self.account).address) + except Exception as e: + return Result.fail(self._sanitize_error(e, "resolving wallet address")) + + normalized_symbol: str | None = None + if kind is not None: + normalized_symbol = self._normalize_user_token(kind) + + # Translate (limit, offset) → (itemsperpage, pageno). The backend + # uses 1-indexed pages; offset 0 → page 1. Defensive ``max(1, ...)`` + # on items-per-page avoids a ZeroDivisionError on a caller-supplied + # ``limit=0`` while still surfacing it as a likely-empty page. + items_per_page = max(1, int(limit)) + page_no = (int(offset) // items_per_page) + 1 + + return cast( + Result[list[Transfer]], + await self._get_combined_transfers_cached( + address, + normalized_symbol, + from_ts, + to_ts, + items_per_page, + page_no, + ), + ) + + @async_ttl_cached(_BALANCE_CACHE) + async def _get_combined_transfers_cached( + self, + address: str, + symbol: str | None, + period_from: int | None, + period_to: int | None, + items_per_page: int, + page_no: int, + ) -> Result[list[Transfer]]: + """Internal cached implementation of get_combined_transfers. + + Cache key is namespaced by resolved address so signer swaps within + a single client never collide on the same slot, and by every + translated opt so different filter combinations are distinct. + """ + try: + headers = self._get_auth_headers() + except Exception as e: + return Result.fail(self._sanitize_error(e, "fetching combined transfers")) + + params: dict[str, Any] = { + "itemsperpage": items_per_page, + "pageno": page_no, + } + if symbol is not None: + params["symbol"] = symbol + if period_from is not None: + params["periodfrom"] = period_from + if period_to is not None: + params["periodto"] = period_to + + try: + data = await self._api_call( + "get", + f"{self.api_base_url}{ENDPOINT_TRADING_COMBINED_TRANSFERS}", + headers=headers, + params=params, + ) + except Exception as e: + return Result.fail(self._sanitize_error(e, "fetching combined transfers")) + + # Backend ships ``{count, rows}``; we tolerate a bare array as a + # forward-compat fallback. + if isinstance(data, list): + rows: list[Any] = data + elif isinstance(data, dict): + envelope_rows = data.get("rows") + rows = envelope_rows if isinstance(envelope_rows, list) else [] + else: + return Result.fail( + f"Unexpected transfers response shape: expected object or array, got {type(data).__name__}." + ) + + transfers: list[Transfer] = [] + for row in rows: + normalized = self._normalize_transfer(row) + if normalized is not None: + transfers.append(normalized) + return Result.ok(transfers) diff --git a/tests/unit/core/test_transfer.py b/tests/unit/core/test_transfer.py index 38e775e..c43838e 100644 --- a/tests/unit/core/test_transfer.py +++ b/tests/unit/core/test_transfer.py @@ -2578,3 +2578,357 @@ def test_coerce_timestamp_seconds_edge_cases(self): assert TransferClient._coerce_timestamp_seconds("1700000000") == 1700000000 # plain number passthrough assert TransferClient._coerce_timestamp_seconds(1700000000) == 1700000000 + + # ---------------------------------------------------------------------- + # get_combined_transfers — signed REST /api/trading/signed/transferscombined + # ---------------------------------------------------------------------- + # Mirrors TypeScript SDK commit f7a9945. + + async def test_get_combined_transfers_no_wallet_returns_fail(self, client): + """No configured account → Result.fail without any REST call.""" + client.account = None + api_spy = AsyncMock(return_value={"count": 0, "rows": []}) + with patch.object(client, "_api_call", api_spy): + result = await client.get_combined_transfers() + assert not result.success + assert "wallet" in result.error.lower() + api_spy.assert_not_called() + + async def test_get_combined_transfers_basic_envelope(self, client): + """Envelope ``{count, rows}`` parsed, each row normalized to Transfer.""" + from dexalot_sdk.core.transfer import Transfer + + api_spy = AsyncMock( + return_value={ + "count": 1, + "rows": [ + { + "action_type": 1, + "status": 0, + "symbol": "USDC", + "quantity": "100.5", + "fee": "0.25", + "traderaddress": VALID_ADDRESS, + "bridge": 2, + "bridge_url": "https://bridge.example/x", + "nonce": 7, + "source_env": "fuji-multi-avax", + "source_chain_id": 43113, + "source_tx": "0xabc", + "source_ts": 1700000000, + "target_env": "fuji-multi-subnet", + "target_chain_id": 12345, + "target_tx": "0xdef", + "target_ts": 1700000010, + } + ], + } + ) + with patch.object(client, "_api_call", api_spy): + result = await client.get_combined_transfers() + assert result.success + assert len(result.data) == 1 + t = result.data[0] + assert isinstance(t, Transfer) + assert t.action_type == "DEPOSITED" + assert t.status == "COMPLETED" + assert t.bridge == "ICM" + assert t.symbol == "USDC" + assert t.quantity == 100.5 + assert t.fee == 0.25 + assert t.nonce == 7 + assert t.source_chain_id == 43113 + assert t.target_chain_id == 12345 + + async def test_get_combined_transfers_bare_array_fallback(self, client): + """Bare list response also accepted as forward-compat.""" + api_spy = AsyncMock( + return_value=[ + { + "action_type": 0, + "status": 1, + "symbol": "ALOT", + "quantity": "1.0", + "fee": "0", + "traderaddress": VALID_ADDRESS, + "bridge": -1, + "source_chain_id": 12345, + } + ] + ) + with patch.object(client, "_api_call", api_spy): + result = await client.get_combined_transfers() + assert result.success + assert len(result.data) == 1 + assert result.data[0].action_type == "WITHDRAWN" + assert result.data[0].status == "INFLIGHT" + assert result.data[0].bridge == "NATIVE" + + async def test_get_combined_transfers_unexpected_shape_fails(self, client): + """Non-object, non-array response surfaces Result.fail.""" + api_spy = AsyncMock(return_value="bad") + with patch.object(client, "_api_call", api_spy): + result = await client.get_combined_transfers() + assert not result.success + assert "transfers response shape" in result.error + + async def test_get_combined_transfers_empty_rows(self, client): + """Empty rows list returns Result.ok([]).""" + api_spy = AsyncMock(return_value={"count": 0, "rows": []}) + with patch.object(client, "_api_call", api_spy): + result = await client.get_combined_transfers() + assert result.success + assert result.data == [] + + async def test_get_combined_transfers_envelope_missing_rows(self, client): + """``{count}`` without ``rows`` is tolerated as empty list.""" + api_spy = AsyncMock(return_value={"count": 0}) + with patch.object(client, "_api_call", api_spy): + result = await client.get_combined_transfers() + assert result.success + assert result.data == [] + + async def test_get_combined_transfers_drops_unknown_enums(self, client): + """Rows with unknown numeric action_type / status are silently dropped.""" + api_spy = AsyncMock( + return_value={ + "count": 4, + "rows": [ + { + "action_type": 99, + "status": 0, + "symbol": "X", + "quantity": "1", + "traderaddress": VALID_ADDRESS, + }, + { + "action_type": 1, + "status": 99, + "symbol": "X", + "quantity": "1", + "traderaddress": VALID_ADDRESS, + }, + { + "action_type": 1, + "status": 0, + "symbol": "GOOD", + "quantity": "5", + "traderaddress": VALID_ADDRESS, + "bridge": 999, # Unknown bridge → falls back to NATIVE + }, + None, + ], + } + ) + with patch.object(client, "_api_call", api_spy): + result = await client.get_combined_transfers() + assert result.success + assert len(result.data) == 1 + assert result.data[0].symbol == "GOOD" + assert result.data[0].bridge == "NATIVE" + + async def test_get_combined_transfers_drops_missing_required_fields(self, client): + """Rows missing action_type / status / symbol / quantity dropped.""" + api_spy = AsyncMock( + return_value=[ + {"status": 0, "symbol": "X", "quantity": "1"}, # missing action_type + {"action_type": 1, "symbol": "X", "quantity": "1"}, # missing status + {"action_type": 1, "status": 0, "quantity": "1"}, # missing symbol + {"action_type": 1, "status": 0, "symbol": "X"}, # missing quantity + { + "action_type": 1, + "status": 0, + "symbol": "X", + "quantity": "abc", # non-numeric quantity + }, + { + "action_type": "1", # action_type as string (not numeric) + "status": 0, + "symbol": "X", + "quantity": "1", + }, + { + "action_type": 1, + "status": "0", # status as string (not numeric) + "symbol": "X", + "quantity": "1", + }, + { + "action_type": 1, + "status": 0, + "symbol": 42, # symbol not a string + "quantity": "1", + }, + { + "action_type": 1, + "status": 0, + "symbol": "GOOD", + "quantity": "1", + }, + ] + ) + with patch.object(client, "_api_call", api_spy): + result = await client.get_combined_transfers() + assert result.success + assert len(result.data) == 1 + assert result.data[0].symbol == "GOOD" + + async def test_get_combined_transfers_quantity_and_fee_numeric_or_string(self, client): + """quantity/fee accepted as either numeric string or numeric.""" + api_spy = AsyncMock( + return_value=[ + { + "action_type": 1, + "status": 0, + "symbol": "X", + "quantity": 12.5, + "fee": 0.01, + "traderaddress": VALID_ADDRESS, + } + ] + ) + with patch.object(client, "_api_call", api_spy): + result = await client.get_combined_transfers() + assert result.success + assert result.data[0].quantity == 12.5 + assert result.data[0].fee == 0.01 + + async def test_get_combined_transfers_fee_missing_defaults_zero(self, client): + """fee absent → defaults to 0.0.""" + api_spy = AsyncMock( + return_value=[ + { + "action_type": 1, + "status": 0, + "symbol": "X", + "quantity": "10", + "traderaddress": VALID_ADDRESS, + } + ] + ) + with patch.object(client, "_api_call", api_spy): + result = await client.get_combined_transfers() + assert result.success + assert result.data[0].fee == 0.0 + + async def test_get_combined_transfers_default_pagination(self, client): + """Defaults: limit=100 → itemsperpage=100, offset=0 → pageno=1.""" + api_spy = AsyncMock(return_value={"count": 0, "rows": []}) + with patch.object(client, "_api_call", api_spy): + await client.get_combined_transfers() + _, kwargs = api_spy.call_args + assert kwargs["params"]["itemsperpage"] == 100 + assert kwargs["params"]["pageno"] == 1 + + async def test_get_combined_transfers_custom_pagination_translation(self, client): + """limit → itemsperpage, offset → pageno (offset/limit = page index, 1-indexed).""" + api_spy = AsyncMock(return_value={"count": 0, "rows": []}) + with patch.object(client, "_api_call", api_spy): + await client.get_combined_transfers(limit=50, offset=100) + _, kwargs = api_spy.call_args + # offset 100 with limit 50 → page 3 (1-indexed) + assert kwargs["params"]["itemsperpage"] == 50 + assert kwargs["params"]["pageno"] == 3 + + async def test_get_combined_transfers_forwards_kind_and_period(self, client): + """kind → symbol; from_ts → periodfrom; to_ts → periodto.""" + api_spy = AsyncMock(return_value={"count": 0, "rows": []}) + with patch.object(client, "_api_call", api_spy): + await client.get_combined_transfers( + kind="ALOT", from_ts=1700000000, to_ts=1700864000 + ) + _, kwargs = api_spy.call_args + assert kwargs["params"]["symbol"] == "ALOT" + assert kwargs["params"]["periodfrom"] == 1700000000 + assert kwargs["params"]["periodto"] == 1700864000 + + async def test_get_combined_transfers_attaches_signature_header(self, client): + """``x-signature`` header attached via _get_auth_headers.""" + api_spy = AsyncMock(return_value={"count": 0, "rows": []}) + with patch.object(client, "_api_call", api_spy): + with patch.object( + client, "_get_auth_headers", return_value={"x-signature": "sig"} + ): + await client.get_combined_transfers() + _, kwargs = api_spy.call_args + assert kwargs["headers"] == {"x-signature": "sig"} + + async def test_get_combined_transfers_endpoint_path(self, client): + """Path is /api/trading/signed/transferscombined (NOT under /privapi/).""" + api_spy = AsyncMock(return_value={"count": 0, "rows": []}) + with patch.object(client, "_api_call", api_spy): + await client.get_combined_transfers() + args, _ = api_spy.call_args + assert "/api/trading/signed/transferscombined" in args[1] + assert "/privapi/" not in args[1] + + async def test_get_combined_transfers_signer_rejection_returns_fail(self, client): + """Auth header generation failure surfaces as Result.fail.""" + api_spy = AsyncMock(return_value={"count": 0, "rows": []}) + with patch.object(client, "_api_call", api_spy): + with patch.object( + client, + "_get_auth_headers", + side_effect=Exception("Private key not configured."), + ): + result = await client.get_combined_transfers() + assert not result.success + + async def test_get_combined_transfers_api_error(self, client): + """REST failure surfaced as Result.fail.""" + api_spy = AsyncMock(side_effect=RuntimeError("network boom")) + with patch.object(client, "_api_call", api_spy): + result = await client.get_combined_transfers() + assert not result.success + + async def test_get_combined_transfers_cache_distinct_per_address_and_opts(self, client): + """Cache key includes (address, all opts); distinct combos do not collide.""" + client._cache_enabled = True + api_spy = AsyncMock(return_value={"count": 0, "rows": []}) + with patch.object(client, "_api_call", api_spy): + await client.get_combined_transfers() + await client.get_combined_transfers() # cache hit (same addr + opts) + await client.get_combined_transfers(kind="ALOT") # distinct slot + await client.get_combined_transfers(limit=50) # distinct slot + # Change address → distinct slot + other_addr = "0x" + "b" * 40 + client.account.address = other_addr + await client.get_combined_transfers() + + assert api_spy.await_count == 4 + + async def test_get_combined_transfers_cache_bypass_when_disabled(self, client): + """When _cache_enabled is False every call hits REST.""" + client._cache_enabled = False + api_spy = AsyncMock(return_value={"count": 0, "rows": []}) + with patch.object(client, "_api_call", api_spy): + await client.get_combined_transfers() + await client.get_combined_transfers() + assert api_spy.await_count == 2 + + async def test_get_combined_transfers_normalizes_user_symbol(self, client): + """``kind`` is run through _normalize_user_token before being forwarded.""" + api_spy = AsyncMock(return_value={"count": 0, "rows": []}) + with patch.object( + client, "_normalize_user_token", return_value="USDC" + ) as norm_spy: + with patch.object(client, "_api_call", api_spy): + await client.get_combined_transfers(kind="usdc") + norm_spy.assert_called_once_with("usdc") + _, kwargs = api_spy.call_args + assert kwargs["params"]["symbol"] == "USDC" + + async def test_get_combined_transfers_address_lookup_failure(self, client): + """A signer that raises when address is read surfaces Result.fail.""" + + class BrokenAccount: + @property + def address(self): # type: ignore[no-untyped-def] + raise RuntimeError("signer is broken") + + client.account = BrokenAccount() + api_spy = AsyncMock(return_value={"count": 0, "rows": []}) + with patch.object(client, "_api_call", api_spy): + result = await client.get_combined_transfers() + assert not result.success + api_spy.assert_not_called() From efe7c563261050acda9a74ee80df9b58e35412bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=BCseyin=20Deniz?= Date: Tue, 2 Jun 2026 21:40:04 +0300 Subject: [PATCH 06/14] feat(clob): add get_order_history for paginated per-account history Signed GET; returns canonical Order list (same shape as get_open_orders). Accepts pair/status/limit/offset filters and optional explicit account argument. Balance-tier cached (10s). Reuses _transform_order_from_api and _get_auth_headers. Mirrors TypeScript SDK commit ece5dcd. --- src/dexalot_sdk/constants.py | 11 ++ src/dexalot_sdk/core/clob.py | 152 ++++++++++++++++- tests/unit/core/test_clob.py | 312 +++++++++++++++++++++++++++++++++++ 3 files changed, 474 insertions(+), 1 deletion(-) diff --git a/src/dexalot_sdk/constants.py b/src/dexalot_sdk/constants.py index 93d6356..f199ec7 100644 --- a/src/dexalot_sdk/constants.py +++ b/src/dexalot_sdk/constants.py @@ -39,6 +39,17 @@ def ws_api_url_for_rest_base(rest_api_base_url: str | None) -> str: ENDPOINT_TRADING_TOKENS = "/privapi/trading/tokens" ENDPOINT_TRADING_DEPLOYMENT = "/privapi/trading/deployment" ENDPOINT_SIGNED_ORDERS = "/privapi/signed/orders" +# Paginated per-account order history (any status) under the +# `/api/trading/signed/` mountpoint. Requires the `x-signature` header. +# Distinct from `ENDPOINT_SIGNED_ORDERS` above — `/privapi/signed/orders` +# returns the currently-open orders for the connected wallet (used by +# `get_open_orders`), while `/api/trading/signed/orders` returns the +# full historical order list (any status, paginated, supports +# `pair` / `status` / `limit` / `offset` filters and an explicit +# `traderaddress`). The trade-kit's `clob_get_orders_by_account` tool +# hits this path via its `signedGet("orders", ...)` helper which mounts +# at `${baseUrl}/trading/signed/`. +ENDPOINT_TRADING_SIGNED_ORDERS_HISTORY = "/api/trading/signed/orders" ENDPOINT_RFQ_PAIRS = "/api/rfq/pairs" ENDPOINT_RFQ_FIRM_QUOTE = "/api/rfq/firmQuote" ENDPOINT_RFQ_PAIR_PRICE = "/api/rfq/pairprice" diff --git a/src/dexalot_sdk/core/clob.py b/src/dexalot_sdk/core/clob.py index 64bc645..69c59b5 100644 --- a/src/dexalot_sdk/core/clob.py +++ b/src/dexalot_sdk/core/clob.py @@ -7,6 +7,7 @@ ENDPOINT_STATS_MARKET_SNAPSHOT, ENDPOINT_TRADING_CANDLE_CHUNK, ENDPOINT_TRADING_PAIRS, + ENDPOINT_TRADING_SIGNED_ORDERS_HISTORY, ENV_FUJI_MULTI_SUBNET, ENV_PROD_MULTI_SUBNET, ws_api_url_for_rest_base, @@ -22,7 +23,7 @@ from ..utils.result import Result from ..utils.retry import async_retry from ..utils.websocket_manager import WebSocketManager -from .base import _ORDERBOOK_CACHE, _SEMI_STATIC_CACHE, DexalotBaseClient +from .base import _BALANCE_CACHE, _ORDERBOOK_CACHE, _SEMI_STATIC_CACHE, DexalotBaseClient _CANDLE_INTERVALS: dict[str, tuple[int, str]] = { "1m": (1, "minute"), @@ -1109,6 +1110,155 @@ async def get_open_orders(self, pair: str | None = None) -> Result[list]: error_msg = self._sanitize_error(e, "fetching open orders") return Result.fail(error_msg) + @track_method("clob") + async def get_order_history( + self, + account: str | None = None, + *, + pair: str | None = None, + status: str | None = None, + limit: int = 100, + offset: int = 0, + ) -> Result[list]: + """Paginated per-account order history (any status: ``NEW``, + ``REJECTED``, ``PARTIAL``, ``FILLED``, ``CANCELED``, ``EXPIRED``, + ``KILLED``). + + Returns canonical order dicts in the same shape as + :meth:`get_open_orders` — rows pass through the shared + :meth:`_transform_order_from_api` helper so callers can mix the + two result sets without re-normalising. + + Routes through the signed REST endpoint + ``/api/trading/signed/orders`` (distinct from ``get_open_orders``' + ``/privapi/signed/orders`` which only returns currently-open + rows). The trade-kit's ``clob_get_orders_by_account`` tool calls + the same endpoint via ``signedGet("orders", ...)`` and provided + the empirical verification for the path + query shape used here. + + Cached for 10 seconds (balance tier) per ``(address, opts)`` + tuple. The resolved address is either the explicit ``account`` + argument or the connected wallet address; distinct addresses + and distinct filter combinations never share a cache slot. + + When no wallet is configured AND no explicit ``account`` is + passed, returns ``Result.fail`` — the call has no addressee. + When a wallet IS configured, the ``x-signature`` header is + attached via the shared :meth:`_get_auth_headers` helper; when + only an explicit account is supplied (no signer), the auth + header is omitted and the backend may reject — this lets + read-only callers query by address without forcing a key, + mirroring the SDK's general read-only ergonomics. + + Args: + account: Optional trader address; defaults to the connected + wallet's address. At least one must be available. + pair: Optional pair filter, e.g. ``"AVAX/USDC"``. + status: Optional status filter (one of ``"NEW"``, + ``"REJECTED"``, ``"PARTIAL"``, ``"FILLED"``, + ``"CANCELED"``, ``"EXPIRED"``, ``"KILLED"``); any other + string is forwarded verbatim for forward-compat. + limit: Page size (default 100). + offset: Row offset (default 0). + """ + if account is not None: + address = account + elif self.account: + try: + address = cast(str, cast(Any, self.account).address) + except Exception as e: + return Result.fail(self._sanitize_error(e, "resolving wallet address")) + else: + return Result.fail( + "get_order_history requires either an account argument or a configured wallet" + ) + + if pair is not None: + pair_result = validate_pair_format(pair, "pair") + if not pair_result.success: + return cast(Result[list[Any]], pair_result) + pair = self._normalize_user_pair(pair) + + return cast( + Result[list], + await self._get_order_history_cached(address, pair, status, limit, offset), + ) + + @async_ttl_cached(_BALANCE_CACHE) + async def _get_order_history_cached( + self, + address: str, + pair: str | None, + status: str | None, + limit: int, + offset: int, + ) -> Result[list]: + """Internal cached implementation of :meth:`get_order_history`. + + Cache key is namespaced by resolved address so signer swaps within + a single client never collide on the same slot, and by every opt + so different filter combinations are distinct. + """ + headers: dict[str, str] = {} + if self.account: + try: + headers = self._get_auth_headers() + except Exception as e: + return Result.fail(self._sanitize_error(e, "fetching order history")) + + params: dict[str, Any] = { + "traderaddress": address, + "limit": limit, + "offset": offset, + } + if pair is not None: + params["pair"] = pair + if status is not None: + params["status"] = status + + try: + data = await self._api_call( + "get", + f"{self.api_base_url}{ENDPOINT_TRADING_SIGNED_ORDERS_HISTORY}", + headers=headers, + params=params, + ) + except Exception as e: + return Result.fail(self._sanitize_error(e, "fetching order history")) + + # Backend ships either a bare list or ``{count, rows}``; we + # tolerate both, and additionally wrap a single non-list dict + # response as a one-row result for forward-compat parity with + # ``get_open_orders``. + orders: list[Any] + if isinstance(data, list): + orders = data + elif isinstance(data, dict): + envelope_rows = data.get("rows") + if isinstance(envelope_rows, list): + orders = envelope_rows + else: + orders = [data] + else: + return Result.fail( + "Unexpected order history response shape: expected object or array, " + f"got {type(data).__name__}." + ) + + # If any row is missing pair info, hydrate the pairs cache so + # :meth:`_resolve_pair_from_order` / :meth:`_resolve_trade_pair_id_from_pair` + # can fill the canonical fields. Mirrors ``get_open_orders``. + if orders and any( + (o.get("trade_pair_id") or o.get("tradePairId") or o.get("tradepairid")) is None + for o in orders + ): + pairs_result = await self.get_clob_pairs() + if not pairs_result.success: + return Result.fail(pairs_result.error or "failed to hydrate pairs") + + transformed = [self._transform_order_from_api(order) for order in orders] + return Result.ok(transformed) + @track_method("clob") async def get_order(self, order_id: str | bytes) -> Result[dict]: """Fetch the details of an order by its Internal ID or Client Order ID. diff --git a/tests/unit/core/test_clob.py b/tests/unit/core/test_clob.py index ee1bb11..420c2c2 100644 --- a/tests/unit/core/test_clob.py +++ b/tests/unit/core/test_clob.py @@ -1057,6 +1057,318 @@ async def test_transform_order_from_api_unconvertible_number(self, client): assert transformed["quantity"] is None assert transformed["total_amount"] is None + # ---------------------------------------------------------------------- + # get_order_history — signed REST /api/trading/signed/orders + # ---------------------------------------------------------------------- + # Mirrors TypeScript SDK commit ece5dcd. Distinct from + # /privapi/signed/orders (get_open_orders, open-only). Same canonical + # Order shape via the shared _transform_order_from_api helper. + + def _make_api_order_row(self, **overrides): + """Build a raw REST-API order row (lowercase aliases + numeric enums).""" + base = { + "id": "0x" + "a" * 64, + "clientordid": "0x" + "b" * 64, + "tradepairid": "0xPairId_AVAX_USDC", + "price": "100", + "quantity": "1.5", + "quantityfilled": "0.5", + "status": 4, # CANCELED + "side": 1, # SELL + "type1": 1, # LIMIT + "type2": 0, # GTC + "pair": "AVAX/USDC", + "totalamount": "150", + "totalfee": "0.1", + "traderaddress": VALID_ADDRESS, + "createBlock": 100, + "updateBlock": 101, + "timestamp": "2024-01-01T00:00:00.000Z", + "update_ts": "2024-01-01T00:01:00.000Z", + "tx": "0xfeed", + } + base.update(overrides) + return base + + async def test_get_order_history_no_wallet_and_no_account_returns_fail(self, client): + """No configured account and no explicit account → Result.fail without any REST call.""" + client.account = None + api_spy = AsyncMock(return_value={"count": 0, "rows": []}) + with patch.object(client, "_api_call", api_spy): + result = await client.get_order_history() + assert not result.success + assert "get_order_history" in result.error + api_spy.assert_not_called() + + async def test_get_order_history_envelope_returns_canonical_orders(self, client): + """``{count, rows}`` envelope parsed; each row normalized to canonical Order.""" + api_spy = AsyncMock( + return_value={"count": 1, "rows": [self._make_api_order_row()]} + ) + with patch.object(client, "_api_call", api_spy): + result = await client.get_order_history() + assert result.success + assert len(result.data) == 1 + order = result.data[0] + assert order["internal_order_id"] == "0x" + "a" * 64 + assert order["client_order_id"] == "0x" + "b" * 64 + assert order["trade_pair_id"] == "0xPairId_AVAX_USDC" + assert order["pair"] == "AVAX/USDC" + assert order["price"] == 100.0 + assert order["total_amount"] == 150.0 + assert order["quantity"] == 1.5 + assert order["quantity_filled"] == 0.5 + assert order["total_fee"] == 0.1 + assert order["trader_address"] == VALID_ADDRESS + assert order["side"] == "SELL" + assert order["type1"] == "LIMIT" + assert order["type2"] == "GTC" + assert order["status"] == "CANCELED" + assert order["create_block"] == 100 + assert order["update_block"] == 101 + assert order["create_ts"] == "2024-01-01T00:00:00.000Z" + assert order["update_ts"] == "2024-01-01T00:01:00.000Z" + assert order["tx"] == "0xfeed" + # raw aliases stripped + assert "id" not in order + assert "clientordid" not in order + assert "tradepairid" not in order + + async def test_get_order_history_bare_list_response(self, client): + """Bare list response also accepted as forward-compat.""" + api_spy = AsyncMock(return_value=[self._make_api_order_row()]) + with patch.object(client, "_api_call", api_spy): + result = await client.get_order_history() + assert result.success + assert len(result.data) == 1 + assert result.data[0]["pair"] == "AVAX/USDC" + + async def test_get_order_history_empty_rows(self, client): + """Empty rows list returns Result.ok([]).""" + api_spy = AsyncMock(return_value={"count": 0, "rows": []}) + with patch.object(client, "_api_call", api_spy): + result = await client.get_order_history() + assert result.success + assert result.data == [] + + async def test_get_order_history_single_dict_wrapped(self, client): + """A single non-array object response is wrapped as a one-row result.""" + api_spy = AsyncMock(return_value=self._make_api_order_row()) + with patch.object(client, "_api_call", api_spy): + result = await client.get_order_history() + assert result.success + assert len(result.data) == 1 + assert result.data[0]["pair"] == "AVAX/USDC" + + async def test_get_order_history_unexpected_shape_fails(self, client): + """Non-object, non-array response surfaces Result.fail.""" + api_spy = AsyncMock(return_value="just a string") + with patch.object(client, "_api_call", api_spy): + result = await client.get_order_history() + assert not result.success + assert "order history response shape" in result.error + + async def test_get_order_history_explicit_account_overrides_wallet(self, client): + """Explicit account argument overrides the connected wallet's address.""" + other_addr = "0x" + "c" * 40 + api_spy = AsyncMock(return_value={"count": 0, "rows": []}) + with patch.object(client, "_api_call", api_spy): + await client.get_order_history(other_addr) + _, kwargs = api_spy.call_args + assert kwargs["params"]["traderaddress"] == other_addr + + async def test_get_order_history_no_wallet_but_explicit_account(self, client): + """Without a signer but with an explicit account, the call still works + (auth header is omitted; backend may reject — SDK doesn't guard).""" + client.account = None + api_spy = AsyncMock(return_value={"count": 0, "rows": []}) + with patch.object(client, "_api_call", api_spy): + result = await client.get_order_history("0x" + "d" * 40) + assert result.success + assert result.data == [] + _, kwargs = api_spy.call_args + # No auth header when no signer + assert kwargs["headers"] == {} + assert kwargs["params"]["traderaddress"] == "0x" + "d" * 40 + + async def test_get_order_history_forwards_filters(self, client): + """pair / status / limit / offset all forwarded as query params.""" + api_spy = AsyncMock(return_value={"count": 0, "rows": []}) + with patch.object(client, "_api_call", api_spy): + await client.get_order_history( + pair="AVAX/USDC", status="FILLED", limit=25, offset=50 + ) + _, kwargs = api_spy.call_args + params = kwargs["params"] + assert params["traderaddress"] == VALID_ADDRESS + assert params["pair"] == "AVAX/USDC" + assert params["status"] == "FILLED" + assert params["limit"] == 25 + assert params["offset"] == 50 + + async def test_get_order_history_default_pagination(self, client): + """Defaults: limit=100, offset=0.""" + api_spy = AsyncMock(return_value={"count": 0, "rows": []}) + with patch.object(client, "_api_call", api_spy): + await client.get_order_history() + _, kwargs = api_spy.call_args + assert kwargs["params"]["limit"] == 100 + assert kwargs["params"]["offset"] == 0 + + async def test_get_order_history_endpoint_path(self, client): + """Path is /api/trading/signed/orders (NOT /privapi/signed/orders).""" + api_spy = AsyncMock(return_value={"count": 0, "rows": []}) + with patch.object(client, "_api_call", api_spy): + await client.get_order_history() + args, _ = api_spy.call_args + assert "/api/trading/signed/orders" in args[1] + assert "/privapi/" not in args[1] + + async def test_get_order_history_attaches_auth_header_when_signer_present(self, client): + """x-signature header attached via _get_auth_headers when a signer exists.""" + api_spy = AsyncMock(return_value={"count": 0, "rows": []}) + with patch.object(client, "_api_call", api_spy): + with patch.object( + client, "_get_auth_headers", return_value={"x-signature": "sig"} + ): + await client.get_order_history() + _, kwargs = api_spy.call_args + assert kwargs["headers"] == {"x-signature": "sig"} + + async def test_get_order_history_signer_rejection_returns_fail(self, client): + """Auth header generation failure surfaces as Result.fail.""" + api_spy = AsyncMock(return_value={"count": 0, "rows": []}) + with patch.object(client, "_api_call", api_spy): + with patch.object( + client, + "_get_auth_headers", + side_effect=Exception("Private key not configured."), + ): + result = await client.get_order_history() + assert not result.success + api_spy.assert_not_called() + + async def test_get_order_history_api_error_preserves_reason_code(self, client): + """REST failure (with backend reason code) surfaced as Result.fail.""" + api_spy = AsyncMock(side_effect=RuntimeError("FQ-123: upstream down")) + with patch.object(client, "_api_call", api_spy): + result = await client.get_order_history() + assert not result.success + assert "FQ-123" in result.error + + async def test_get_order_history_address_lookup_failure(self, client): + """A signer that raises when address is read surfaces Result.fail.""" + + class BrokenAccount: + @property + def address(self): + raise RuntimeError("signer is broken") + + client.account = BrokenAccount() + api_spy = AsyncMock(return_value={"count": 0, "rows": []}) + with patch.object(client, "_api_call", api_spy): + result = await client.get_order_history() + assert not result.success + api_spy.assert_not_called() + + async def test_get_order_history_invalid_pair_returns_fail(self, client): + """Malformed pair filter rejected before any REST call.""" + api_spy = AsyncMock(return_value={"count": 0, "rows": []}) + with patch.object(client, "_api_call", api_spy): + result = await client.get_order_history(pair="INVALID") + assert not result.success + assert "pair" in result.error + api_spy.assert_not_called() + + async def test_get_order_history_hydrates_pairs_when_row_missing_pair_info(self, client): + """Row without trade_pair_id triggers a get_clob_pairs() hydration.""" + from dexalot_sdk.utils.result import Result + + client.pairs = {} + get_pairs_spy = AsyncMock(return_value=Result.ok([{"pair": "AVAX/USDC"}])) + api_spy = AsyncMock( + return_value={ + "count": 1, + "rows": [ + { + # No tradePairId — forces hydration + "id": "0x" + "a" * 64, + "pair": "AVAX/USDC", + "price": "1", + "quantity": "1", + "side": 0, + "type1": 1, + "type2": 0, + "status": 3, + "createBlock": 1, + "updateBlock": 2, + } + ], + } + ) + with patch.object(client, "_api_call", api_spy): + with patch.object(client, "get_clob_pairs", get_pairs_spy): + result = await client.get_order_history() + assert result.success + assert len(result.data) == 1 + get_pairs_spy.assert_awaited_once() + + async def test_get_order_history_pair_hydration_failure_propagates(self, client): + """If hydration of pairs fails, error is propagated.""" + from dexalot_sdk.utils.result import Result + + client.pairs = {} + get_pairs_spy = AsyncMock(return_value=Result.fail("pair fetch down")) + api_spy = AsyncMock( + return_value={ + "count": 1, + "rows": [ + { + # No tradePairId — forces hydration + "id": "0x" + "a" * 64, + "price": "1", + "quantity": "1", + "side": 0, + "type1": 1, + "type2": 0, + "status": 3, + "createBlock": 1, + "updateBlock": 2, + } + ], + } + ) + with patch.object(client, "_api_call", api_spy): + with patch.object(client, "get_clob_pairs", get_pairs_spy): + result = await client.get_order_history() + assert not result.success + assert "pair fetch down" in result.error + + async def test_get_order_history_caches_per_address_and_opts(self, client): + """Cache key includes (address, all opts); distinct combos do not collide.""" + client._cache_enabled = True + api_spy = AsyncMock(return_value={"count": 0, "rows": []}) + with patch.object(client, "_api_call", api_spy): + await client.get_order_history() + await client.get_order_history() # cache hit (same addr + opts) + await client.get_order_history(pair="AVAX/USDC") # distinct slot + await client.get_order_history(status="FILLED") # distinct slot + await client.get_order_history(limit=50) # distinct slot + await client.get_order_history(offset=10) # distinct slot + # Change address → distinct slot + other_addr = "0x" + "e" * 40 + await client.get_order_history(other_addr) + assert api_spy.await_count == 6 + + async def test_get_order_history_cache_bypass_when_disabled(self, client): + """When _cache_enabled is False every call hits REST.""" + client._cache_enabled = False + api_spy = AsyncMock(return_value={"count": 0, "rows": []}) + with patch.object(client, "_api_call", api_spy): + await client.get_order_history() + await client.get_order_history() + assert api_spy.await_count == 2 + async def test_cancel_all_orders(self, client): """Test cancel_all_orders.""" # Mock get_open_orders From 23ef45d84184a9092d350cfee77a93747a4d5e52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=BCseyin=20Deniz?= Date: Tue, 2 Jun 2026 21:41:02 +0300 Subject: [PATCH 07/14] docs(readme): document 5 new methods + get_deployment filters + error preservation Adds new methods to Cached Methods lists, type-normalization entries for PricePoint + Transfer, an Error Handling note on preserved backend reason codes, and a get_order_history usage snippet. Mirrors TypeScript SDK commit fa4959c. --- README.md | 46 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 94d9cf4..eb7e38a 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,20 @@ else: ``` See `examples/error_handling.py` for comprehensive error handling patterns. + +### Order History + +```python +result = await client.get_order_history( + pair="ALOT/USDC", + status="FILLED", + limit=50, +) +if result.success: + for order in result.data: + print(f"{order['pair']} {order['side']} {order['quantity']} @ {order['price']}") +``` + ## Dependencies - `web3>=6.0.0`: Multi-chain blockchain interactions (AsyncWeb3 for async operations) @@ -300,12 +314,15 @@ client.invalidate_cache(level="balance") # Options: static, semi_static, balanc **Static Data (1 hour):** - `get_environments()` - `get_chains()` -- `get_deployment()` +- `get_deployment()` (also caches per `(env, contract_type, return_abi)` filter combination) +- `get_token_price_history(token, *, from_ts=None, to_ts=None)` +- `get_token_hourly_price_history(token, *, from_ts=None, to_ts=None)` **Semi-Static Data (15 minutes):** - `get_tokens()` - `get_clob_pairs()` - `get_swap_pairs(chain_identifier)` +- `get_token_usd_prices(env=None)` **Balance Data (10 seconds):** - `get_portfolio_balance(token, address=None)` @@ -314,6 +331,8 @@ client.invalidate_cache(level="balance") # Options: static, semi_static, balanc - `get_chain_wallet_balances(chain, address=None)` - `get_chain_token_balances(chain, address=None, tokens=...)` - `get_all_chain_wallet_balances(address=None)` +- `get_order_history(account=None, *, pair=None, status=None, limit=100, offset=0)` +- `get_combined_transfers(*, kind=None, from_ts=None, to_ts=None, limit=100, offset=0)` **Orderbook Data (1 second):** - `get_orderbook(pair)` @@ -787,6 +806,17 @@ Error messages are automatically sanitized to prevent information leakage: - Stack traces are removed - User-friendly messages are provided +### Backend reason codes + +Errors from the Dexalot REST API include structured `reasonCode` (e.g. `FQ-015`, `P-AFNE-02`, `T-TMDQ-01`, `RF-IMV-01`) and human `reason` fields. These are preserved verbatim in `Result.fail()` messages — you'll see `"FQ-015: insufficient liquidity"` rather than the generic `"Request failed with status code 400"`. Pattern-match on the code prefix to react programmatically: + +```python +result = await client.get_swap_firm_quote("USDC", "AVAX", 100) +if not result.success and result.error.startswith("FQ-"): + # RFQ backend rejected the quote — see the reason code prefix for why + ... +``` + ### Best Practices 1. **Always check `result.success`** before accessing `result.data` @@ -988,6 +1018,20 @@ Orders are normalized into one canonical SDK shape regardless of whether the sou **Deployment API:** - `env`, `address`, `abi` (handles variations like `Env`, `Address`, `Abi`) +**Price History API (`get_token_price_history`, `get_token_hourly_price_history`):** +- Returns `list[PricePoint]` with `timestamp` (unix seconds, UTC) and `price` (`float`). +- `timestamp` is normalized from `date` ISO-8601, `ts`, `timestamp`, or `time` aliases — millisecond magnitudes are auto-detected and divided down to seconds. +- `price` is coerced from a string decimal to `float`; scientific notation is supported. +- Rows are returned sorted ascending by `timestamp`. + +**Combined Transfers API (`get_combined_transfers`):** +- Returns `list[Transfer]` — a frozen dataclass with snake_case fields normalized from the backend's `DBTransfer` shape: `action_type`, `status`, `symbol`, `quantity`, `fee`, `trader_address`, `bridge`, `bridge_url`, `nonce`, `source_env`, `source_chain_id`, `source_tx`, `source_ts`, `target_env`, `target_chain_id`, `target_tx`, `target_ts`. +- Numeric enums are mapped to string `Literal` labels: `status` (`COMPLETED`/`INFLIGHT`/`DELAYED`), `action_type` (10 labels including `WITHDRAWN`/`DEPOSITED`/`SENT`/`RECEIVED`/`RECOVERED`/`ADD_GAS`/`REMOVE_GAS`/`AUTO_FILL`/`WITHDRAW_PENDING`/`DEPOSIT_PENDING`), `bridge` (`NATIVE`/`LAYER0`/`CELER`/`ICM`). +- `quantity` and `fee` arrive as display-decimal strings — no wei→human conversion is needed (parsed via `float()`). + +**Order History API (`get_order_history`):** +- Same canonical order dict and field aliases as `get_open_orders` — see the "Orders API" entry above. + ### Benefits - **Consistent interface**: Field names are exposed in snake_case in Python consistently. From b1b7c0dcb9b1f3cd42878d6081775f3b4a49abda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=BCseyin=20Deniz?= Date: Tue, 2 Jun 2026 22:01:45 +0300 Subject: [PATCH 08/14] =?UTF-8?q?chore(release):=20bump=20to=20v0.5.16=20(?= =?UTF-8?q?skip=200.5.15=20=E2=80=94=20already=20on=20PyPI)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six new features in this PR (order history, USD valuation x3, combined transfers, get_deployment filters) plus reason_code/reason error preservation. Local was at 0.5.14; PyPI has 0.5.15 already, so skip to 0.5.16 to avoid version collision. --- VERSION | 2 +- pyproject.toml | 2 +- src/dexalot_sdk/__init__.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/VERSION b/VERSION index 83ac1cc..3afb327 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.14 +0.5.16 diff --git a/pyproject.toml b/pyproject.toml index 51906a2..06e9675 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ license = "MIT" license-files = [ "LICENSE.txt" ] -version = "0.5.14" +version = "0.5.16" description = "Dexalot Python SDK - Core library for Dexalot interaction" readme = "README.md" requires-python = ">=3.12,<3.15" diff --git a/src/dexalot_sdk/__init__.py b/src/dexalot_sdk/__init__.py index d4431a3..5fa290a 100644 --- a/src/dexalot_sdk/__init__.py +++ b/src/dexalot_sdk/__init__.py @@ -12,7 +12,7 @@ secrets_vault_set, ) -__version__ = "0.5.14" +__version__ = "0.5.16" def get_version() -> str: From a7183126f05784c604384acb2e59e45c315f8a1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=BCseyin=20Deniz?= Date: Thu, 4 Jun 2026 17:56:07 +0300 Subject: [PATCH 09/14] chore: bump dexalot-sdk version to 0.5.16 --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index c2eeecf..44ceb63 100644 --- a/uv.lock +++ b/uv.lock @@ -733,7 +733,7 @@ wheels = [ [[package]] name = "dexalot-sdk" -version = "0.5.14" +version = "0.5.16" source = { editable = "." } dependencies = [ { name = "aiohttp" }, From 6c7c3507997a357c1251eaa2b42a9cef61062ed2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=BCseyin=20Deniz?= Date: Thu, 4 Jun 2026 18:22:40 +0300 Subject: [PATCH 10/14] style: ruff format pass on 4 files CI's `ruff format --check` caught formatting drift in: - src/dexalot_sdk/core/transfer.py - tests/unit/core/{test_base,test_clob,test_transfer}.py My local Makefile target `make lint` only runs `ruff check` (lint), not `ruff format --check`. The CI runs both. Auto-applied with `ruff format .`. --- src/dexalot_sdk/core/transfer.py | 4 +--- tests/unit/core/test_base.py | 4 +++- tests/unit/core/test_clob.py | 12 +++--------- tests/unit/core/test_transfer.py | 20 +++++--------------- 4 files changed, 12 insertions(+), 28 deletions(-) diff --git a/src/dexalot_sdk/core/transfer.py b/src/dexalot_sdk/core/transfer.py index ba3805b..bfd5432 100644 --- a/src/dexalot_sdk/core/transfer.py +++ b/src/dexalot_sdk/core/transfer.py @@ -1948,9 +1948,7 @@ async def get_token_price_history( No authentication required (public endpoint). """ - return await self._fetch_price_history( - ENDPOINT_INFO_PRICE_HISTORY, token, from_ts, to_ts - ) + return await self._fetch_price_history(ENDPOINT_INFO_PRICE_HISTORY, token, from_ts, to_ts) @track_method("transfer") async def get_token_hourly_price_history( diff --git a/tests/unit/core/test_base.py b/tests/unit/core/test_base.py index 252bce5..cd45b0f 100644 --- a/tests/unit/core/test_base.py +++ b/tests/unit/core/test_base.py @@ -2783,7 +2783,9 @@ async def test_api_call_reason_code_without_reason_uses_generic_tail(self, clien """When reasonCode is set but reason is missing, fall back to a generic tail.""" cm = self._http_error_response(500, {"reasonCode": "P-OK01"}) with patch.object(client, "_make_http_request", AsyncMock(return_value=cm)): - with pytest.raises(RuntimeError, match=r"^P-OK01: Request failed with status code 500$"): + with pytest.raises( + RuntimeError, match=r"^P-OK01: Request failed with status code 500$" + ): await client._api_call("get", "https://api/x") async def test_api_call_reason_alone_without_reason_code(self, client): diff --git a/tests/unit/core/test_clob.py b/tests/unit/core/test_clob.py index 2ba1258..51fce08 100644 --- a/tests/unit/core/test_clob.py +++ b/tests/unit/core/test_clob.py @@ -1209,9 +1209,7 @@ async def test_get_order_history_no_wallet_and_no_account_returns_fail(self, cli async def test_get_order_history_envelope_returns_canonical_orders(self, client): """``{count, rows}`` envelope parsed; each row normalized to canonical Order.""" - api_spy = AsyncMock( - return_value={"count": 1, "rows": [self._make_api_order_row()]} - ) + api_spy = AsyncMock(return_value={"count": 1, "rows": [self._make_api_order_row()]}) with patch.object(client, "_api_call", api_spy): result = await client.get_order_history() assert result.success @@ -1302,9 +1300,7 @@ async def test_get_order_history_forwards_filters(self, client): """pair / status / limit / offset all forwarded as query params.""" api_spy = AsyncMock(return_value={"count": 0, "rows": []}) with patch.object(client, "_api_call", api_spy): - await client.get_order_history( - pair="AVAX/USDC", status="FILLED", limit=25, offset=50 - ) + await client.get_order_history(pair="AVAX/USDC", status="FILLED", limit=25, offset=50) _, kwargs = api_spy.call_args params = kwargs["params"] assert params["traderaddress"] == VALID_ADDRESS @@ -1335,9 +1331,7 @@ async def test_get_order_history_attaches_auth_header_when_signer_present(self, """x-signature header attached via _get_auth_headers when a signer exists.""" api_spy = AsyncMock(return_value={"count": 0, "rows": []}) with patch.object(client, "_api_call", api_spy): - with patch.object( - client, "_get_auth_headers", return_value={"x-signature": "sig"} - ): + with patch.object(client, "_get_auth_headers", return_value={"x-signature": "sig"}): await client.get_order_history() _, kwargs = api_spy.call_args assert kwargs["headers"] == {"x-signature": "sig"} diff --git a/tests/unit/core/test_transfer.py b/tests/unit/core/test_transfer.py index 766d6c2..2ef2a78 100644 --- a/tests/unit/core/test_transfer.py +++ b/tests/unit/core/test_transfer.py @@ -2595,9 +2595,7 @@ async def test_get_token_price_history_normalizes_and_sorts(self, client): assert result.success assert isinstance(result.data[0], PricePoint) # Ascending by timestamp - assert [p.timestamp for p in result.data] == sorted( - p.timestamp for p in result.data - ) + assert [p.timestamp for p in result.data] == sorted(p.timestamp for p in result.data) assert result.data[0].price == 8.25 assert result.data[-1].price == 10.5 @@ -2624,9 +2622,7 @@ async def test_get_token_price_history_forwards_from_and_to(self, client): """``from_ts``/``to_ts`` forwarded as ``from``/``to`` query params.""" api_spy = AsyncMock(return_value=[]) with patch.object(client, "_api_call", api_spy): - await client.get_token_price_history( - "ALOT", from_ts=1700000000, to_ts=1700864000 - ) + await client.get_token_price_history("ALOT", from_ts=1700000000, to_ts=1700864000) _, kwargs = api_spy.call_args assert kwargs["params"] == { "token": "ALOT", @@ -3014,9 +3010,7 @@ async def test_get_combined_transfers_forwards_kind_and_period(self, client): """kind → symbol; from_ts → periodfrom; to_ts → periodto.""" api_spy = AsyncMock(return_value={"count": 0, "rows": []}) with patch.object(client, "_api_call", api_spy): - await client.get_combined_transfers( - kind="ALOT", from_ts=1700000000, to_ts=1700864000 - ) + await client.get_combined_transfers(kind="ALOT", from_ts=1700000000, to_ts=1700864000) _, kwargs = api_spy.call_args assert kwargs["params"]["symbol"] == "ALOT" assert kwargs["params"]["periodfrom"] == 1700000000 @@ -3026,9 +3020,7 @@ async def test_get_combined_transfers_attaches_signature_header(self, client): """``x-signature`` header attached via _get_auth_headers.""" api_spy = AsyncMock(return_value={"count": 0, "rows": []}) with patch.object(client, "_api_call", api_spy): - with patch.object( - client, "_get_auth_headers", return_value={"x-signature": "sig"} - ): + with patch.object(client, "_get_auth_headers", return_value={"x-signature": "sig"}): await client.get_combined_transfers() _, kwargs = api_spy.call_args assert kwargs["headers"] == {"x-signature": "sig"} @@ -3089,9 +3081,7 @@ async def test_get_combined_transfers_cache_bypass_when_disabled(self, client): async def test_get_combined_transfers_normalizes_user_symbol(self, client): """``kind`` is run through _normalize_user_token before being forwarded.""" api_spy = AsyncMock(return_value={"count": 0, "rows": []}) - with patch.object( - client, "_normalize_user_token", return_value="USDC" - ) as norm_spy: + with patch.object(client, "_normalize_user_token", return_value="USDC") as norm_spy: with patch.object(client, "_api_call", api_spy): await client.get_combined_transfers(kind="usdc") norm_spy.assert_called_once_with("usdc") From 0ba467c9793643ac318d9a91b1dd166e6d84318f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=BCseyin=20Deniz?= Date: Mon, 15 Jun 2026 16:12:10 +0300 Subject: [PATCH 11/14] reconcile(transfer): align gap-fill public shapes to cross-SDK spec (B1-B4) Breaking changes that must land before merge to match the canonical cross-SDK spec for the gap-fill surface. B1: rename get_combined_transfers `kind` kwarg -> `symbol`. Body now normalizes `symbol` via _normalize_user_token; docstring arg doc and cache-key tuple doc updated to `symbol`. The cached layer already keys/forwards on the symbol value. B2: Transfer.target_* legs are now nullable (str|None / int|None) and _normalize_transfer emits None (not "" / 0) for non-crossing rows. target_ts drops the trailing `or 0`; source_ts stays non-null. B3: tests updated to symbol= call sites; added a null-target-legs test asserting target_env/target_chain_id/target_tx/target_ts are None. Coverage held at 100%. B4: README get_combined_transfers param renamed and Transfer nullability note added. (CLAUDE.md does not document this field shape -> untouched.) get_order_history already validates + normalizes the pair (no gap). --- README.md | 4 +-- src/dexalot_sdk/core/transfer.py | 27 ++++++++-------- tests/unit/core/test_transfer.py | 53 ++++++++++++++++++++++++++++---- 3 files changed, 63 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index e5f4d54..42741cb 100644 --- a/README.md +++ b/README.md @@ -332,7 +332,7 @@ client.invalidate_cache(level="balance") # Options: static, semi_static, balanc - `get_chain_token_balances(chain, address=None, tokens=...)` - `get_all_chain_wallet_balances(address=None)` - `get_order_history(account=None, *, pair=None, status=None, limit=100, offset=0)` -- `get_combined_transfers(*, kind=None, from_ts=None, to_ts=None, limit=100, offset=0)` +- `get_combined_transfers(*, symbol=None, from_ts=None, to_ts=None, limit=100, offset=0)` **Orderbook Data (1 second):** - `get_orderbook(pair)` @@ -1025,7 +1025,7 @@ Orders are normalized into one canonical SDK shape regardless of whether the sou - Rows are returned sorted ascending by `timestamp`. **Combined Transfers API (`get_combined_transfers`):** -- Returns `list[Transfer]` — a frozen dataclass with snake_case fields normalized from the backend's `DBTransfer` shape: `action_type`, `status`, `symbol`, `quantity`, `fee`, `trader_address`, `bridge`, `bridge_url`, `nonce`, `source_env`, `source_chain_id`, `source_tx`, `source_ts`, `target_env`, `target_chain_id`, `target_tx`, `target_ts`. +- Returns `list[Transfer]` — a frozen dataclass with snake_case fields normalized from the backend's `DBTransfer` shape: `action_type`, `status`, `symbol`, `quantity`, `fee`, `trader_address`, `bridge`, `bridge_url`, `nonce`, `source_env`, `source_chain_id`, `source_tx`, `source_ts`, `target_env`, `target_chain_id`, `target_tx`, `target_ts`. The `target_*` fields are `None` for non-crossing transfers (no target leg). - Numeric enums are mapped to string `Literal` labels: `status` (`COMPLETED`/`INFLIGHT`/`DELAYED`), `action_type` (10 labels including `WITHDRAWN`/`DEPOSITED`/`SENT`/`RECEIVED`/`RECOVERED`/`ADD_GAS`/`REMOVE_GAS`/`AUTO_FILL`/`WITHDRAW_PENDING`/`DEPOSIT_PENDING`), `bridge` (`NATIVE`/`LAYER0`/`CELER`/`ICM`). - `quantity` and `fee` arrive as display-decimal strings — no wei→human conversion is needed (parsed via `float()`). diff --git a/src/dexalot_sdk/core/transfer.py b/src/dexalot_sdk/core/transfer.py index bfd5432..43e16c5 100644 --- a/src/dexalot_sdk/core/transfer.py +++ b/src/dexalot_sdk/core/transfer.py @@ -95,10 +95,11 @@ class Transfer: source_chain_id: int source_tx: str source_ts: int - target_env: str - target_chain_id: int - target_tx: str - target_ts: int + # ``target_*`` is ``None`` for non-crossing transfers (no target leg). + target_env: str | None + target_chain_id: int | None + target_tx: str | None + target_ts: int | None # Numeric enum → human-readable label lookup tables for the @@ -2122,19 +2123,19 @@ def _normalize_transfer(self, raw: Any) -> Transfer | None: source_ts = self._coerce_timestamp_seconds(raw.get("source_ts")) or 0 target_env_raw = raw.get("target_env") - target_env = target_env_raw if isinstance(target_env_raw, str) else "" + target_env = target_env_raw if isinstance(target_env_raw, str) else None target_chain_id_raw = raw.get("target_chain_id") target_chain_id = ( target_chain_id_raw if isinstance(target_chain_id_raw, int) and not isinstance(target_chain_id_raw, bool) - else 0 + else None ) target_tx_raw = raw.get("target_tx") - target_tx = target_tx_raw if isinstance(target_tx_raw, str) else "" + target_tx = target_tx_raw if isinstance(target_tx_raw, str) else None - target_ts = self._coerce_timestamp_seconds(raw.get("target_ts")) or 0 + target_ts = self._coerce_timestamp_seconds(raw.get("target_ts")) return Transfer( action_type=action_type, @@ -2160,7 +2161,7 @@ def _normalize_transfer(self, raw: Any) -> Transfer | None: async def get_combined_transfers( self, *, - kind: str | None = None, + symbol: str | None = None, from_ts: int | None = None, to_ts: int | None = None, limit: int = 100, @@ -2183,14 +2184,14 @@ async def get_combined_transfers( (``pageno = (offset // limit) + 1``). Cached for 10 seconds (balance tier) per - ``(address, kind, from_ts, to_ts, limit, offset)`` tuple — + ``(address, symbol, from_ts, to_ts, limit, offset)`` tuple — distinct signers and distinct filter combinations never share a cache slot. Returned ``quantity`` / ``fee`` are already display- decimal — no wei→human conversion is applied because the backend has already done it. Args: - kind: Optional token symbol filter (forwarded to backend as + symbol: Optional token symbol filter (forwarded to backend as ``symbol``). Normalised through :meth:`_normalize_user_token`. from_ts: Optional unix-seconds lower bound (forwarded as ``periodfrom``). @@ -2209,8 +2210,8 @@ async def get_combined_transfers( return Result.fail(self._sanitize_error(e, "resolving wallet address")) normalized_symbol: str | None = None - if kind is not None: - normalized_symbol = self._normalize_user_token(kind) + if symbol is not None: + normalized_symbol = self._normalize_user_token(symbol) # Translate (limit, offset) → (itemsperpage, pageno). The backend # uses 1-indexed pages; offset 0 → page 1. Defensive ``max(1, ...)`` diff --git a/tests/unit/core/test_transfer.py b/tests/unit/core/test_transfer.py index 2ef2a78..8cd818c 100644 --- a/tests/unit/core/test_transfer.py +++ b/tests/unit/core/test_transfer.py @@ -2816,6 +2816,47 @@ async def test_get_combined_transfers_basic_envelope(self, client): assert t.source_chain_id == 43113 assert t.target_chain_id == 12345 + async def test_get_combined_transfers_null_target_legs(self, client): + """A non-crossing row (omitted/null target_* fields) yields None legs.""" + api_spy = AsyncMock( + return_value={ + "count": 1, + "rows": [ + { + "action_type": 1, + "status": 0, + "symbol": "USDC", + "quantity": "100.5", + "fee": "0.25", + "traderaddress": VALID_ADDRESS, + "bridge": 2, + "bridge_url": "https://bridge.example/x", + "nonce": 7, + "source_env": "fuji-multi-avax", + "source_chain_id": 43113, + "source_tx": "0xabc", + "source_ts": 1700000000, + # target_* intentionally omitted / null + "target_env": None, + "target_chain_id": None, + "target_tx": None, + "target_ts": None, + } + ], + } + ) + with patch.object(client, "_api_call", api_spy): + result = await client.get_combined_transfers() + assert result.success + assert len(result.data) == 1 + t = result.data[0] + assert t.target_env is None + assert t.target_chain_id is None + assert t.target_tx is None + assert t.target_ts is None + # source leg is still non-null + assert t.source_ts == 1700000000 + async def test_get_combined_transfers_bare_array_fallback(self, client): """Bare list response also accepted as forward-compat.""" api_spy = AsyncMock( @@ -3006,11 +3047,11 @@ async def test_get_combined_transfers_custom_pagination_translation(self, client assert kwargs["params"]["itemsperpage"] == 50 assert kwargs["params"]["pageno"] == 3 - async def test_get_combined_transfers_forwards_kind_and_period(self, client): - """kind → symbol; from_ts → periodfrom; to_ts → periodto.""" + async def test_get_combined_transfers_forwards_symbol_and_period(self, client): + """symbol → symbol; from_ts → periodfrom; to_ts → periodto.""" api_spy = AsyncMock(return_value={"count": 0, "rows": []}) with patch.object(client, "_api_call", api_spy): - await client.get_combined_transfers(kind="ALOT", from_ts=1700000000, to_ts=1700864000) + await client.get_combined_transfers(symbol="ALOT", from_ts=1700000000, to_ts=1700864000) _, kwargs = api_spy.call_args assert kwargs["params"]["symbol"] == "ALOT" assert kwargs["params"]["periodfrom"] == 1700000000 @@ -3060,7 +3101,7 @@ async def test_get_combined_transfers_cache_distinct_per_address_and_opts(self, with patch.object(client, "_api_call", api_spy): await client.get_combined_transfers() await client.get_combined_transfers() # cache hit (same addr + opts) - await client.get_combined_transfers(kind="ALOT") # distinct slot + await client.get_combined_transfers(symbol="ALOT") # distinct slot await client.get_combined_transfers(limit=50) # distinct slot # Change address → distinct slot other_addr = "0x" + "b" * 40 @@ -3079,11 +3120,11 @@ async def test_get_combined_transfers_cache_bypass_when_disabled(self, client): assert api_spy.await_count == 2 async def test_get_combined_transfers_normalizes_user_symbol(self, client): - """``kind`` is run through _normalize_user_token before being forwarded.""" + """``symbol`` is run through _normalize_user_token before being forwarded.""" api_spy = AsyncMock(return_value={"count": 0, "rows": []}) with patch.object(client, "_normalize_user_token", return_value="USDC") as norm_spy: with patch.object(client, "_api_call", api_spy): - await client.get_combined_transfers(kind="usdc") + await client.get_combined_transfers(symbol="usdc") norm_spy.assert_called_once_with("usdc") _, kwargs = api_spy.call_args assert kwargs["params"]["symbol"] == "USDC" From e5dab096aeeddc540650ad11d431f8315661a6fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=BCseyin=20Deniz?= Date: Mon, 15 Jun 2026 16:39:35 +0300 Subject: [PATCH 12/14] fix(transfer): convert get_combined_transfers fromTs/toTs to ISO-8601 for the backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reconciliation forwarded the ergonomic unix-seconds from_ts/to_ts straight through as the backend's periodfrom/periodto, but that endpoint rejects raw unix integers with "Malformed Request! ISO Date format problem". Convert to ISO-8601 (matching the TS SDK's new Date(ts*1000).toISOString() output byte-for-byte) before sending. Public API is unchanged — from_ts/to_ts stay unix-seconds ints; only the internal backend translation is corrected. Found via live devnet smoke through the trade-kit consumer (unit tests mock the SDK so couldn't catch the malformed query). --- src/dexalot_sdk/core/transfer.py | 25 ++++++++++++++++++++++--- tests/unit/core/test_transfer.py | 9 ++++++--- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/src/dexalot_sdk/core/transfer.py b/src/dexalot_sdk/core/transfer.py index 43e16c5..da018bc 100644 --- a/src/dexalot_sdk/core/transfer.py +++ b/src/dexalot_sdk/core/transfer.py @@ -1,7 +1,7 @@ import asyncio import math from dataclasses import dataclass -from datetime import datetime +from datetime import UTC, datetime from typing import Any, Literal, cast from ..constants import ( @@ -2232,6 +2232,21 @@ async def get_combined_transfers( ), ) + @staticmethod + def _unix_seconds_to_iso(ts: int) -> str: + """Convert unix seconds to an ISO-8601 string (e.g. ``2026-05-01T00:00:00.000Z``). + + The combined-transfers backend rejects raw unix integers for + ``periodfrom``/``periodto`` with "Malformed Request! ISO Date format + problem"; they must be ISO-8601. Matches the TypeScript SDK's + ``new Date(ts * 1000).toISOString()`` output byte-for-byte. + """ + return ( + datetime.fromtimestamp(ts, tz=UTC) + .isoformat(timespec="milliseconds") + .replace("+00:00", "Z") + ) + @async_ttl_cached(_BALANCE_CACHE) async def _get_combined_transfers_cached( self, @@ -2259,10 +2274,14 @@ async def _get_combined_transfers_cached( } if symbol is not None: params["symbol"] = symbol + # The backend's periodfrom/periodto expect ISO-8601 date strings, not + # unix seconds — forwarding the raw integers makes the endpoint reject + # the request ("Malformed Request! ISO Date format problem"). Convert + # the ergonomic unix-seconds inputs to ISO-8601 here. if period_from is not None: - params["periodfrom"] = period_from + params["periodfrom"] = self._unix_seconds_to_iso(period_from) if period_to is not None: - params["periodto"] = period_to + params["periodto"] = self._unix_seconds_to_iso(period_to) try: data = await self._api_call( diff --git a/tests/unit/core/test_transfer.py b/tests/unit/core/test_transfer.py index 8cd818c..06224b8 100644 --- a/tests/unit/core/test_transfer.py +++ b/tests/unit/core/test_transfer.py @@ -3048,14 +3048,17 @@ async def test_get_combined_transfers_custom_pagination_translation(self, client assert kwargs["params"]["pageno"] == 3 async def test_get_combined_transfers_forwards_symbol_and_period(self, client): - """symbol → symbol; from_ts → periodfrom; to_ts → periodto.""" + """symbol → symbol; from_ts → periodfrom; to_ts → periodto (ISO-8601).""" api_spy = AsyncMock(return_value={"count": 0, "rows": []}) with patch.object(client, "_api_call", api_spy): await client.get_combined_transfers(symbol="ALOT", from_ts=1700000000, to_ts=1700864000) _, kwargs = api_spy.call_args assert kwargs["params"]["symbol"] == "ALOT" - assert kwargs["params"]["periodfrom"] == 1700000000 - assert kwargs["params"]["periodto"] == 1700864000 + # from_ts/to_ts (unix seconds) are converted to ISO-8601 strings — the + # backend's periodfrom/periodto reject raw unix integers with + # "ISO Date format problem". + assert kwargs["params"]["periodfrom"] == "2023-11-14T22:13:20.000Z" + assert kwargs["params"]["periodto"] == "2023-11-24T22:13:20.000Z" async def test_get_combined_transfers_attaches_signature_header(self, client): """``x-signature`` header attached via _get_auth_headers.""" From f29e3ff55ab60f61e61e43ecebf2635d70066dd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=BCseyin=20Deniz?= Date: Tue, 16 Jun 2026 01:00:18 +0300 Subject: [PATCH 13/14] fix(transfer): parse ISO-8601 source_ts/target_ts in combined transfers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live devnet testing showed the combined-transfers backend returns source_ts/target_ts as ISO-8601 strings, but _coerce_timestamp_seconds is numeric-only — so it yielded source_ts=0 / target_ts=None for real data, violating the canonical "source_ts: unix-seconds int" shape (the TS SDK, which uses Date.parse, returned the correct seconds). Add _coerce_transfer_ts (ISO-8601 + numeric -> unix seconds, mirroring the TS _coerceTransferTs) and use it in _normalize_transfer. Verified live on devnet: source_ts/target_ts now match TS exactly (1779731190 / 1779731274). Unit tests previously mocked numeric timestamps, which is why they missed it — added a coercer branch test. --- src/dexalot_sdk/core/transfer.py | 24 ++++++++++++++++++++++-- tests/unit/core/test_transfer.py | 13 +++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/dexalot_sdk/core/transfer.py b/src/dexalot_sdk/core/transfer.py index da018bc..54791e2 100644 --- a/src/dexalot_sdk/core/transfer.py +++ b/src/dexalot_sdk/core/transfer.py @@ -1898,6 +1898,26 @@ def _coerce_timestamp_seconds(raw: Any) -> int | None: n = math.floor(n / 1000) return int(math.floor(n)) + @staticmethod + def _coerce_transfer_ts(raw: Any) -> int | None: + """ISO-8601 string OR numeric/numeric-string -> unix seconds (UTC), or None. + + The combined-transfers backend returns source_ts/target_ts as ISO-8601 + strings; _coerce_timestamp_seconds only handles numerics, so parse ISO + here first (mirrors the TS SDK's _coerceTransferTs). Falls back to the + numeric coercer for numeric / numeric-string inputs. + """ + if isinstance(raw, str) and raw.strip(): + try: + dt = datetime.fromisoformat(raw.strip().replace("Z", "+00:00")) + except ValueError: + dt = None + if dt is not None: + if dt.tzinfo is None: + dt = dt.replace(tzinfo=UTC) + return int(dt.timestamp()) + return TransferClient._coerce_timestamp_seconds(raw) + @staticmethod def _extract_history_timestamp(row: dict[str, Any]) -> int | None: """Pull a timestamp out of one raw price-history row. @@ -2120,7 +2140,7 @@ def _normalize_transfer(self, raw: Any) -> Transfer | None: source_tx_raw = raw.get("source_tx") source_tx = source_tx_raw if isinstance(source_tx_raw, str) else "" - source_ts = self._coerce_timestamp_seconds(raw.get("source_ts")) or 0 + source_ts = self._coerce_transfer_ts(raw.get("source_ts")) or 0 target_env_raw = raw.get("target_env") target_env = target_env_raw if isinstance(target_env_raw, str) else None @@ -2135,7 +2155,7 @@ def _normalize_transfer(self, raw: Any) -> Transfer | None: target_tx_raw = raw.get("target_tx") target_tx = target_tx_raw if isinstance(target_tx_raw, str) else None - target_ts = self._coerce_timestamp_seconds(raw.get("target_ts")) + target_ts = self._coerce_transfer_ts(raw.get("target_ts")) return Transfer( action_type=action_type, diff --git a/tests/unit/core/test_transfer.py b/tests/unit/core/test_transfer.py index 06224b8..9192efe 100644 --- a/tests/unit/core/test_transfer.py +++ b/tests/unit/core/test_transfer.py @@ -3146,3 +3146,16 @@ def address(self) -> str: result = await client.get_combined_transfers() assert not result.success api_spy.assert_not_called() + + +def test_coerce_transfer_ts_parses_iso_numeric_and_rejects_junk(): + """source_ts/target_ts arrive as ISO-8601 strings from the backend; the + coercer must parse those (tz-aware and naive) plus numerics, and reject junk.""" + f = TransferClient._coerce_transfer_ts + assert f("2023-11-14T22:13:20.000Z") == 1700000000 # ISO, tz-aware (Z) + assert f("2023-11-14T22:13:20") == 1700000000 # ISO, naive -> assume UTC + assert f("1700000000") == 1700000000 # numeric string -> fallback coercer + assert f(1700000000) == 1700000000 # int -> fallback coercer + assert f(None) is None # non-str -> fallback + assert f("") is None # empty string -> fallback + assert f("not a date") is None # unparseable -> None From 9a7ddbc3efaf1fb82bad97bd7224c8cc363ff336 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=BCseyin=20Deniz?= Date: Tue, 16 Jun 2026 01:08:01 +0300 Subject: [PATCH 14/14] chore(deps): bump aiohttp 3.14.1 + cryptography 48.0.1 for CVE fixes pip-audit (CI security gate) began failing on newly-published advisories: - aiohttp 3.14.0 -> 3.14.1 (8 CVEs: CVE-2026-54273..54280) - cryptography 47.0.0 -> 48.0.1 (GHSA-537c-gmf6-5ccf) cryptography's constraint was <48, which excluded the fix, so widened to >=48.0.1,<49; aiohttp floor raised to >=3.14.1. Relocked uv.lock. Tests (1045), 100% coverage, mypy, ruff, bandit, pip-audit all clean; live devnet smoke confirms the bumped HTTP/crypto libs work. --- pyproject.toml | 4 +- uv.lock | 264 ++++++++++++++++++++++++------------------------- 2 files changed, 134 insertions(+), 134 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5c1d7f0..2af98be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,11 +29,11 @@ classifiers = [ ] dependencies = [ "web3>=6.0.0,<8", - "aiohttp>=3.9,<4", + "aiohttp>=3.14.1,<4", "python-dotenv>=1.0,<2", "eth-account>=0.11,<1", "websockets>=13.0,<15", - "cryptography>=46,<48", + "cryptography>=48.0.1,<49", # Pin transitive deps to versions free of known CVEs (pip-audit gate). # - idna>=3.15 closes CVE-2026-45409 # - urllib3>=2.7.0 closes PYSEC-2026-141 and PYSEC-2026-142 diff --git a/uv.lock b/uv.lock index fc21c9b..01ee80d 100644 --- a/uv.lock +++ b/uv.lock @@ -13,7 +13,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.14.0" +version = "3.14.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -25,90 +25,90 @@ dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.13'" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ee/ab/93ce242f899b68c51b0578c027aafa791ab3614cb9345fa5d37b5f5c8e3e/aiohttp-3.14.0.tar.gz", hash = "sha256:2882de819734c715fd1b9c11c97e09fa020d14438203d1d354d8ed1702791c9b", size = 7940674, upload-time = "2026-06-01T19:41:02.763Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/89/97/2b6889bfb6b6847520d50d95eb8c4307a45e28aaca39faf4a9454b3d1b2f/aiohttp-3.14.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b29518c9c2ec7e373e68259206a137c7f4f5439c58baaec4b5ab3ab799850a4e", size = 750194, upload-time = "2026-06-01T19:37:48.164Z" }, - { url = "https://files.pythonhosted.org/packages/21/e2/62634b7fff918ed98c3c6b2f0e70d520f7f28846cb412d451b04354c6459/aiohttp-3.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:dbec68ce61b64cb73cab4d33df9433427b1713c8bcccb181dce695c1b6f8e87c", size = 506966, upload-time = "2026-06-01T19:37:50.014Z" }, - { url = "https://files.pythonhosted.org/packages/dd/fb/5ce075150828c797a5106f1c2fb26034e709d4289b9d2bf8b07f1e59fac6/aiohttp-3.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3cdf534aa455593e589302990c5097aa5c92c06c4262a20da22934f9186a5fff", size = 507527, upload-time = "2026-06-01T19:37:51.96Z" }, - { url = "https://files.pythonhosted.org/packages/01/d5/405a0ae4e6b081754a3609c1c97c63a950e000a2def16046f1e736933a0e/aiohttp-3.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb6c657104393b5fbff01a5f59b2023db74058a8077d94475d6c25d03882a108", size = 1762420, upload-time = "2026-06-01T19:37:53.839Z" }, - { url = "https://files.pythonhosted.org/packages/ae/1d/e05a7c896b15a6bc6fb8fc5319eb437861c2c49c34559ef928add6590315/aiohttp-3.14.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:46fbbec4e4fab7428d4396a3823f9320e4560aa3113b89eeebce712c27c9ed5a", size = 1733672, upload-time = "2026-06-01T19:37:55.791Z" }, - { url = "https://files.pythonhosted.org/packages/cc/22/a72f7c459e195fa41bf4f7abd1f925b91fe91f8097e51c654229ba144a33/aiohttp-3.14.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2c2c7e05dd5335b298085abf45ddf98673934c3ee1c083d0b9ea13d4186ad500", size = 1805064, upload-time = "2026-06-01T19:37:57.931Z" }, - { url = "https://files.pythonhosted.org/packages/80/50/e85bdaba0be59ca4838005ebfef4048fcdd5f35a02b07057a9a123394440/aiohttp-3.14.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3c7139100fbaae76515b73051d8f0aa3a3ff02e415eec8a8eee8e2223d9ba955", size = 1902125, upload-time = "2026-06-01T19:38:00.225Z" }, - { url = "https://files.pythonhosted.org/packages/19/d8/51de5c6b971c27bb1ef620293b8d1ca611ec78736b34b3f6ccf68e4c8785/aiohttp-3.14.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78d6f9286a629ce52728430afe18f8ed2b6c39a1fddb3802d7244b9983910ad2", size = 1783112, upload-time = "2026-06-01T19:38:02.641Z" }, - { url = "https://files.pythonhosted.org/packages/73/ae/b4402bfde77e43dfb1b6ccff83c7b7ab63ed06b50c4754f0c5423fb374fe/aiohttp-3.14.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc3c3e12cdaeb92d7dcf13db00e9f6b1956b910e47256e696df1cfa946d02159", size = 1586356, upload-time = "2026-06-01T19:38:04.637Z" }, - { url = "https://files.pythonhosted.org/packages/bc/05/750a3265ca4dc54a460bd0cb1121a8f2ce9171fce4a135fb47ea7fd594d2/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4d6a998191f5ebe3b8c28463ff72bc030250008b3193c402464efadd08b5ca02", size = 1723119, upload-time = "2026-06-01T19:38:06.713Z" }, - { url = "https://files.pythonhosted.org/packages/37/01/8c0812c50b3b1b1c37b323bf170d6be8847a8f234060485b7d1e71953f60/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0fc2b75ae8d169d853be2862d960be8550da6c5c65711d5476407eb3fdb006bd", size = 1757216, upload-time = "2026-06-01T19:38:08.736Z" }, - { url = "https://files.pythonhosted.org/packages/47/2a/50fb98028a26887cbe48dcc1df92a90825615bc73b5584301304090cded8/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:16eee56bcc72d04600bc56c1759982c2385ec0b41d3fd3521f836bf64a0957ef", size = 1770500, upload-time = "2026-06-01T19:38:11.111Z" }, - { url = "https://files.pythonhosted.org/packages/bd/32/0ffd598a2fa2b9a423daf242e700cfdabda35d6e602394ad9ae58972c1c7/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5a2e7ca615c3ddc15b82687e05a624e5f5cba3f1d6c20cb81172d70ea498451e", size = 1576224, upload-time = "2026-06-01T19:38:13.391Z" }, - { url = "https://files.pythonhosted.org/packages/0b/f9/b9fc381dd9b66afb33f2634c40e229d106467be0afcabe79648631ab6712/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:f0b7b8bbbec3ce9467ee0ebe334622fd90624f593edd3136c567811453fc4fae", size = 1794252, upload-time = "2026-06-01T19:38:15.498Z" }, - { url = "https://files.pythonhosted.org/packages/a8/fb/05d9214c975f23225a8cd5c439325e338c7c377b315480ef3871db51f54e/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ba10966d4f03dd96a14365be4b8e37c327c76f11c3ca867116966cdd9f98066", size = 1760193, upload-time = "2026-06-01T19:38:17.624Z" }, - { url = "https://files.pythonhosted.org/packages/d9/4b/02992fc4fb9e1b6673ee3f888a8e587a6447afda1f6f4aca776c148c2876/aiohttp-3.14.0-cp312-cp312-win32.whl", hash = "sha256:101df7779c80c0636014a6b2c6642acd3efb5b355d48347c9d7dfb720aee9430", size = 448650, upload-time = "2026-06-01T19:38:19.545Z" }, - { url = "https://files.pythonhosted.org/packages/39/e9/246532214c3abda518477cbaaf16d420295ad8effa5233844cbb38f299ab/aiohttp-3.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:b0a5747586d4467efd1f932710b269131c9717a872dce082cd92a00c1c13123a", size = 476145, upload-time = "2026-06-01T19:38:21.505Z" }, - { url = "https://files.pythonhosted.org/packages/2b/c3/63f8c20090048915711598b0adf475b149216d736157961de06480a45b15/aiohttp-3.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:5f1c5be60add78fabb4aacd13c5a348ae79d2fcbfc7fa78da8f1eb192273b370", size = 444250, upload-time = "2026-06-01T19:38:24.027Z" }, - { url = "https://files.pythonhosted.org/packages/21/61/d11f7d9a3144bffe825247d6367cd93053666da50b94707c9129c78868d5/aiohttp-3.14.0-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:25400d710641a8040bf022a8a99f579e581ffa1c5bd42c33255d7d6f3957c127", size = 502399, upload-time = "2026-06-01T19:38:25.955Z" }, - { url = "https://files.pythonhosted.org/packages/4f/9b/a7e317625d36356844f8bb022cabd305b541f968856cc3c2e0b58e53ee6e/aiohttp-3.14.0-cp313-cp313-android_21_x86_64.whl", hash = "sha256:c5492b9929826e07cc3fcb9739ae87aab05dff6b5e67a9b73fd1700c6d008981", size = 510068, upload-time = "2026-06-01T19:38:27.828Z" }, - { url = "https://files.pythonhosted.org/packages/11/41/cc2d2cfbfbdc3126ba258f3cd27d1ac8a33492ae3c35a4583ee21f0ba7f1/aiohttp-3.14.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3366751d68d237c621264233a32f3078bbc21b7904ab90a77e03d21390c742c6", size = 481670, upload-time = "2026-06-01T19:38:29.836Z" }, - { url = "https://files.pythonhosted.org/packages/3c/07/381f4023c3b08cb616e520f566d8c58957abad54e56441d41fe67cfb0195/aiohttp-3.14.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:57ea07d28695a7a40304d42251892a8df765e5588c10ee32afeddcd5df33c0a2", size = 487591, upload-time = "2026-06-01T19:38:31.704Z" }, - { url = "https://files.pythonhosted.org/packages/fb/4d/4506fdb7a022bdf70011a3bbb4ca00c5c570026ef6a3c5bd7bc70c39089c/aiohttp-3.14.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:076cb014191ae2e65d949e1ad01f1dcfe33e32789b5172510f3e79c79fc04d50", size = 496503, upload-time = "2026-06-01T19:38:33.6Z" }, - { url = "https://files.pythonhosted.org/packages/ef/7d/c814111e04894a45d9e2defc94443879a6f118d9633d5fedfe6e2e8af5f0/aiohttp-3.14.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2f3fc37054564dee64a855b5b092d87ec35dcddfaabf7dacb1c8a2b1f83dc0a9", size = 745870, upload-time = "2026-06-01T19:38:36.013Z" }, - { url = "https://files.pythonhosted.org/packages/c6/ee/80eee0efddfe187e7cd05027086b7ce1c0e492e82a4eda58f5c5543a44a0/aiohttp-3.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8fcaef74d2ab0f607d7ff85a0d15e21bb5a258c4a58df1908396eb50d7f4ed3c", size = 505588, upload-time = "2026-06-01T19:38:38.282Z" }, - { url = "https://files.pythonhosted.org/packages/d6/f8/0f28f04eef75d52fc9c715dde7ce9c0abb810fd20cfeb0fea7afd2ab1e98/aiohttp-3.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e4c01b0bfc6209590960e68eac083cd22d5d87c21f974dd6208cafa5d3542bc8", size = 504492, upload-time = "2026-06-01T19:38:40.611Z" }, - { url = "https://files.pythonhosted.org/packages/ff/db/44c755232085545065c94378dfce38641b1aee647f4939fcd32f5b32e719/aiohttp-3.14.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f12eb7896e81caf403a2b18c9406426f1207361e7239c057ab29c076d4257e83", size = 1752111, upload-time = "2026-06-01T19:38:42.682Z" }, - { url = "https://files.pythonhosted.org/packages/5e/6a/42e030a46743841414402a3b00cd3d78419055e86c66fb5822c14b5abfc6/aiohttp-3.14.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6c79a044cacf360ec46738d863d2f41c9300d2a06ef4a7402ea0df306a350e61", size = 1729674, upload-time = "2026-06-01T19:38:44.79Z" }, - { url = "https://files.pythonhosted.org/packages/34/26/3199beb415202e3108e7b83ecebe10914d806d33fb9860c3e4aa60a19be3/aiohttp-3.14.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:85e0675f47be4eff0636bf88c02140ea89168ae0df3ff1f3f464e9de9610d277", size = 1798808, upload-time = "2026-06-01T19:38:47.01Z" }, - { url = "https://files.pythonhosted.org/packages/bd/94/b9b6fcf0ee17c21d0d19fb8c22bf83ad18f82e702a9c3bd901a868f5e446/aiohttp-3.14.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b33e751cab03fdc960095b1e326cb5a03f5ee577d6ded59f3d1c100f8668882", size = 1891921, upload-time = "2026-06-01T19:38:49.233Z" }, - { url = "https://files.pythonhosted.org/packages/c5/a3/3800dbd095cb2bb165a7ea5d94d790914677e27f45638c7d80e3f34c8945/aiohttp-3.14.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:26d9224c6dd7f5c749aba4f61315a894601448b28d94d12f4dea0903e26d2096", size = 1777241, upload-time = "2026-06-01T19:38:52.04Z" }, - { url = "https://files.pythonhosted.org/packages/21/2a/45be91ad1b860508557448d4cc2e165a2ee68dd865657b73bf66cc5a00fb/aiohttp-3.14.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6281aecdf2732940f4fe06bd6adec5ae4d59b78b080b8e3a6b81467301010988", size = 1579554, upload-time = "2026-06-01T19:38:54.508Z" }, - { url = "https://files.pythonhosted.org/packages/b4/3d/dc94df99ed1511fdf28314f722643ed334112643cab00223577085e788c4/aiohttp-3.14.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:23e8314e7aed8576fbe33314d218bd81447a3adbc91dc36f1163bf583cd3084c", size = 1714864, upload-time = "2026-06-01T19:38:56.788Z" }, - { url = "https://files.pythonhosted.org/packages/ae/e4/1f1c8acbb3acd5c8f795473b92c9c3d44eb60a5692c6104256c8a1c83a0c/aiohttp-3.14.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3b54fbff46127aeafdd764cecd0d99fa2f24a0e37ea5c18a7c3a4ac450df1db3", size = 1749803, upload-time = "2026-06-01T19:38:59.367Z" }, - { url = "https://files.pythonhosted.org/packages/0b/c8/c45ea6e7ed84cebba939b9c334498a045ba19d79c61b0110df5f21580de3/aiohttp-3.14.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b27d89af91a555f58e08e4902dbcbc48862fd40095720ca705990476bd93b7ac", size = 1765023, upload-time = "2026-06-01T19:39:01.651Z" }, - { url = "https://files.pythonhosted.org/packages/a8/a1/a932941784432962fe390e1066823aaef64b4e5ac9fa595df57b5fe472a9/aiohttp-3.14.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:25d2326a4967bf705a9f9913a13005e93b6020ad8a9f6bd6bd78850d5171332e", size = 1571671, upload-time = "2026-06-01T19:39:04.044Z" }, - { url = "https://files.pythonhosted.org/packages/b0/01/e1280feac522597a4d46eb67a0cdfa053cfae263033030b761ab146f29fb/aiohttp-3.14.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a1d209375c503472b3c0a340cdf3c55fcd82e84b46dda7caeaced59faba373ec", size = 1789904, upload-time = "2026-06-01T19:39:06.294Z" }, - { url = "https://files.pythonhosted.org/packages/fa/10/ab28818262f4d26bdb47ed5f1fc7999b69e2fc6e0370b02d0f49011f45ea/aiohttp-3.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:666c7c5036df57b693026398b69b41874a1931ac5b3485fd910e57bfac253869", size = 1754516, upload-time = "2026-06-01T19:39:08.788Z" }, - { url = "https://files.pythonhosted.org/packages/af/cc/c122eabd7a1b7e0c9bbdd6be60e4715905b858399145d9df872bb94f1427/aiohttp-3.14.0-cp313-cp313-win32.whl", hash = "sha256:23f094a1ef64823fd35854ddf5c7a80a078162f37f9d2f7c6142b51a6affa456", size = 448656, upload-time = "2026-06-01T19:39:11.171Z" }, - { url = "https://files.pythonhosted.org/packages/41/a5/bab07d79848a00eedd8ed979ccb302aaea3ac6eb9fa16bd0ed87135869b4/aiohttp-3.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:e03abdaa17d553f17e1d1d06bb266b3970106c78051d06795723e748d8e49d11", size = 475803, upload-time = "2026-06-01T19:39:13.439Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a0/f03ade8566c153666a3871afccbedf6d99911da006325e1fc6cf72a2de99/aiohttp-3.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:acdb400538cf4769543548bb5d1eb23d39bed4f96554a6078cb728c7cb2c268b", size = 443889, upload-time = "2026-06-01T19:39:15.945Z" }, - { url = "https://files.pythonhosted.org/packages/28/03/5f36ab196a88ba5e9648ae5643e6531e67a3a8c0e96f9c6510ff41540fec/aiohttp-3.14.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:363ef9e91014e7891679bfb2ac0a7c6ea93435dbbfd10ecf41b9f06fcf506c5f", size = 503330, upload-time = "2026-06-01T19:39:18.195Z" }, - { url = "https://files.pythonhosted.org/packages/2c/ce/8b49ec2f30f68e02f314f4832186cd45e583360a5a386058be36855d23b6/aiohttp-3.14.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:884a4edbdad77be9d0ef36142c8b504351b170df0bf62b51e784fadabf311c42", size = 509822, upload-time = "2026-06-01T19:39:20.396Z" }, - { url = "https://files.pythonhosted.org/packages/1a/fe/6edbf5d39bf29322b6816365b17ed8ede4dace164a3aea1abcd30110eb78/aiohttp-3.14.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:70ea956f6cc4a37620966b56c2e205d88ca3e6d85ec063277e414b1035cddad3", size = 483329, upload-time = "2026-06-01T19:39:22.607Z" }, - { url = "https://files.pythonhosted.org/packages/1b/5a/fae531bdbc6456fb6241f46b7b81e4d8a0dd3fc09118a0055dc7141ac1ec/aiohttp-3.14.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:ea3b9806c89f61da22fddf1f12dd524fb368e5e28f1261fbdafe5c3cd8ce893b", size = 489502, upload-time = "2026-06-01T19:39:24.881Z" }, - { url = "https://files.pythonhosted.org/packages/36/f4/48a7b0414db7fed77a03d5dde34508c026afd83510ab6bca08c313855776/aiohttp-3.14.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a071be341c2bd9b0188e62d173509f024e0a35b1c342c53c50f8daaeda8c3bd8", size = 497357, upload-time = "2026-06-01T19:39:27.197Z" }, - { url = "https://files.pythonhosted.org/packages/75/75/e85a13a370acc007fca5feb1fd1b88ac2d8426e6dadd625479b7cadd55a3/aiohttp-3.14.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:198cfe61bf253b19da1fb3e0fa122249dc4f14c12709493fed8054aa0411cc76", size = 750898, upload-time = "2026-06-01T19:39:29.563Z" }, - { url = "https://files.pythonhosted.org/packages/9e/e4/3d637f800c724eff0e2bed64df72557444482366fd0a35b0cec0e6968f6c/aiohttp-3.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9dc203d6ce6b9106d54e2a93f41dfdfebfbca2d99962ba503bfd3e5921a6549e", size = 506986, upload-time = "2026-06-01T19:39:31.872Z" }, - { url = "https://files.pythonhosted.org/packages/1d/df/35161f3598bf7501d2b2a805b41ab4f45a2e34150c421bcb4ef8c0d281a7/aiohttp-3.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9e19d17ab02bf16832a2c8c0d55a486792c5b1645665652ee9531aebcc30cb72", size = 508033, upload-time = "2026-06-01T19:39:34.137Z" }, - { url = "https://files.pythonhosted.org/packages/e5/39/b36e5d3d31e850fb4691dd3e941684ac490a2559249f6fa634b6b0fdf020/aiohttp-3.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d925fba0c14d5b498a8028b0107beebdfd16c5d48d702ff54f879cb017aaaca3", size = 1746213, upload-time = "2026-06-01T19:39:36.654Z" }, - { url = "https://files.pythonhosted.org/packages/b1/28/24e1409e605a9aa5d84abe0e2acb365354b70ae56d40948101cabe3341ab/aiohttp-3.14.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d33e61021222ce7f9792bcac870d6f58d8adfceda33ab857b01264f4560f2c5f", size = 1705862, upload-time = "2026-06-01T19:39:38.968Z" }, - { url = "https://files.pythonhosted.org/packages/8c/d0/e5eb3ff1daeaf644c7e36a957517672494122628e067c38b263fa04eda77/aiohttp-3.14.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:44eca38755d0105bb32f47d085f5dd449846a449e1245fc105889e3279dcf8e3", size = 1798909, upload-time = "2026-06-01T19:39:41.334Z" }, - { url = "https://files.pythonhosted.org/packages/d3/ba/8943f906f0570342886ababb9a722a44e360f786a028c5e0b0e29e3f735b/aiohttp-3.14.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f13087e06f68fea4941c21a0c541c00553aa16e4f8fd7bbe2b198df761e964d6", size = 1868892, upload-time = "2026-06-01T19:39:43.807Z" }, - { url = "https://files.pythonhosted.org/packages/3a/05/27df32c844b2156e1675a8d8ec22d963e3c8ba469ed7ceb1863320c7b521/aiohttp-3.14.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ff82be7f1ef73634cb77890a770743239bc3d487b848669be1c599889336dc0a", size = 1751659, upload-time = "2026-06-01T19:39:46.398Z" }, - { url = "https://files.pythonhosted.org/packages/7f/62/da182e5910ab912b2e88aa919b61a16046a37a95714a5795b02eb57b2d18/aiohttp-3.14.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a150c0875ac8fd87f1c398650841308a30d65facf7416b12dbdb9cfdcbe5a48c", size = 1578775, upload-time = "2026-06-01T19:39:48.902Z" }, - { url = "https://files.pythonhosted.org/packages/66/e3/53c67097e8a5ce98625e91e3fa7f43c9c6940de680345d03b3509a72a078/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:edc01ea4e1ec5a1649a28866262bf24195889ff7b27bdd947029a6086741de9b", size = 1710090, upload-time = "2026-06-01T19:39:51.392Z" }, - { url = "https://files.pythonhosted.org/packages/dd/55/0e2732ca598c7a4dfe8a775662376d0ca2977cb1030e48386d4da5d9a456/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:540632bf882ff8fc88f2e1697be0761578e89e0d79fb4a8a6d65dc5da7e729d4", size = 1715016, upload-time = "2026-06-01T19:39:53.807Z" }, - { url = "https://files.pythonhosted.org/packages/5a/96/f0b73730798c9ca525afc30b39f1f81bbe24e245d9654c54d3b39d63212d/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:860a86bc2c80237f5dff52edcf427e10a8d8352271fd84845429a3e60199e02c", size = 1763810, upload-time = "2026-06-01T19:39:56.31Z" }, - { url = "https://files.pythonhosted.org/packages/71/cc/11acb6c4518f448323405a7312b6f255d0f974a34373ad1db7633c4aadc8/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5cbd50e6a50d6b99283a826b18cbdebf65b0797689a7535cb0e9dd37be0f63c3", size = 1573064, upload-time = "2026-06-01T19:39:58.718Z" }, - { url = "https://files.pythonhosted.org/packages/de/2d/28c31dde0a7dc98c0ee7d0da2ddcec3f7688c4fc131e5989e278d0c03c0a/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:20144819e99db593e22bbd2f3f2691a5e149f879142d6b8670254708853ff4fb", size = 1775765, upload-time = "2026-06-01T19:40:01.195Z" }, - { url = "https://files.pythonhosted.org/packages/b8/69/155c4ef3aec96417d47024800472b33b16c5d8a665371dcd044c2afdf25d/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:26b6d79aa54cb4ed50cc7d41ed14e99e0f1fc8e7c2d42f2e05b37aea897b2b52", size = 1733716, upload-time = "2026-06-01T19:40:03.631Z" }, - { url = "https://files.pythonhosted.org/packages/5f/44/6126116fd8a316b712bb615660b855c78466bb67ba1bb1742427eafcf7ac/aiohttp-3.14.0-cp314-cp314-win32.whl", hash = "sha256:106ed074a856f3e21d186b8579e2c8afb6da598e267cdaab01059e13db2fc44d", size = 453684, upload-time = "2026-06-01T19:40:06.277Z" }, - { url = "https://files.pythonhosted.org/packages/a2/d7/eff4c58a88c5cac5e38b55f44fb8a6d3929c3cbd77356e383e094d3220bd/aiohttp-3.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:4f770846edae8f00ecc57af825bce811f787f87a7dcf0e90d191790efe5b31f7", size = 481758, upload-time = "2026-06-01T19:40:08.653Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ed/17b5bd9fbcb46e688f02e572f517754a9a75831e7b54702f027761dc4fa5/aiohttp-3.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:acf1581c4f21ed4b80a2dded504d87b055a071a84d5737ea966435f768275ac6", size = 450557, upload-time = "2026-06-01T19:40:11.03Z" }, - { url = "https://files.pythonhosted.org/packages/12/34/6180103ce9aabc8ebff3f7bb55a1228ffe60f61042823031d9692cb7b101/aiohttp-3.14.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6aa1a40f9cbb3da9f80714c5966b8946c21e6a2530d809b9498b33161e3c8733", size = 787878, upload-time = "2026-06-01T19:40:13.401Z" }, - { url = "https://files.pythonhosted.org/packages/92/e9/08954a40e8b7baa3d8beadd2b074b186e9b1e9c8ddabc288678a6265de50/aiohttp-3.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b62af5a8cc96a194eaa01a9ed7b34a3ffa58d3d8daaa1a0d7a749353ad12d228", size = 524400, upload-time = "2026-06-01T19:40:15.972Z" }, - { url = "https://files.pythonhosted.org/packages/08/6a/b5965a634ac4d5ba99a463314cf4ab214ca073fcdc38a15e0294273701fc/aiohttp-3.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6eb63b1417efaf7d1002a6ad034a40d44376afcc16508a57f8e74b49ad26a095", size = 527904, upload-time = "2026-06-01T19:40:18.28Z" }, - { url = "https://files.pythonhosted.org/packages/06/b4/932bcdd850c354d9bcca30f360e475d7852e30413fbbd44b182782ed5432/aiohttp-3.14.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c20b9ad156a79eb97be5cf9e069eec01d2f0dc8472ffbd75299a8b2d4c2cbbde", size = 1912162, upload-time = "2026-06-01T19:40:20.825Z" }, - { url = "https://files.pythonhosted.org/packages/c6/85/ce79bab0310d2e3fd2d7bc7e44412abeff7c8338f8a21dd0f2f1714989e5/aiohttp-3.14.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:40ae7b0642c25632c7eabc4a04754012691864d2a1b93becf7cddb76027b838a", size = 1778813, upload-time = "2026-06-01T19:40:23.726Z" }, - { url = "https://files.pythonhosted.org/packages/05/54/ba62ac2d1bc87e010aad23751e383b8794e45d931df67677313a2da78823/aiohttp-3.14.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:95f5217e76a046b9f228a101717ef8d42b1eb3d9d196d15202db5bf41df88936", size = 1899969, upload-time = "2026-06-01T19:40:26.406Z" }, - { url = "https://files.pythonhosted.org/packages/dc/82/7cc7907725d83a19f31551334061e1ab8e108b1d7ac52632a2a844a4acb5/aiohttp-3.14.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1a4a9f17e85b80878c176695c1998c790e83731d8271881e5d356488652a1f9e", size = 1991771, upload-time = "2026-06-01T19:40:29.061Z" }, - { url = "https://files.pythonhosted.org/packages/d0/1c/a57de71a4508c93a830b77c28af3d08cd97f606dedfc6b94275347744508/aiohttp-3.14.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:145262119b07d7f95abc1839add35ba2bfc84551d4b4660ca11542c0b215455b", size = 1868606, upload-time = "2026-06-01T19:40:31.843Z" }, - { url = "https://files.pythonhosted.org/packages/9c/ae/3839726cd49150a53ed340cc24ce5ba09d4c2117020ef9d45542bec5eb2f/aiohttp-3.14.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:49a33ded29b0b2fa7a367a02cf0fb89af602bb87542a16177ec8ce1c9c51d12a", size = 1665437, upload-time = "2026-06-01T19:40:35.01Z" }, - { url = "https://files.pythonhosted.org/packages/35/1e/c237923232c7da7f0392ea25d89fc5e60c0e93f685f4ebca8e7bcdd5271c/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2cc736a9c9fc2bc4dd71fd404815741b6573df27c3f985948ec4076989ac57de", size = 1834090, upload-time = "2026-06-01T19:40:37.733Z" }, - { url = "https://files.pythonhosted.org/packages/98/02/a5a7a2524f92d3911761b405a7c067c751891942144adc13e2ad79611e39/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b4141a3e5342ee3053a9cab54d25b64ed28289c1041e4c54b3d99839314d90ce", size = 1816907, upload-time = "2026-06-01T19:40:40.46Z" }, - { url = "https://files.pythonhosted.org/packages/fa/76/a8b9f0d09234d516af9f2d7dd715557f33b5da3b0b56ead41d1170e86e3c/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e30871b2d58996cb81aac52d2b1d15ac05257131ef0f90f18c2115a380fbfe7c", size = 1840382, upload-time = "2026-06-01T19:40:43.48Z" }, - { url = "https://files.pythonhosted.org/packages/c9/8e/140e715a0a4bbc211979ea30ec8396ad2ed5bf90ab87d8058fc4668b1923/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:667b881d083ccae3900ea5a241e17e5007ca78844c53ed389bb63d48f729d9c7", size = 1659497, upload-time = "2026-06-01T19:40:46.265Z" }, - { url = "https://files.pythonhosted.org/packages/10/c7/7ba5de8af9650b9767b063c675427b8685f43fa7ce563673a7bc3af60f08/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:b584dfe615d151e9b8f0a8ecb3aee6147f2927ec5b95ba25fe621f5377510928", size = 1870829, upload-time = "2026-06-01T19:40:49.583Z" }, - { url = "https://files.pythonhosted.org/packages/cc/bc/2aaab2f85cadb26ea59c091fa2b8e370d625154b5c14b478f1b489d07551/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6199707cc40e0e9cd39c36fbc97bec416c704e1d0ddce03412bb3b3e6a90ccd0", size = 1832281, upload-time = "2026-06-01T19:40:52.303Z" }, - { url = "https://files.pythonhosted.org/packages/39/98/31b9ad9fbc01f0075ee7221002df5fd2d10b647f451ca5f30edc802d9dd6/aiohttp-3.14.0-cp314-cp314t-win32.whl", hash = "sha256:a8d93334d4961c9d566b1f046c81dee475b7c21eb730728d38237bfa70d1c8e6", size = 490597, upload-time = "2026-06-01T19:40:54.937Z" }, - { url = "https://files.pythonhosted.org/packages/59/1f/299b21441c8de42ff70fddc7cfe65e92f810abcf740739a09b56f7835364/aiohttp-3.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2d2ffe9b614f50f069068b3b52e73414e4107fc10b7efc939a76acff9251fdd2", size = 525789, upload-time = "2026-06-01T19:40:57.306Z" }, - { url = "https://files.pythonhosted.org/packages/70/11/7f83fcba9ee05d4c54d61b3f8104da0d43a59adac44dd28effc0c9a10422/aiohttp-3.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:7a3fc4358e65826c515350f199c210de747cf669998211b1ee6c2e46de364b24", size = 467399, upload-time = "2026-06-01T19:40:59.993Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/21/151624b51cd92553d95424daf4bf19f19ce9be9002d19253e7e7ce67197b/aiohttp-3.14.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d35143e27778b4bb0fb189562d7f275bff79c62ab8e98459717c0ea617ff2480", size = 757402, upload-time = "2026-06-07T21:06:40.311Z" }, + { url = "https://files.pythonhosted.org/packages/c2/82/280619e0bd7bf2454987e19282616e84762255dd9c8468f62382e8c191f1/aiohttp-3.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bcfb80a2cc36fba2534e5e5b5264dc7ae6fcd9bf15256da3e53d2f499e6fa29d", size = 512310, upload-time = "2026-06-07T21:06:42.207Z" }, + { url = "https://files.pythonhosted.org/packages/55/b2/2aac325583aaa1353045f96dffa586d8a34e8322e14a7ba49cffeb103ab4/aiohttp-3.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27fd7c91e51729b4f7e1577865fa6d34c9adccbc39aabe9000285b48af9f0ec2", size = 512448, upload-time = "2026-06-07T21:06:43.813Z" }, + { url = "https://files.pythonhosted.org/packages/8a/72/a60607cb849faa8af8a356c9329ea2eb6f395d49e82cc82ccba1fd8deb8f/aiohttp-3.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c567bf9eaf664280116a8688f63016e6b32db2505908e2bdaca1b6438142f2", size = 1766854, upload-time = "2026-06-07T21:06:45.391Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d3/d9fe1c9ec7557ab4d0d82bebaa728c6418f0b93295ec2f4ab015f7710cc7/aiohttp-3.14.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f5e6ff2bdbb8f4cd3fbe41f99e25bbcd58e3bf9f13d3dd31a11e7917251cc77a", size = 1740884, upload-time = "2026-06-07T21:06:47.413Z" }, + { url = "https://files.pythonhosted.org/packages/c1/dc/f2cecfaf9337ba3e63f181500814ff502aa3d00d9c7ec93a9d23d10a27b2/aiohttp-3.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f73e01dc37122325caf079982621262f96d74823c179038a82fddfc50359264", size = 1810034, upload-time = "2026-06-07T21:06:50.165Z" }, + { url = "https://files.pythonhosted.org/packages/66/d7/2ff65c5e65c0d7476daf7e15c032e0805e36811185b9623e3238ad6c763e/aiohttp-3.14.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb2c0c80d431c0d03f2c7dbf125150fedd4f0de17366a7ca33f7ccb822391842", size = 1904054, upload-time = "2026-06-07T21:06:52.035Z" }, + { url = "https://files.pythonhosted.org/packages/20/9c/d445818389df371f56d141d881153ba23183c4735a03f7356ffb43f7757d/aiohttp-3.14.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e6fc1a85fa7194a1a7d19f44e8609180f4a8eb5fa4c7ed8b4355f080fad235c", size = 1790278, upload-time = "2026-06-07T21:06:54.049Z" }, + { url = "https://files.pythonhosted.org/packages/4d/aa/bf04cb4d865fc6101c2229a294ad744973b72e513fdc5a6b791e6983d72a/aiohttp-3.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:686b6c0d3911ec387b444ddf5dc62fb7f7c0a7d5186a7861626496a5ab4aff95", size = 1591795, upload-time = "2026-06-07T21:06:55.911Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b4/4dac0038960427ba832f6609dfb4ea5437d7fd80c72001b9e48f834f428b/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c6fa4dc7ad6f8109c70bb1499e589f76b0b792baf39f9b017eb92c8a81d0a199", size = 1728397, upload-time = "2026-06-07T21:06:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/7cd4e8ad7aa3b75f17d56bb5498dd604a93d4e6eece822ba0568c413fff0/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:87a5eea1b2a5e21e1ebdbb33ad4165359189327e63fc4e4894693e7f821ac817", size = 1766504, upload-time = "2026-06-07T21:07:00.009Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/fc01d9fcad0f73fed3f3d361f1f94f975947b50dff82919f6dc2bf4316cc/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c1421eb01d4fd608d88cc8290211d177a58532b55ad94076fb349c5bf467f0a", size = 1777806, upload-time = "2026-06-07T21:07:02.064Z" }, + { url = "https://files.pythonhosted.org/packages/41/09/47e2d090bddcc8fb4ccb4c314aadc32d7c5d9bb55f50f6ad1c92fc15d501/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:34b257ec41345c1e8f2df68fa908a7952f5de932723871eb633ecbbff396c9a4", size = 1580707, upload-time = "2026-06-07T21:07:03.942Z" }, + { url = "https://files.pythonhosted.org/packages/3d/36/f1a4ce904ae0b6930cfe9afc96d0896f7ec1a620c400405d63783bb95a9c/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:de538791a80e5d862addbc183f70f0158ac9b9bb872bb147f1fd2a683691e087", size = 1798121, upload-time = "2026-06-07T21:07:05.987Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/e0075ce9ca0279ee1d4f0c0b85f54fea02ebc83c3007651a72bece658fec/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f71173be42d3241d428f760122febb748de0623f44308a6f120d0dd9ec572e3", size = 1767580, upload-time = "2026-06-07T21:07:07.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/a0c0a8f327a9c52095cdd8e312391b00d3ed64ab6c72bb5c33d8ec251cf7/aiohttp-3.14.1-cp312-cp312-win32.whl", hash = "sha256:ec8dc383ee57ea3e883477dcca3f11b65d58199f1080acaf4cd6ad9a99698be4", size = 452771, upload-time = "2026-06-07T21:07:09.669Z" }, + { url = "https://files.pythonhosted.org/packages/df/d9/ea367c75f16ac9c6cdc8febb25e8318fa21a2b1bc8d6514d4b2d890bface/aiohttp-3.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271", size = 479873, upload-time = "2026-06-07T21:07:11.538Z" }, + { url = "https://files.pythonhosted.org/packages/03/64/8d96784a7851156db8a4c6c3f6f91042fdf39fb15a4cc38c8b3c14833c45/aiohttp-3.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:2c840c90759922cb5e6dda94596e079a30fb5a5ba548e7e0dc00574703940847", size = 448073, upload-time = "2026-06-07T21:07:13.637Z" }, + { url = "https://files.pythonhosted.org/packages/bc/97/bd137012dd97e1649162b099135a80e1fd59aaa807b2430fc448d1029aff/aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178", size = 506882, upload-time = "2026-06-07T21:07:15.501Z" }, + { url = "https://files.pythonhosted.org/packages/ef/79/e5cc690e9d922a66887ceeaca53a8ffd5a7b0be3816142b7abc433742d89/aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf", size = 515270, upload-time = "2026-06-07T21:07:17.53Z" }, + { url = "https://files.pythonhosted.org/packages/fe/22/a73ccbf9dbd6e26dda0b24d5fd5db7da92ee3383a79f47677ffb834c5c5b/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd", size = 485841, upload-time = "2026-06-07T21:07:19.555Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b9/57ed8eaf596321c2ad747bd480fb1700dbd7177c60dfc9e4c187f629662e/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe", size = 492088, upload-time = "2026-06-07T21:07:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/78/c0/5ebe5270a7c140d7c6f79dcb018640225f14d406c149e4eec04a7d82fe71/aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4", size = 501564, upload-time = "2026-06-07T21:07:23.388Z" }, + { url = "https://files.pythonhosted.org/packages/75/7f/8cdaa24fc7983865e0915153b96a9ac5bcdd3548d64c5a27d17cecccad2d/aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876", size = 751998, upload-time = "2026-06-07T21:07:25.046Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f4/c4227aacfacc5cb0cc2d119b65301d177912a6842cd64e120c47af76064f/aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da", size = 510918, upload-time = "2026-06-07T21:07:27.28Z" }, + { url = "https://files.pythonhosted.org/packages/ab/01/a2d5f96cd4e74424864d30bc0a7e44d0a12dacdcfa91b5b2d1bd3dca6bf3/aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6", size = 508657, upload-time = "2026-06-07T21:07:29.252Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ed/3c0fb5c500fdd8e7ebc10d1889c04384fffa1a9163eac1356088ca9da1b1/aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96", size = 1757907, upload-time = "2026-06-07T21:07:31.03Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ab/d4c924d9bd5be3050c226612413ce68cb54c70d2c31b661bfc8d9a5b6a70/aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f", size = 1737565, upload-time = "2026-06-07T21:07:33.031Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/37326821ff779084020cdc33224d20b19f42f4183a500ff92022a739eda7/aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296", size = 1799018, upload-time = "2026-06-07T21:07:35.003Z" }, + { url = "https://files.pythonhosted.org/packages/b3/4f/6e947ba73e4ce09070761c05ed3a8ceb7c21f5e46798671d8b2aac0e4626/aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa", size = 1894416, upload-time = "2026-06-07T21:07:36.956Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6e/dbf1d0625dc711fb2851f4f3c3055c39ed58bae92082d8c627dbe6013736/aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451", size = 1783881, upload-time = "2026-06-07T21:07:39.063Z" }, + { url = "https://files.pythonhosted.org/packages/44/c2/5e25098a67268ed369483ae7d1a58bd0a13d03aab860d2a0e4a6eb25b046/aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c", size = 1587572, upload-time = "2026-06-07T21:07:41.058Z" }, + { url = "https://files.pythonhosted.org/packages/2a/bd/cf9cee17e140f942a3de73e658a543aa8fbf35a5fc67a9d2538d52d77f0b/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca", size = 1722137, upload-time = "2026-06-07T21:07:43.014Z" }, + { url = "https://files.pythonhosted.org/packages/89/6d/5684f8c59045c96f81a18cefbc1fbbd79d25b88f1c622f2a5c5c08fcb632/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09", size = 1755953, upload-time = "2026-06-07T21:07:45.933Z" }, + { url = "https://files.pythonhosted.org/packages/a8/40/35caf3170f8359760740a7d9aa0fff2e344bef98e1d1186f5a0f6dec17e6/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397", size = 1766479, upload-time = "2026-06-07T21:07:48.047Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a1/b0c61e7a137f0d81de49a82023a6df73c3c16d6fefb0f8e4a93d21639002/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080", size = 1580077, upload-time = "2026-06-07T21:07:50.069Z" }, + { url = "https://files.pythonhosted.org/packages/0b/41/194ea4623693009fcefebef7aef63c141754f153e9cd0d39d3b9e36c175c/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345", size = 1791688, upload-time = "2026-06-07T21:07:52.106Z" }, + { url = "https://files.pythonhosted.org/packages/ba/45/4de841f005cfe1fd63e2a2fe011262c515e2a62aa6994b15947e7d717ac9/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588", size = 1761094, upload-time = "2026-06-07T21:07:54.113Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ae/dbce10533d3896d544d5053939ed75b7dc31a1b0973d959b1b5ae21028d6/aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780", size = 452662, upload-time = "2026-06-07T21:07:56.06Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d9/0bf1a19362c32f06229da5e7ddfcec91f93474d6307f7a2d3135e9c674dc/aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a", size = 479748, upload-time = "2026-06-07T21:07:58.319Z" }, + { url = "https://files.pythonhosted.org/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8", size = 447723, upload-time = "2026-06-07T21:08:00.154Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15", size = 508531, upload-time = "2026-06-07T21:08:02.093Z" }, + { url = "https://files.pythonhosted.org/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8", size = 514718, upload-time = "2026-06-07T21:08:04.476Z" }, + { url = "https://files.pythonhosted.org/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3", size = 487918, upload-time = "2026-06-07T21:08:06.377Z" }, + { url = "https://files.pythonhosted.org/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba", size = 494014, upload-time = "2026-06-07T21:08:08.263Z" }, + { url = "https://files.pythonhosted.org/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397", size = 502398, upload-time = "2026-06-07T21:08:10.244Z" }, + { url = "https://files.pythonhosted.org/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448", size = 758018, upload-time = "2026-06-07T21:08:12.447Z" }, + { url = "https://files.pythonhosted.org/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004", size = 512462, upload-time = "2026-06-07T21:08:14.624Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983", size = 512824, upload-time = "2026-06-07T21:08:16.572Z" }, + { url = "https://files.pythonhosted.org/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe", size = 1749898, upload-time = "2026-06-07T21:08:18.635Z" }, + { url = "https://files.pythonhosted.org/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333", size = 1710114, upload-time = "2026-06-07T21:08:20.892Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0", size = 1802541, upload-time = "2026-06-07T21:08:23.044Z" }, + { url = "https://files.pythonhosted.org/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a", size = 1875776, upload-time = "2026-06-07T21:08:25.288Z" }, + { url = "https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602", size = 1760329, upload-time = "2026-06-07T21:08:27.363Z" }, + { url = "https://files.pythonhosted.org/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca", size = 1587293, upload-time = "2026-06-07T21:08:29.805Z" }, + { url = "https://files.pythonhosted.org/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35", size = 1714756, upload-time = "2026-06-07T21:08:32.094Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844", size = 1721052, upload-time = "2026-06-07T21:08:34.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496", size = 1766888, upload-time = "2026-06-07T21:08:36.95Z" }, + { url = "https://files.pythonhosted.org/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5", size = 1581679, upload-time = "2026-06-07T21:08:39.292Z" }, + { url = "https://files.pythonhosted.org/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95", size = 1782021, upload-time = "2026-06-07T21:08:41.407Z" }, + { url = "https://files.pythonhosted.org/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444", size = 1742574, upload-time = "2026-06-07T21:08:43.795Z" }, + { url = "https://files.pythonhosted.org/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0", size = 457773, upload-time = "2026-06-07T21:08:46.168Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719", size = 485001, upload-time = "2026-06-07T21:08:48.401Z" }, + { url = "https://files.pythonhosted.org/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec", size = 453809, upload-time = "2026-06-07T21:08:50.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2", size = 793320, upload-time = "2026-06-07T21:08:52.775Z" }, + { url = "https://files.pythonhosted.org/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340", size = 529077, upload-time = "2026-06-07T21:08:55Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d", size = 532476, upload-time = "2026-06-07T21:08:57.176Z" }, + { url = "https://files.pythonhosted.org/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94", size = 1922347, upload-time = "2026-06-07T21:08:59.563Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc", size = 1786465, upload-time = "2026-06-07T21:09:01.909Z" }, + { url = "https://files.pythonhosted.org/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251", size = 1909423, upload-time = "2026-06-07T21:09:04.357Z" }, + { url = "https://files.pythonhosted.org/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1", size = 2001906, upload-time = "2026-06-07T21:09:06.722Z" }, + { url = "https://files.pythonhosted.org/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3", size = 1877095, upload-time = "2026-06-07T21:09:09.011Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c", size = 1676222, upload-time = "2026-06-07T21:09:11.461Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203", size = 1842922, upload-time = "2026-06-07T21:09:14.118Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1", size = 1825035, upload-time = "2026-06-07T21:09:16.447Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665", size = 1849512, upload-time = "2026-06-07T21:09:18.742Z" }, + { url = "https://files.pythonhosted.org/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1", size = 1668571, upload-time = "2026-06-07T21:09:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d", size = 1881159, upload-time = "2026-06-07T21:09:23.813Z" }, + { url = "https://files.pythonhosted.org/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c", size = 1841409, upload-time = "2026-06-07T21:09:26.207Z" }, + { url = "https://files.pythonhosted.org/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365", size = 494166, upload-time = "2026-06-07T21:09:28.505Z" }, + { url = "https://files.pythonhosted.org/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9", size = 530255, upload-time = "2026-06-07T21:09:30.843Z" }, + { url = "https://files.pythonhosted.org/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", size = 469640, upload-time = "2026-06-07T21:09:33.028Z" }, ] [[package]] @@ -584,55 +584,55 @@ wheels = [ [[package]] name = "cryptography" -version = "47.0.0" +version = "48.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ef/b2/7ffa7fe8207a8c42147ffe70c3e360b228160c1d85dc3faff16aaa3244c0/cryptography-47.0.0.tar.gz", hash = "sha256:9f8e55fe4e63613a5e1cc5819030f27b97742d720203a087802ce4ce9ceb52bb", size = 830863, upload-time = "2026-04-24T19:54:57.056Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/98/40dfe932134bdcae4f6ab5927c87488754bf9eb79297d7e0070b78dd58e9/cryptography-47.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:160ad728f128972d362e714054f6ba0067cab7fb350c5202a9ae8ae4ce3ef1a0", size = 7912214, upload-time = "2026-04-24T19:53:03.864Z" }, - { url = "https://files.pythonhosted.org/packages/34/c6/2733531243fba725f58611b918056b277692f1033373dcc8bd01af1c05d4/cryptography-47.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b9a8943e359b7615db1a3ba587994618e094ff3d6fa5a390c73d079ce18b3973", size = 4644617, upload-time = "2026-04-24T19:53:06.909Z" }, - { url = "https://files.pythonhosted.org/packages/00/e3/b27be1a670a9b87f855d211cf0e1174a5d721216b7616bd52d8581d912ed/cryptography-47.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5c15764f261394b22aef6b00252f5195f46f2ca300bec57149474e2538b31f8", size = 4668186, upload-time = "2026-04-24T19:53:09.053Z" }, - { url = "https://files.pythonhosted.org/packages/81/b9/8443cfe5d17d482d348cee7048acf502bb89a51b6382f06240fd290d4ca3/cryptography-47.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9c59ab0e0fa3a180a5a9c59f3a5abe3ef90d474bc56d7fadfbe80359491b615b", size = 4651244, upload-time = "2026-04-24T19:53:11.217Z" }, - { url = "https://files.pythonhosted.org/packages/5d/5e/13ed0cdd0eb88ba159d6dd5ebfece8cb901dbcf1ae5ac4072e28b55d3153/cryptography-47.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:34b4358b925a5ea3e14384ca781a2c0ef7ac219b57bb9eacc4457078e2b19f92", size = 5252906, upload-time = "2026-04-24T19:53:13.532Z" }, - { url = "https://files.pythonhosted.org/packages/64/16/ed058e1df0f33d440217cd120d41d5dda9dd215a80b8187f68483185af82/cryptography-47.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0024b87d47ae2399165a6bfb20d24888881eeab83ae2566d62467c5ff0030ce7", size = 4701842, upload-time = "2026-04-24T19:53:15.618Z" }, - { url = "https://files.pythonhosted.org/packages/02/e0/3d30986b30fdbd9e969abbdf8ba00ed0618615144341faeb57f395a084fe/cryptography-47.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:1e47422b5557bb82d3fff997e8d92cff4e28b9789576984f08c248d2b3535d93", size = 4289313, upload-time = "2026-04-24T19:53:17.755Z" }, - { url = "https://files.pythonhosted.org/packages/df/fd/32db38e3ad0cb331f0691cb4c7a8a6f176f679124dee746b3af6633db4d9/cryptography-47.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6f29f36582e6151d9686235e586dd35bb67491f024767d10b842e520dc6a07ac", size = 4650964, upload-time = "2026-04-24T19:53:20.062Z" }, - { url = "https://files.pythonhosted.org/packages/86/53/5395d944dfd48cb1f67917f533c609c34347185ef15eb4308024c876f274/cryptography-47.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a9b761f012a943b7de0e828843c5688d0de94a0578d44d6c85a1bae32f87791f", size = 5207817, upload-time = "2026-04-24T19:53:22.498Z" }, - { url = "https://files.pythonhosted.org/packages/34/4f/e5711b28e1901f7d480a2b1b688b645aa4c77c73f10731ed17e7f7db3f0d/cryptography-47.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4e1de79e047e25d6e9f8cea71c86b4a53aced64134f0f003bbcbf3655fd172c8", size = 4701544, upload-time = "2026-04-24T19:53:24.356Z" }, - { url = "https://files.pythonhosted.org/packages/22/22/c8ddc25de3010fc8da447648f5a092c40e7a8fadf01dd6d255d9c0b9373d/cryptography-47.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef6b3634087f18d2155b1e8ce264e5345a753da2c5fa9815e7d41315c90f8318", size = 4783536, upload-time = "2026-04-24T19:53:26.665Z" }, - { url = "https://files.pythonhosted.org/packages/66/b6/d4a68f4ea999c6d89e8498579cba1c5fcba4276284de7773b17e4fa69293/cryptography-47.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:11dbb9f50a0f1bb9757b3d8c27c1101780efb8f0bdecfb12439c22a74d64c001", size = 4926106, upload-time = "2026-04-24T19:53:28.686Z" }, - { url = "https://files.pythonhosted.org/packages/54/ed/5f524db1fade9c013aa618e1c99c6ed05e8ffc9ceee6cda22fed22dda3f4/cryptography-47.0.0-cp311-abi3-win32.whl", hash = "sha256:7fda2f02c9015db3f42bb8a22324a454516ed10a8c29ca6ece6cdbb5efe2a203", size = 3258581, upload-time = "2026-04-24T19:53:31.058Z" }, - { url = "https://files.pythonhosted.org/packages/b2/dc/1b901990b174786569029f67542b3edf72ac068b6c3c8683c17e6a2f5363/cryptography-47.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:f5c3296dab66202f1b18a91fa266be93d6aa0c2806ea3d67762c69f60adc71aa", size = 3775309, upload-time = "2026-04-24T19:53:33.054Z" }, - { url = "https://files.pythonhosted.org/packages/14/88/7aa18ad9c11bc87689affa5ce4368d884b517502d75739d475fc6f4a03c7/cryptography-47.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:be12cb6a204f77ed968bcefe68086eb061695b540a3dd05edac507a3111b25f0", size = 7904299, upload-time = "2026-04-24T19:53:35.003Z" }, - { url = "https://files.pythonhosted.org/packages/07/55/c18f75724544872f234678fdedc871391722cb34a2aee19faa9f63100bb2/cryptography-47.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2ebd84adf0728c039a3be2700289378e1c164afc6748df1a5ed456767bef9ba7", size = 4631180, upload-time = "2026-04-24T19:53:37.517Z" }, - { url = "https://files.pythonhosted.org/packages/ee/65/31a5cc0eaca99cec5bafffe155d407115d96136bb161e8b49e0ef73f09a7/cryptography-47.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f68d6fbc7fbbcfb0939fea72c3b96a9f9a6edfc0e1b1d29778a2066030418b1", size = 4653529, upload-time = "2026-04-24T19:53:39.775Z" }, - { url = "https://files.pythonhosted.org/packages/e5/bc/641c0519a495f3bfd0421b48d7cd325c4336578523ccd76ea322b6c29c7a/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:6651d32eff255423503aa276739da98c30f26c40cbeffcc6048e0d54ef704c0c", size = 4638570, upload-time = "2026-04-24T19:53:42.129Z" }, - { url = "https://files.pythonhosted.org/packages/2b/f2/300327b0a47f6dc94dd8b71b57052aefe178bb51745073d73d80604f11ab/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3fb8fa48075fad7193f2e5496135c6a76ac4b2aa5a38433df0a539296b377829", size = 5238019, upload-time = "2026-04-24T19:53:44.577Z" }, - { url = "https://files.pythonhosted.org/packages/e9/5a/5b5cf994391d4bf9d9c7efd4c66aabe4d95227256627f8fea6cff7dfadbd/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11438c7518132d95f354fa01a4aa2f806d172a061a7bed18cf18cbdacdb204d7", size = 4686832, upload-time = "2026-04-24T19:53:47.015Z" }, - { url = "https://files.pythonhosted.org/packages/dc/2c/ae950e28fd6475c852fc21a44db3e6b5bcc1261d1e370f2b6e42fa800fef/cryptography-47.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8c1a736bbb3288005796c3f7ccb9453360d7fed483b13b9f468aea5171432923", size = 4269301, upload-time = "2026-04-24T19:53:48.97Z" }, - { url = "https://files.pythonhosted.org/packages/67/fb/6a39782e150ffe5cc1b0018cb6ddc48bf7ca62b498d7539ffc8a758e977d/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:f1557695e5c2b86e204f6ce9470497848634100787935ab7adc5397c54abd7ab", size = 4638110, upload-time = "2026-04-24T19:53:51.011Z" }, - { url = "https://files.pythonhosted.org/packages/8e/d7/0b3c71090a76e5c203164a47688b697635ece006dcd2499ab3a4dbd3f0bd/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:f9a034b642b960767fb343766ae5ba6ad653f2e890ddd82955aef288ffea8736", size = 5194988, upload-time = "2026-04-24T19:53:52.962Z" }, - { url = "https://files.pythonhosted.org/packages/63/33/63a961498a9df51721ab578c5a2622661411fc520e00bd83b0cc64eb20c4/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b1c76fca783aa7698eb21eb14f9c4aa09452248ee54a627d125025a43f83e7a7", size = 4686563, upload-time = "2026-04-24T19:53:55.274Z" }, - { url = "https://files.pythonhosted.org/packages/b7/bf/5ee5b145248f92250de86145d1c1d6edebbd57a7fe7caa4dedb5d4cf06a1/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4f7722c97826770bab8ae92959a2e7b20a5e9e9bf4deae68fd86c3ca457bab52", size = 4770094, upload-time = "2026-04-24T19:53:57.753Z" }, - { url = "https://files.pythonhosted.org/packages/92/43/21d220b2da5d517773894dacdcdb5c682c28d3fffce65548cb06e87d5501/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:09f6d7bf6724f8db8b32f11eccf23efc8e759924bc5603800335cf8859a3ddbd", size = 4913811, upload-time = "2026-04-24T19:54:00.236Z" }, - { url = "https://files.pythonhosted.org/packages/31/98/dc4ad376ac5f1a1a7d4a83f7b0c6f2bcad36b5d2d8f30aeb482d3a7d9582/cryptography-47.0.0-cp314-cp314t-win32.whl", hash = "sha256:6eebcaf0df1d21ce1f90605c9b432dd2c4f4ab665ac29a40d5e3fc68f51b5e63", size = 3237158, upload-time = "2026-04-24T19:54:02.606Z" }, - { url = "https://files.pythonhosted.org/packages/bc/da/97f62d18306b5133468bc3f8cc73a3111e8cdc8cf8d3e69474d6e5fd2d1b/cryptography-47.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:51c9313e90bd1690ec5a75ed047c27c0b8e6c570029712943d6116ef9a90620b", size = 3758706, upload-time = "2026-04-24T19:54:04.433Z" }, - { url = "https://files.pythonhosted.org/packages/e0/34/a4fae8ae7c3bc227460c9ae43f56abf1b911da0ec29e0ebac53bb0a4b6b7/cryptography-47.0.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:14432c8a9bcb37009784f9594a62fae211a2ae9543e96c92b2a8e4c3cd5cd0c4", size = 7904072, upload-time = "2026-04-24T19:54:06.411Z" }, - { url = "https://files.pythonhosted.org/packages/01/64/d7b1e54fdb69f22d24a64bb3e88dc718b31c7fb10ef0b9691a3cf7eeea6e/cryptography-47.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:07efe86201817e7d3c18781ca9770bc0db04e1e48c994be384e4602bc38f8f27", size = 4635767, upload-time = "2026-04-24T19:54:08.519Z" }, - { url = "https://files.pythonhosted.org/packages/8b/7b/cca826391fb2a94efdcdfe4631eb69306ee1cff0b22f664a412c90713877/cryptography-47.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b45761c6ec22b7c726d6a829558777e32d0f1c8be7c3f3480f9c912d5ee8a10", size = 4654350, upload-time = "2026-04-24T19:54:10.795Z" }, - { url = "https://files.pythonhosted.org/packages/4c/65/4b57bcc823f42a991627c51c2f68c9fd6eb1393c1756aac876cba2accae2/cryptography-47.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:edd4da498015da5b9f26d38d3bfc2e90257bfa9cbed1f6767c282a0025ae649b", size = 4643394, upload-time = "2026-04-24T19:54:13.275Z" }, - { url = "https://files.pythonhosted.org/packages/f4/c4/2c5fbeea70adbbca2bbae865e1d605d6a4a7f8dbd9d33eaf69645087f06c/cryptography-47.0.0-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9af828c0d5a65c70ec729cd7495a4bf1a67ecb66417b8f02ff125ab8a6326a74", size = 5225777, upload-time = "2026-04-24T19:54:15.18Z" }, - { url = "https://files.pythonhosted.org/packages/7e/b8/ac57107ef32749d2b244e36069bb688792a363aaaa3acc9e3cf84c130315/cryptography-47.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:256d07c78a04d6b276f5df935a9923275f53bd1522f214447fdf365494e2d515", size = 4688771, upload-time = "2026-04-24T19:54:17.835Z" }, - { url = "https://files.pythonhosted.org/packages/56/fc/9f1de22ff8be99d991f240a46863c52d475404c408886c5a38d2b5c3bb26/cryptography-47.0.0-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:5d0e362ff51041b0c0d219cc7d6924d7b8996f57ce5712bdcef71eb3c65a59cc", size = 4270753, upload-time = "2026-04-24T19:54:19.963Z" }, - { url = "https://files.pythonhosted.org/packages/00/68/d70c852797aa68e8e48d12e5a87170c43f67bb4a59403627259dd57d15de/cryptography-47.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:1581aef4219f7ca2849d0250edaa3866212fb74bf5667284f46aa92f9e65c1ca", size = 4642911, upload-time = "2026-04-24T19:54:21.818Z" }, - { url = "https://files.pythonhosted.org/packages/a5/51/661cbee74f594c5d97ff82d34f10d5551c085ca4668645f4606ebd22bd5d/cryptography-47.0.0-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a49a3eb5341b9503fa3000a9a0db033161db90d47285291f53c2a9d2cd1b7f76", size = 5181411, upload-time = "2026-04-24T19:54:24.376Z" }, - { url = "https://files.pythonhosted.org/packages/94/87/f2b6c374a82cf076cfa1416992ac8e8ec94d79facc37aec87c1a5cb72352/cryptography-47.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2207a498b03275d0051589e326b79d4cf59985c99031b05bb292ac52631c37fe", size = 4688262, upload-time = "2026-04-24T19:54:26.946Z" }, - { url = "https://files.pythonhosted.org/packages/14/e2/8b7462f4acf21ec509616f0245018bb197194ab0b65c2ea21a0bdd53c0eb/cryptography-47.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a02675e2fabd0c0fc04c868b8781863cbf1967691543c22f5470500ff840b31", size = 4775506, upload-time = "2026-04-24T19:54:28.926Z" }, - { url = "https://files.pythonhosted.org/packages/70/75/158e494e4c08dc05e039da5bb48553826bd26c23930cf8d3cd5f21fa8921/cryptography-47.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80887c5cbd1774683cb126f0ab4184567f080071d5acf62205acb354b4b753b7", size = 4912060, upload-time = "2026-04-24T19:54:30.869Z" }, - { url = "https://files.pythonhosted.org/packages/06/bd/0a9d3edbf5eadbac926d7b9b3cd0c4be584eeeae4a003d24d9eda4affbbd/cryptography-47.0.0-cp38-abi3-win32.whl", hash = "sha256:ed67ea4e0cfb5faa5bc7ecb6e2b8838f3807a03758eec239d6c21c8769355310", size = 3248487, upload-time = "2026-04-24T19:54:33.494Z" }, - { url = "https://files.pythonhosted.org/packages/60/80/5681af756d0da3a599b7bdb586fac5a1540f1bcefd2717a20e611ddade45/cryptography-47.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:835d2d7f47cdc53b3224e90810fb1d36ca94ea29cc1801fb4c1bc43876735769", size = 3755737, upload-time = "2026-04-24T19:54:35.408Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/12/45/870e7f4bef50e5f53b9f51d4428aee5290eedf58ba443f16b1ebb7ab8e66/cryptography-48.0.1.tar.gz", hash = "sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a", size = 832989, upload-time = "2026-06-09T22:32:31.8Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/bc/ee4137cbbe105652c0ee4252792b78fc8e7afa4b8e61d9d5dc05a7f45731/cryptography-48.0.1-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3e4a1a3232eef2e6c732827d5722db29a0cc8b27af2a4d865b094cf954be9ca1", size = 8008324, upload-time = "2026-06-09T22:31:00.702Z" }, + { url = "https://files.pythonhosted.org/packages/d5/85/6379d42181bfc713094f081360fc5784d6c816b599d45e7f082502d173ce/cryptography-48.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:32143b24adb918f078134e1e230f1eb8cc04886b92c28b5f0041aaf3e5699225", size = 4696243, upload-time = "2026-06-09T22:32:33.446Z" }, + { url = "https://files.pythonhosted.org/packages/9c/87/c85d147b53323c7eb4d850920c8901377323c2a0ff8d79c262d4fee89aa2/cryptography-48.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0d27a5696721ef7a672b8c810f6aded391058e0b9486e63e6d93baf765da691", size = 4713235, upload-time = "2026-06-09T22:31:40.141Z" }, + { url = "https://files.pythonhosted.org/packages/79/58/67cbf8cf1ee7c54b439ca07bbecf8362c07afc11a3724fea70f745784add/cryptography-48.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb86ce1af36fe65041b6db9a8bb064ee621a7e5fded0f80d475ec243477cd242", size = 4702323, upload-time = "2026-06-09T22:31:42.191Z" }, + { url = "https://files.pythonhosted.org/packages/89/c6/24266ac10c47f6cd2a865f4446062b466da1d1f10b27189eac00e61bf0c9/cryptography-48.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b024e784ad6c077ee0147b35ea9cbfc1e34e1fd4c1dcca214c2794d73a12df08", size = 5300085, upload-time = "2026-06-09T22:31:58.703Z" }, + { url = "https://files.pythonhosted.org/packages/d2/bb/cc4b78784f97efc8c5874c2a9743708d172be6663024b34a0467885ae0c8/cryptography-48.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3752f2dbc8f07a30aad2932c986cea495b03bb554887828225da104f732852b6", size = 4746137, upload-time = "2026-06-09T22:31:31.01Z" }, + { url = "https://files.pythonhosted.org/packages/1f/52/0c44de3f5267f8fbe8e835138017522a333436166e406f0db9b9e6e3033f/cryptography-48.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:bd81490cd5801d755cf97bb68ac191f14b708470b1c7cf4580f669b9c9264cd8", size = 4333867, upload-time = "2026-06-09T22:32:28.096Z" }, + { url = "https://files.pythonhosted.org/packages/9a/2e/772d7adbfa931537bc401640b7cac9976bff689bda187833e5d63b428e49/cryptography-48.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:66fd0771e7b9c6dcd44cf1120690d2338d16d72795cf40cae2786a39eba65429", size = 4701805, upload-time = "2026-06-09T22:31:38.284Z" }, + { url = "https://files.pythonhosted.org/packages/f8/a3/b06844f303873493c963caf581c04df31c7035e0c1b0f02c4814d319ec80/cryptography-48.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:3fd2ca57062b241c856670b073487d2e86c4637937ca5601e48f97bf8e11fc8f", size = 5258461, upload-time = "2026-06-09T22:31:04.187Z" }, + { url = "https://files.pythonhosted.org/packages/9f/13/8b765e2e12b07c74941caadb9d1c8fdc006c4dfbf2b8f2d610519758954d/cryptography-48.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:0ee6ea481db1ab889cba043ec1eda17bb9c1ea79db6722f779c3667f9f70322f", size = 4745488, upload-time = "2026-06-09T22:32:30.07Z" }, + { url = "https://files.pythonhosted.org/packages/2e/aa/48972bce55049b32a94f4907eda4d75fa385aad8a39506cc2fc72196ecf0/cryptography-48.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f2ceef93cb096aa3c4cc4b5c94ca6131f9196d28c64d6111533402a9b2054d41", size = 4830256, upload-time = "2026-06-09T22:31:43.868Z" }, + { url = "https://files.pythonhosted.org/packages/47/a2/e5079a032fb85cf6005046ca92bbd78b0c82dad2b5751ab8c311659da06f/cryptography-48.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bd3f92d76217892b15df84ca256c2c113d386fdda7a7d8691aeeced976507c6", size = 4979117, upload-time = "2026-06-09T22:31:05.845Z" }, + { url = "https://files.pythonhosted.org/packages/b7/a0/8f50cae9c74e718ed769d63ed5c74bd0ea830c9550a74629cebd1b9c7bc7/cryptography-48.0.1-cp311-abi3-win32.whl", hash = "sha256:b9a32b876490d66c8bcc9963ef220199569748434ab01a9d6aaeabf88e7f5158", size = 3304154, upload-time = "2026-06-09T22:32:16.845Z" }, + { url = "https://files.pythonhosted.org/packages/c5/69/0572c77dbace6fef72f33755bd52ea399c71367250d366237f8691826b9e/cryptography-48.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:39489bfca54c7a1f6b297efcd8bc608ab92d16c4ca631b0cad4da46724588b24", size = 3817138, upload-time = "2026-06-09T22:32:00.388Z" }, + { url = "https://files.pythonhosted.org/packages/42/06/3e768b4c3bc78201583fa35a0e18f640dd782ff41afba88f8545481a8874/cryptography-48.0.1-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:f817adc181390bd54f2f700107a7419040fb7c1bdf2fc26f36551a06a68c3345", size = 7989830, upload-time = "2026-06-09T22:31:07.8Z" }, + { url = "https://files.pythonhosted.org/packages/8a/13/6476736484b94041110c8340a3eb63962fea4975baea8cb4a512adb44d4d/cryptography-48.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d5d30989c6917b478b5817902e85fddaea2261efa8648383d965381ccb9e1ac4", size = 4689201, upload-time = "2026-06-09T22:31:09.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/62/65a87f34d2a431546e2509b85d55e8c90df86d668f6731da64d538512ac2/cryptography-48.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:df637c05205ea7c1d7fbcbe54bbfea648a52951155f997af13d895d0ecc96991", size = 4702822, upload-time = "2026-06-09T22:32:24.409Z" }, + { url = "https://files.pythonhosted.org/packages/7f/59/810b5204b0a9b10f4b6bc06bd551a8b609803cd931806bc3b71884b225e5/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:869c3b8a53bfe27147832df48b32adadf558249d50e76cb3769d40e986b13265", size = 4694875, upload-time = "2026-06-09T22:32:08.737Z" }, + { url = "https://files.pythonhosted.org/packages/24/dc/d8ca05ffea724eec6d232ea6f18e74c269eb6bdfdcc9bfba689790d1325f/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:e361afba8918070d376df76f408a4f67fec0ee9cff81a99e48fe9a233ef59e17", size = 5290385, upload-time = "2026-06-09T22:31:15.212Z" }, + { url = "https://files.pythonhosted.org/packages/03/8c/3be6cb4da181f5bb6c19cf560c2359d60644a6b5fc5b57854e528f47b296/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:d069066deead00ac7f090be101be875a06855908f7ec004c27b8fefb4acfb411", size = 4737082, upload-time = "2026-06-09T22:32:22.66Z" }, + { url = "https://files.pythonhosted.org/packages/aa/f6/d5f60a5a1434dbfd949e227fd0065d194c7e6b6ac526b17f5c06152b8231/cryptography-48.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:09f73a725d582cef64b91281a322cd798d14a33b2b6f2b7ad9531dc336d84c02", size = 4325328, upload-time = "2026-06-09T22:32:10.777Z" }, + { url = "https://files.pythonhosted.org/packages/17/b7/ba75dd947a14b6ad907b01ae8f6b5b348cdd1b48142f0063dee9e20c1d9d/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:15254441469dd6bf027039453288e2072124f8b6603563f5d759e1c9b69273fa", size = 4694530, upload-time = "2026-06-09T22:31:53.105Z" }, + { url = "https://files.pythonhosted.org/packages/62/29/50d6b9e8aff12d8b67afaeb3569335e32dc83a5723e3bbded24fdac9f809/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:8ace4507d1e6533c125f4fac754f8bb8b6a74c08e92179dabd7e16571a3efbf3", size = 5245046, upload-time = "2026-06-09T22:31:25.774Z" }, + { url = "https://files.pythonhosted.org/packages/9f/04/618f4115cfc0add0838c82507aa18a346089428da8653ad38b3ff36f5cb3/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b4e391975f038e66432328639620a4aff2d307513b004f1ca06d6225bced815c", size = 4736660, upload-time = "2026-06-09T22:32:12.676Z" }, + { url = "https://files.pythonhosted.org/packages/24/9c/06e062462a0de28a3b3911322eded4c16deb9f441b1b7575d3dc59488ab5/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42fcd8e26fe555d9b3577a135f5091fefa0aa4e99129c23fb56787a1bd4ada72", size = 4822229, upload-time = "2026-06-09T22:31:17.062Z" }, + { url = "https://files.pythonhosted.org/packages/f4/be/0561971eaaee4b8a0e7d5113c536921063ab91aaf23278ac374eaf881e11/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c1400da5e32a43253392277eac7490a60e497d810a63dd5608d71bbd7af507c9", size = 4966364, upload-time = "2026-06-09T22:31:32.842Z" }, + { url = "https://files.pythonhosted.org/packages/a4/27/728c77876f12b000820b69ae490f3c4083775e79e07827e9e60be07ad209/cryptography-48.0.1-cp314-cp314t-win32.whl", hash = "sha256:0df56b056bc17c1b7d6821dfa65216e62bd232d8ab05eb3db44e71d235651471", size = 3278498, upload-time = "2026-06-09T22:31:29.154Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/79a612c6d7b1e6ee0edd43633d53035bec2cfb78c82b76f7864f39e36f34/cryptography-48.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:9de21387aa95e2a895823d0745b430bed4f33503ba9ab5e0b5311f33e37d66d2", size = 3798790, upload-time = "2026-06-09T22:31:56.697Z" }, + { url = "https://files.pythonhosted.org/packages/ca/6c/00fa2a95997164c8b2072ce327c23d4ab20809ccc323ea5fab91e53a4bba/cryptography-48.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:4fdc69f8e4316bcf0c8c8ec1f26f285d12e8142d88d96c876a59a03be3f6ae67", size = 7987408, upload-time = "2026-06-09T22:32:20.777Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d9/45f309a7e4e5f3f8f121d6d3be9e94024a7726ec598d6e08ae04edb2f04d/cryptography-48.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48fe40804d4caa2288f24e70ca8c64c42dd826da0ad7e4f1b41b2128d679e6c8", size = 4690196, upload-time = "2026-06-09T22:31:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9f/a1bc8bcc798811b8527eb374bbccf30a3f3e806829d967118222bf1125eb/cryptography-48.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:86be3b1b0b6bf09482fb50a979c508d2950ed95f5621ec77f4e385962006b83a", size = 4696782, upload-time = "2026-06-09T22:31:45.615Z" }, + { url = "https://files.pythonhosted.org/packages/66/c2/81a4fb4e4373c500bb526bc337ac5719dd31dd15b970b84a238168c6aa08/cryptography-48.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4ab0a343c807bbcd90c971cd1ecf072937cd01847a9e002bef88fb47ac6be577", size = 4696618, upload-time = "2026-06-09T22:31:11.564Z" }, + { url = "https://files.pythonhosted.org/packages/e5/0b/aa68b221dde92d09cb29a024ede17550ee21e77a404e59fc093c82bb51e1/cryptography-48.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9621de99d2da096006b629979efd8ae7eb2d8b822488d0c89ee4000c306c59b1", size = 5289970, upload-time = "2026-06-09T22:31:20.368Z" }, + { url = "https://files.pythonhosted.org/packages/78/13/fba657f958d2af66ea959a4ba01212632089249d34af1ae48054136344d7/cryptography-48.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:88c852a0ae366e262e5a1744b685e6a433dc8788dd2a277e418bf4904203609d", size = 4731873, upload-time = "2026-06-09T22:31:22.253Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4c/9a964756d24a26b3e34dfcb16f961b89838786e6700b635b0d1e3adff4b6/cryptography-48.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:43c5835e2cb98c8733d86f57d6fc879b613f5c3478607281c3e36daffc6dd8a6", size = 4330804, upload-time = "2026-06-09T22:31:36.56Z" }, + { url = "https://files.pythonhosted.org/packages/4b/0f/a10f3a6eb12950a10e3a874070283aa2dd5875b2bfd15fad8a3e17b3f13e/cryptography-48.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:fe0180af5bf9236518a087e35bf2d9a347d5f5f51e63c579d683ddff424e3d46", size = 4696217, upload-time = "2026-06-09T22:31:13.351Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6f/5cd12f951165ea73ef85266775d97e4c763b2474ccfd816dd69d3a18d6f8/cryptography-48.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:b7a2d1a937a738a881737cec135a38bb61470589b17515b9f73f571d0ae10401", size = 5245252, upload-time = "2026-06-09T22:32:02.193Z" }, + { url = "https://files.pythonhosted.org/packages/68/ab/8aaa12e4516ec4464033ab79b6f3b592bd5a92102467c4ace8a0d970203f/cryptography-48.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b74ca3b8e5ecdd833bf6a002ca41b4793bb27fb8f1c06ffaf2643c9e9140e31b", size = 4731388, upload-time = "2026-06-09T22:32:04.019Z" }, + { url = "https://files.pythonhosted.org/packages/1b/24/50027ea4dca85ec1f40688f3c24fb32ccacd520583c9592c3cc95628e6fb/cryptography-48.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2c37f2461406063b417837f5f3daab668652acd82423efcd7f0a9f04be972de1", size = 4824186, upload-time = "2026-06-09T22:32:18.707Z" }, + { url = "https://files.pythonhosted.org/packages/52/41/04cb5eb17085ade6f50cc611fb657df6a0f5885350de8764ece89c050197/cryptography-48.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:86fe77abb1bd87afb251d4d02ada7ecf53a32cee9b67d976abb2e45a13297475", size = 4964539, upload-time = "2026-06-09T22:31:18.793Z" }, + { url = "https://files.pythonhosted.org/packages/36/bf/ed70785c496e89d7e73b7cda2d21f2447fd6d4e821714b8d04ff217fed92/cryptography-48.0.1-cp39-abi3-win32.whl", hash = "sha256:6b2c0c3e6ccf3ade7750f836ef3ee36eea250cc467d45c256895573ac08cc6f1", size = 3282307, upload-time = "2026-06-09T22:30:53.162Z" }, + { url = "https://files.pythonhosted.org/packages/b3/ff/371ea7d252656ee1eb6d83eeeef3d1d0c6baf1d6497687d081ea03814670/cryptography-48.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:9a49ca6c81417f6a5edb50375a60cccdd70fa0a91a5211829dbea74eba94d2ac", size = 3793408, upload-time = "2026-06-09T22:32:15.191Z" }, ] [[package]] @@ -822,8 +822,8 @@ security = [ [package.metadata] requires-dist = [ - { name = "aiohttp", specifier = ">=3.9,<4" }, - { name = "cryptography", specifier = ">=46,<48" }, + { name = "aiohttp", specifier = ">=3.14.1,<4" }, + { name = "cryptography", specifier = ">=48.0.1,<49" }, { name = "eth-account", specifier = ">=0.11,<1" }, { name = "idna", specifier = ">=3.15" }, { name = "python-dotenv", specifier = ">=1.0,<2" },