duo-data-utilities 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,37 @@
1
+ """Pay-per-call data for AI agents (x402, USDC on Base): IBAN, ISIN, VAT/tax ID, GTIN and 29 more utilities, plus
2
+ crypto derivatives data (perp funding rates, open interest, futures basis, implied volatility, liquidations,
3
+ order book depth) through DuoClient(...).derivs or DerivsClient."""
4
+
5
+ from ._version import USER_AGENT, __version__
6
+ from .client import DuoClient, PaidResult
7
+ from .counterparty import CounterpartyCheck, CounterpartyResult
8
+ from .derivs import DEFAULT_DERIVS_BASE_URL, DEFAULT_DERIVS_MAX_PRICE_USD, DERIVS_MCP_URL, DerivsClient, DerivsPaidResult
9
+ from .derivs_generated import DERIVS_ROUTES, DerivsMethods, DerivsRouteInfo
10
+ from .errors import DuoApiError, PriceGuardError
11
+ from .generated import ROUTES, DuoMethods, RouteInfo
12
+ from .price_guard import BASE_NETWORK, BASE_USDC_ADDRESS, DEFAULT_MAX_PRICE_USD
13
+
14
+ __all__ = [
15
+ "BASE_NETWORK",
16
+ "BASE_USDC_ADDRESS",
17
+ "CounterpartyCheck",
18
+ "CounterpartyResult",
19
+ "DEFAULT_DERIVS_BASE_URL",
20
+ "DEFAULT_DERIVS_MAX_PRICE_USD",
21
+ "DEFAULT_MAX_PRICE_USD",
22
+ "DERIVS_MCP_URL",
23
+ "DERIVS_ROUTES",
24
+ "DerivsClient",
25
+ "DerivsMethods",
26
+ "DerivsPaidResult",
27
+ "DerivsRouteInfo",
28
+ "DuoApiError",
29
+ "DuoClient",
30
+ "DuoMethods",
31
+ "PaidResult",
32
+ "PriceGuardError",
33
+ "ROUTES",
34
+ "RouteInfo",
35
+ "USER_AGENT",
36
+ "__version__",
37
+ ]
@@ -0,0 +1,116 @@
1
+ """HTTP plumbing shared by DuoClient (api.duoleads.com) and DerivsClient (derivs.duoleads.com)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Any, Callable, Mapping
7
+ from urllib.parse import urlsplit, urlunsplit
8
+
9
+ import requests
10
+ from x402 import SchemeRegistration, x402ClientConfig, x402ClientSync
11
+ from x402.http import x402HTTPClientSync
12
+ from x402.http.clients.requests import wrapRequestsWithPayment
13
+ from x402.http.utils import decode_payment_response_header
14
+ from x402.mechanisms.evm.exact.client import ExactEvmScheme
15
+
16
+ from .errors import PriceGuardError
17
+ from .price_guard import BASE_NETWORK, guard_policy, requirement_problem
18
+
19
+ # urllib returns IPv6 hosts without brackets.
20
+ _LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
21
+
22
+
23
+ def normalise_base_url(raw: str) -> str:
24
+ parts = urlsplit(raw)
25
+ plain_loopback = parts.scheme == "http" and parts.hostname in _LOOPBACK_HOSTS
26
+ if parts.scheme != "https" and not plain_loopback:
27
+ raise ValueError(f"base_url must use https (plain http is only allowed for localhost): {raw}")
28
+ if parts.query or parts.fragment:
29
+ raise ValueError("base_url must not contain a query or a fragment")
30
+ return urlunsplit((parts.scheme, parts.netloc, parts.path.rstrip("/"), "", ""))
31
+
32
+
33
+ def assert_route_path(path: str) -> None:
34
+ if not re.fullmatch(r"/v1/[a-z]+/[a-z0-9-]+", path):
35
+ raise ValueError(f"not a route path: {path!r} (expected /v1/<group>/<name>)")
36
+
37
+
38
+ def _query_value(name: str, value: Any) -> str:
39
+ # bool is a subclass of int, so it is checked first. The API expects "true" and "false".
40
+ if isinstance(value, bool):
41
+ return "true" if value else "false"
42
+ if isinstance(value, (int, float)):
43
+ return str(value)
44
+ if isinstance(value, str):
45
+ return value
46
+ raise TypeError(f"parameter {name} must be a string, number or boolean")
47
+
48
+
49
+ def _query(params: Mapping[str, Any]) -> dict[str, str]:
50
+ return {name: _query_value(name, value) for name, value in params.items() if value is not None}
51
+
52
+
53
+ def _parse_json(response: requests.Response) -> Any:
54
+ try:
55
+ return response.json()
56
+ except ValueError:
57
+ return None
58
+
59
+
60
+ def _receipt_of(response: requests.Response) -> Any:
61
+ header = response.headers.get("PAYMENT-RESPONSE")
62
+ if not header:
63
+ return None
64
+ try:
65
+ return decode_payment_response_header(header)
66
+ except ValueError:
67
+ return None
68
+
69
+
70
+ def _price_guard_refusal(error: BaseException) -> PriceGuardError | None:
71
+ """The PriceGuardError inside an x402 PaymentError chain, if there is one."""
72
+ current: BaseException | None = error
73
+ while current is not None:
74
+ if isinstance(current, PriceGuardError):
75
+ return current
76
+ current = current.__cause__ or current.__context__
77
+ return None
78
+
79
+
80
+ def _challenge_guard(max_atomic: int, max_price_usd: float, pay_to: str | None) -> Callable[[Any], None]:
81
+ """Checks every option of a 402 challenge before the x402 client selects or signs anything.
82
+
83
+ Runs as an on_payment_required hook. It refuses a challenge with no acceptable option, the same way the
84
+ TypeScript client does. Any exception it raises reaches the caller wrapped in PaymentError, which
85
+ _price_guard_refusal unwraps.
86
+ """
87
+
88
+ def check(context: Any) -> None:
89
+ required = context.payment_required
90
+ reasons: list[str] = []
91
+ if required.x402_version != 2:
92
+ reasons.append(f"x402Version {required.x402_version} is not 2")
93
+ options = list(required.accepts or [])
94
+ problems = [requirement_problem(option, max_atomic, pay_to) for option in options]
95
+ if not options:
96
+ reasons.append("the challenge offers no payment option")
97
+ elif all(problem is not None for problem in problems):
98
+ reasons.extend(problem for problem in problems if problem is not None)
99
+ if reasons:
100
+ raise PriceGuardError(reasons, max_price_usd)
101
+
102
+ return check
103
+
104
+
105
+ def _paying_session(account: Any, max_atomic: int, max_price_usd: float, pay_to: str | None) -> requests.Session:
106
+ config = x402ClientConfig(
107
+ schemes=[SchemeRegistration(network=BASE_NETWORK, client=ExactEvmScheme(account))],
108
+ policies=[guard_policy(max_atomic, max_price_usd, pay_to)],
109
+ # The price guard is the only spend control. It is stricter (Base USDC only, at most max_price_usd),
110
+ # and the payment layer's default-asset check would refuse the same requirements with a vaguer error.
111
+ spend_controls=False,
112
+ )
113
+ http_client = x402HTTPClientSync(x402ClientSync.from_config(config)).on_payment_required(
114
+ _challenge_guard(max_atomic, max_price_usd, pay_to)
115
+ )
116
+ return wrapRequestsWithPayment(requests.Session(), http_client)
@@ -0,0 +1,12 @@
1
+ """The package version and the User-Agent, both read from the installed package metadata (pyproject.toml)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.metadata import PackageNotFoundError, version
6
+
7
+ try:
8
+ __version__ = version("duo-data-utilities")
9
+ except PackageNotFoundError: # running from a source tree that was never installed
10
+ __version__ = "0.0.0+unknown"
11
+
12
+ USER_AGENT = f"duo-data-utilities-py/{__version__} (+https://github.com/hiesem/duo-data-utilities)"
@@ -0,0 +1,150 @@
1
+ """Client for https://api.duoleads.com. Each call costs $0.05 (x402 v2, USDC on Base)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any, Mapping
7
+
8
+ import requests
9
+ from x402.http.clients.requests import PaymentError
10
+
11
+ from ._http import (
12
+ _paying_session,
13
+ _parse_json,
14
+ _price_guard_refusal,
15
+ _query,
16
+ _receipt_of,
17
+ assert_route_path,
18
+ normalise_base_url,
19
+ )
20
+ from ._version import USER_AGENT
21
+ from .counterparty import CounterpartyResult, run_counterparty_check
22
+ from .derivs import DEFAULT_DERIVS_BASE_URL, DerivsClient
23
+ from .errors import DuoApiError
24
+ from .generated import DuoMethods
25
+ from .price_guard import DEFAULT_MAX_PRICE_USD, DEFAULT_PAY_TO, usd_to_atomic
26
+
27
+ DEFAULT_BASE_URL = "https://api.duoleads.com"
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class PaidResult:
32
+ """A route's result with the response meta and the settlement receipt (None when the header is absent)."""
33
+
34
+ data: dict[str, Any]
35
+ meta: dict[str, Any]
36
+ receipt: Any
37
+
38
+
39
+ def _api_error(path: str, response: requests.Response, body: Any) -> DuoApiError:
40
+ status = response.status_code
41
+ meta = body.get("meta") if isinstance(body, dict) else None
42
+ request_id = meta.get("request_id") if isinstance(meta, dict) and isinstance(meta.get("request_id"), str) else None
43
+ if isinstance(body, dict) and body.get("ok") is False and isinstance(body.get("error"), dict):
44
+ error = body["error"]
45
+ return DuoApiError(
46
+ code=str(error.get("code") or "UNKNOWN_ERROR"),
47
+ message=str(error.get("message") or f"{path} failed with HTTP {status}"),
48
+ param=error.get("param") if isinstance(error.get("param"), str) else None,
49
+ expected=error.get("expected") if isinstance(error.get("expected"), str) else None,
50
+ status=status,
51
+ route=path,
52
+ request_id=request_id,
53
+ )
54
+ if status == 402:
55
+ return DuoApiError(
56
+ code="PAYMENT_REQUIRED",
57
+ message="the payment was not accepted (HTTP 402)",
58
+ status=status,
59
+ route=path,
60
+ request_id=request_id,
61
+ )
62
+ if 200 <= status < 300:
63
+ return DuoApiError(
64
+ code="INVALID_RESPONSE",
65
+ message=f"{path} did not return a valid response envelope (HTTP {status})",
66
+ status=status,
67
+ route=path,
68
+ request_id=request_id,
69
+ )
70
+ return DuoApiError(code=f"HTTP_{status}", message=f"{path} failed with HTTP {status}", status=status, route=path, request_id=request_id)
71
+
72
+
73
+
74
+ class DuoClient(DuoMethods):
75
+ """Typed methods for every route, plus call, call_with_receipt and check_counterparty.
76
+
77
+ Pass `account` (an eth_account LocalAccount, or any object implementing x402's ClientEvmSigner) to pay
78
+ with your wallet. The client refuses any price above `max_price_usd`, and any payee other than `pay_to`
79
+ (default: the service's public address; None accepts any), before it signs. Pass `session`
80
+ instead to use a requests.Session that already pays; the price guard cannot check payments made inside it.
81
+ Every request carries `user_agent` (default USER_AGENT, naming the SDK and its version); to append your own
82
+ product, pass f"{USER_AGENT} my-agent/1.0".
83
+
84
+ `derivs` is a DerivsClient for https://derivs.duoleads.com (crypto derivatives data, $0.003 or $0.005 per
85
+ call) on the same wallet or session, with its own price cap `derivs_max_price_usd` (default: each route's own
86
+ listed price).
87
+ """
88
+
89
+ def __init__(
90
+ self,
91
+ account: Any = None,
92
+ *,
93
+ session: requests.Session | None = None,
94
+ base_url: str = DEFAULT_BASE_URL,
95
+ max_price_usd: float = DEFAULT_MAX_PRICE_USD,
96
+ timeout: float | None = None,
97
+ pay_to: str | None = DEFAULT_PAY_TO,
98
+ user_agent: str = USER_AGENT,
99
+ derivs_base_url: str = DEFAULT_DERIVS_BASE_URL,
100
+ derivs_max_price_usd: float | None = None,
101
+ ) -> None:
102
+ if (account is None) == (session is None):
103
+ raise TypeError("pass exactly one of account (pays with your wallet) or session (a requests.Session that already pays)")
104
+ if "\r" in user_agent or "\n" in user_agent:
105
+ raise ValueError("user_agent must not contain line breaks")
106
+ max_atomic = usd_to_atomic(max_price_usd)
107
+ self._user_agent = user_agent
108
+ self._base_url = normalise_base_url(base_url)
109
+ self._max_price_usd = max_price_usd
110
+ self._timeout = timeout
111
+ self._session = session if session is not None else _paying_session(account, max_atomic, max_price_usd, pay_to)
112
+ super().__init__(self.call)
113
+ self.derivs = DerivsClient(
114
+ account if session is None else None,
115
+ session=session,
116
+ base_url=derivs_base_url,
117
+ max_price_usd=derivs_max_price_usd,
118
+ timeout=timeout,
119
+ pay_to=pay_to,
120
+ user_agent=user_agent,
121
+ )
122
+
123
+ def call(self, path: str, params: Mapping[str, Any] | None = None) -> dict[str, Any]:
124
+ """Calls any route by path and returns its `data` object. Costs $0.05 per call."""
125
+ return self.call_with_receipt(path, params).data
126
+
127
+ def call_with_receipt(self, path: str, params: Mapping[str, Any] | None = None) -> PaidResult:
128
+ """Like `call`, and also returns the response meta and the settlement receipt."""
129
+ assert_route_path(path)
130
+ try:
131
+ response = self._session.get(
132
+ f"{self._base_url}{path}",
133
+ params=_query(params or {}),
134
+ headers={"accept": "application/json", "User-Agent": self._user_agent},
135
+ timeout=self._timeout,
136
+ )
137
+ except PaymentError as error:
138
+ refusal = _price_guard_refusal(error)
139
+ if refusal is not None:
140
+ raise refusal from error
141
+ raise
142
+ body = _parse_json(response)
143
+ if 200 <= response.status_code < 300 and isinstance(body, dict) and body.get("ok") is True and "data" in body:
144
+ meta = body.get("meta")
145
+ return PaidResult(data=body["data"], meta=meta if isinstance(meta, dict) else {}, receipt=_receipt_of(response))
146
+ raise _api_error(path, response, body)
147
+
148
+ def check_counterparty(self, *, iban: str, country: str, tax_id_type: str, tax_id: str) -> CounterpartyResult:
149
+ """Runs IBAN, tax ID and country checks: three calls, $0.15. Structure and checksum only."""
150
+ return run_counterparty_check(self, iban=iban, country=country, tax_id_type=tax_id_type, tax_id=tax_id)
@@ -0,0 +1,118 @@
1
+ """Counterparty check: IBAN, tax ID and country in one verdict. Mirrors src/counterparty.ts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass, field
7
+ from typing import Any, Literal
8
+
9
+ from .errors import DuoApiError
10
+
11
+ Status = Literal["pass", "fail", "review"]
12
+
13
+ INPUT_PROBLEM_CODES = frozenset({"MISSING_PARAM", "INVALID_PARAM", "INPUT_TOO_LARGE"})
14
+ NOT_COVERED_CODES = frozenset({"UNSUPPORTED_VALUE"})
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class CounterpartyCheck:
19
+ """One check. `check` is one of iban, tax_id, country or country_match (no route for the last)."""
20
+
21
+ check: str
22
+ route: str | None
23
+ status: Status
24
+ detail: str
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class CounterpartyResult:
29
+ """`verdict` is fail if any check failed, else review if any check could not run or the countries differ, else pass."""
30
+
31
+ verdict: Status
32
+ checks: list[CounterpartyCheck] = field(default_factory=list)
33
+ reasons: list[str] = field(default_factory=list)
34
+
35
+
36
+ def _text(value: Any, name: str) -> str:
37
+ if not isinstance(value, str) or value.strip() == "":
38
+ raise TypeError(f"{name} must be a non-empty string")
39
+ return value.strip()
40
+
41
+
42
+ def _country(value: Any) -> str:
43
+ code = _text(value, "country").upper()
44
+ if not re.fullmatch(r"[A-Z]{2}", code):
45
+ raise ValueError("country must be an ISO 3166-1 alpha-2 code, e.g. NL")
46
+ return "GB" if code == "UK" else code
47
+
48
+
49
+ def _problem_check(check: str, route: str, problem: DuoApiError) -> CounterpartyCheck:
50
+ status: Status = "review" if problem.code in NOT_COVERED_CODES else "fail"
51
+ return CounterpartyCheck(check=check, route=route, status=status, detail=f"{problem.message} ({problem.code})")
52
+
53
+
54
+ def run_counterparty_check(api: Any, *, iban: str, country: str, tax_id_type: str, tax_id: str) -> CounterpartyResult:
55
+ """Runs the three checks in order (three calls, $0.15) and combines them. Inputs are validated before any call."""
56
+ iban = _text(iban, "iban")
57
+ country = _country(country)
58
+ tax_id_type = _text(tax_id_type, "tax_id_type")
59
+ tax_id = _text(tax_id, "tax_id")
60
+ checks: list[CounterpartyCheck] = []
61
+
62
+ iban_route = "/v1/id/iban"
63
+ iban_country: str | None = None
64
+ try:
65
+ iban_data = api.iban.validate(iban=iban)
66
+ except DuoApiError as problem:
67
+ if problem.code not in INPUT_PROBLEM_CODES | NOT_COVERED_CODES:
68
+ raise
69
+ checks.append(_problem_check("iban", iban_route, problem))
70
+ else:
71
+ if iban_data["valid"]:
72
+ iban_country = str(iban_data["country"]).upper()
73
+ checks.append(CounterpartyCheck("iban", iban_route, "pass", f"IBAN checksum valid ({iban_country})"))
74
+ else:
75
+ checks.append(CounterpartyCheck("iban", iban_route, "fail", f"IBAN invalid: {iban_data.get('reason') or 'unknown reason'}"))
76
+
77
+ tax_route = "/v1/id/national"
78
+ try:
79
+ tax_data = api.tax_id.validate(country=country, kind=tax_id_type, value=tax_id)
80
+ except DuoApiError as problem:
81
+ if problem.code not in INPUT_PROBLEM_CODES | NOT_COVERED_CODES:
82
+ raise
83
+ checks.append(_problem_check("tax_id", tax_route, problem))
84
+ else:
85
+ if tax_data["valid"]:
86
+ checks.append(CounterpartyCheck("tax_id", tax_route, "pass", f"{tax_id_type} checksum valid for {country}"))
87
+ else:
88
+ checks.append(
89
+ CounterpartyCheck("tax_id", tax_route, "fail", f"{tax_id_type} invalid for {country}: {tax_data.get('reason') or 'unknown reason'}")
90
+ )
91
+
92
+ country_route = "/v1/ref/country"
93
+ try:
94
+ country_data = api.country.lookup(code=country)
95
+ except DuoApiError as problem:
96
+ if problem.code not in INPUT_PROBLEM_CODES | NOT_COVERED_CODES:
97
+ raise
98
+ checks.append(_problem_check("country", country_route, problem))
99
+ else:
100
+ if country_data["found"] and country_data.get("kind") == "country":
101
+ checks.append(CounterpartyCheck("country", country_route, "pass", f"{country} is an ISO 3166-1 country"))
102
+ else:
103
+ checks.append(CounterpartyCheck("country", country_route, "fail", f"{country} is not an ISO 3166-1 country code"))
104
+
105
+ if iban_country is not None and iban_country != country:
106
+ checks.append(
107
+ CounterpartyCheck(
108
+ "country_match",
109
+ None,
110
+ "review",
111
+ f"IBAN country {iban_country} differs from the tax ID country {country}",
112
+ )
113
+ )
114
+
115
+ statuses = [check.status for check in checks]
116
+ verdict: Status = "fail" if "fail" in statuses else "review" if "review" in statuses else "pass"
117
+ reasons = [check.detail for check in checks if check.status != "pass"]
118
+ return CounterpartyResult(verdict=verdict, checks=checks, reasons=reasons)
@@ -0,0 +1,165 @@
1
+ """Client for https://derivs.duoleads.com: crypto derivatives data, paid per call over x402 (USDC on Base).
2
+
3
+ Perp funding rates, funding history and arbitrage, a perp screener, open interest, futures basis, implied
4
+ volatility, liquidations, order book depth and a one-call snapshot, across Bybit, Hyperliquid, dYdX, Gate,
5
+ Kraken, Bitfinex, Deribit and OKX. $0.003 or $0.005 per call. Mirrors src/derivs.ts.
6
+
7
+ Differences from the main API handled here: paths are /v1/<name> (one segment), a 200 answer is the data
8
+ object itself (no {ok, data, meta} envelope), and an error answer is {"error": {...}, "request_id": ...}.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import math
14
+ import re
15
+ from dataclasses import dataclass
16
+ from typing import Any, Mapping
17
+
18
+ import requests
19
+ from x402.http.clients.requests import PaymentError
20
+
21
+ from ._http import _parse_json, _paying_session, _price_guard_refusal, _receipt_of, normalise_base_url
22
+ from ._version import USER_AGENT
23
+ from .derivs_generated import DERIVS_ROUTES, DerivsMethods
24
+ from .errors import DuoApiError
25
+ from .price_guard import DEFAULT_PAY_TO, usd_to_atomic
26
+
27
+ DEFAULT_DERIVS_BASE_URL = "https://derivs.duoleads.com"
28
+ # The cap for a path the client does not know (the most expensive derivs route costs $0.005). Every listed route
29
+ # is capped by default at its own price, $0.003 or $0.005.
30
+ DEFAULT_DERIVS_MAX_PRICE_USD = 0.005
31
+ # Default cap per path when the caller passed no max_price_usd: each route's own price.
32
+ _ROUTE_CAP_USD: dict[str, float] = {r["path"]: float(r["price_usd"]) for r in DERIVS_ROUTES}
33
+ # Paid MCP endpoint (Streamable HTTP; payment in params._meta["x402/payment"]).
34
+ DERIVS_MCP_URL = "https://derivs.duoleads.com/mcp"
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class DerivsPaidResult:
39
+ """A derivs answer (its JSON body) with the settlement receipt (None when the header is absent)."""
40
+
41
+ data: dict[str, Any]
42
+ receipt: Any
43
+
44
+
45
+ def assert_derivs_path(path: str) -> None:
46
+ if not re.fullmatch(r"/v1/[a-z0-9-]+", path):
47
+ raise ValueError(f"not a derivs route path: {path!r} (expected /v1/<name>)")
48
+
49
+
50
+ def derivs_query_value(name: str, value: Any) -> str:
51
+ """A query value as the derivs API reads it: numbers as plain decimals (no exponent, at most 8 decimals)."""
52
+ if isinstance(value, bool):
53
+ return "true" if value else "false"
54
+ if isinstance(value, int):
55
+ return str(value)
56
+ if isinstance(value, float):
57
+ if not math.isfinite(value):
58
+ raise TypeError(f"parameter {name} must be a finite number")
59
+ if value.is_integer():
60
+ return str(int(value))
61
+ return f"{value:.8f}".rstrip("0").rstrip(".")
62
+ if isinstance(value, str):
63
+ return value
64
+ raise TypeError(f"parameter {name} must be a string, number or boolean")
65
+
66
+
67
+ def derivs_query(params: Mapping[str, Any]) -> dict[str, str]:
68
+ return {name: derivs_query_value(name, value) for name, value in params.items() if value is not None}
69
+
70
+
71
+ def _derivs_error(path: str, response: requests.Response, body: Any) -> DuoApiError:
72
+ status = response.status_code
73
+ request_id = body.get("request_id") if isinstance(body, dict) and isinstance(body.get("request_id"), str) else None
74
+ if isinstance(body, dict) and isinstance(body.get("error"), dict):
75
+ error = body["error"]
76
+ return DuoApiError(
77
+ code=str(error.get("code") or "UNKNOWN_ERROR"),
78
+ message=str(error.get("message") or f"{path} failed with HTTP {status}"),
79
+ param=error.get("param") if isinstance(error.get("param"), str) else None,
80
+ expected=error.get("expected") if isinstance(error.get("expected"), str) else None,
81
+ status=status,
82
+ route=path,
83
+ request_id=request_id,
84
+ )
85
+ if status == 402:
86
+ return DuoApiError(code="PAYMENT_REQUIRED", message="the payment was not accepted (HTTP 402)", status=status, route=path, request_id=request_id)
87
+ if 200 <= status < 300:
88
+ return DuoApiError(code="INVALID_RESPONSE", message=f"{path} did not return a JSON object (HTTP {status})", status=status, route=path, request_id=request_id)
89
+ return DuoApiError(code=f"HTTP_{status}", message=f"{path} failed with HTTP {status}", status=status, route=path, request_id=request_id)
90
+
91
+
92
+ class DerivsClient(DerivsMethods):
93
+ """Typed methods for the 10 derivs routes, plus call and call_with_receipt.
94
+
95
+ Pass `account` to pay with your wallet; the client refuses any price above `max_price_usd` (by default
96
+ each route's own listed price, $0.003 or $0.005, and 0.005 for a path it does not know; pass 0.003 to
97
+ refuse the $0.005 routes), any network but Base, any asset but USDC and any payee but `pay_to`, before
98
+ it signs. Pass `session` instead to use a requests.Session that already pays.
99
+ `DuoClient(...).derivs` is the same client, sharing the wallet with the main API.
100
+ """
101
+
102
+ def __init__(
103
+ self,
104
+ account: Any = None,
105
+ *,
106
+ session: requests.Session | None = None,
107
+ base_url: str = DEFAULT_DERIVS_BASE_URL,
108
+ max_price_usd: float | None = None,
109
+ timeout: float | None = None,
110
+ pay_to: str | None = DEFAULT_PAY_TO,
111
+ user_agent: str = USER_AGENT,
112
+ ) -> None:
113
+ if (account is None) == (session is None):
114
+ raise TypeError("pass exactly one of account (pays with your wallet) or session (a requests.Session that already pays)")
115
+ if "\r" in user_agent or "\n" in user_agent:
116
+ raise ValueError("user_agent must not contain line breaks")
117
+ if max_price_usd is not None:
118
+ usd_to_atomic(max_price_usd) # validates it at construction
119
+ self._user_agent = user_agent
120
+ self._base_url = normalise_base_url(base_url)
121
+ self._max_price_usd = max_price_usd
122
+ self._timeout = timeout
123
+ self._account = account
124
+ self._pay_to = pay_to
125
+ self._session = session
126
+ # One paying session per price cap (at most one per distinct route price), built on first use.
127
+ self._sessions: dict[float, requests.Session] = {}
128
+ super().__init__(self.call)
129
+
130
+ def _cap_for(self, path: str) -> float:
131
+ if self._max_price_usd is not None:
132
+ return self._max_price_usd
133
+ return _ROUTE_CAP_USD.get(path, DEFAULT_DERIVS_MAX_PRICE_USD)
134
+
135
+ def _session_for(self, path: str) -> requests.Session:
136
+ if self._session is not None:
137
+ return self._session
138
+ cap = self._cap_for(path)
139
+ if cap not in self._sessions:
140
+ self._sessions[cap] = _paying_session(self._account, usd_to_atomic(cap), cap, self._pay_to)
141
+ return self._sessions[cap]
142
+
143
+ def call(self, path: str, params: Mapping[str, Any] | None = None) -> dict[str, Any]:
144
+ """Calls any derivs route by path (e.g. /v1/funding-rates) and returns its JSON body."""
145
+ return self.call_with_receipt(path, params).data
146
+
147
+ def call_with_receipt(self, path: str, params: Mapping[str, Any] | None = None) -> DerivsPaidResult:
148
+ """Like `call`, and also returns the settlement receipt."""
149
+ assert_derivs_path(path)
150
+ try:
151
+ response = self._session_for(path).get(
152
+ f"{self._base_url}{path}",
153
+ params=derivs_query(params or {}),
154
+ headers={"accept": "application/json", "User-Agent": self._user_agent},
155
+ timeout=self._timeout,
156
+ )
157
+ except PaymentError as error:
158
+ refusal = _price_guard_refusal(error)
159
+ if refusal is not None:
160
+ raise refusal from error
161
+ raise
162
+ body = _parse_json(response)
163
+ if 200 <= response.status_code < 300 and isinstance(body, dict) and "error" not in body:
164
+ return DerivsPaidResult(data=body, receipt=_receipt_of(response))
165
+ raise _derivs_error(path, response, body)