duo-data-utilities 0.2.1__tar.gz
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.
- duo_data_utilities-0.2.1/LICENSE +21 -0
- duo_data_utilities-0.2.1/PKG-INFO +36 -0
- duo_data_utilities-0.2.1/README.md +13 -0
- duo_data_utilities-0.2.1/duo_data_utilities/__init__.py +37 -0
- duo_data_utilities-0.2.1/duo_data_utilities/_http.py +116 -0
- duo_data_utilities-0.2.1/duo_data_utilities/_version.py +12 -0
- duo_data_utilities-0.2.1/duo_data_utilities/client.py +150 -0
- duo_data_utilities-0.2.1/duo_data_utilities/counterparty.py +118 -0
- duo_data_utilities-0.2.1/duo_data_utilities/derivs.py +165 -0
- duo_data_utilities-0.2.1/duo_data_utilities/derivs_generated.py +154 -0
- duo_data_utilities-0.2.1/duo_data_utilities/errors.py +38 -0
- duo_data_utilities-0.2.1/duo_data_utilities/generated.py +701 -0
- duo_data_utilities-0.2.1/duo_data_utilities/price_guard.py +77 -0
- duo_data_utilities-0.2.1/duo_data_utilities/py.typed +0 -0
- duo_data_utilities-0.2.1/duo_data_utilities.egg-info/PKG-INFO +36 -0
- duo_data_utilities-0.2.1/duo_data_utilities.egg-info/SOURCES.txt +26 -0
- duo_data_utilities-0.2.1/duo_data_utilities.egg-info/dependency_links.txt +1 -0
- duo_data_utilities-0.2.1/duo_data_utilities.egg-info/requires.txt +4 -0
- duo_data_utilities-0.2.1/duo_data_utilities.egg-info/top_level.txt +1 -0
- duo_data_utilities-0.2.1/pyproject.toml +40 -0
- duo_data_utilities-0.2.1/setup.cfg +4 -0
- duo_data_utilities-0.2.1/tests/test_client.py +211 -0
- duo_data_utilities-0.2.1/tests/test_counterparty.py +137 -0
- duo_data_utilities-0.2.1/tests/test_derivs.py +208 -0
- duo_data_utilities-0.2.1/tests/test_examples.py +129 -0
- duo_data_utilities-0.2.1/tests/test_price_guard.py +83 -0
- duo_data_utilities-0.2.1/tests/test_route_caps.py +144 -0
- duo_data_utilities-0.2.1/tests/test_user_agent.py +63 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Duo Data Utilities
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: duo-data-utilities
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: IBAN, ISIN, VAT/tax ID and GTIN barcode validation plus 29 more pay-per-call utilities, and crypto derivatives data (perp funding rates, open interest, futures basis, implied volatility, liquidations, order book depth) for AI agents (x402, USDC on Base, no API key).
|
|
5
|
+
Author: Duo Data Utilities
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://api.duoleads.com
|
|
8
|
+
Project-URL: Repository, https://github.com/hiesem/duo-data-utilities
|
|
9
|
+
Keywords: iban,isin,vat,tax-id,gtin,barcode,x402,usdc,ai-agents,pay-per-call,mod-97,crypto-derivatives,perp,funding-rate,funding-arbitrage,open-interest,futures-basis,implied-volatility,liquidations,order-book,hyperliquid,trading-bot
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Typing :: Typed
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: x402[evm,requests]<3,>=2.25.0
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# duo-data-utilities (Python)
|
|
25
|
+
|
|
26
|
+
Typed Python client for https://api.duoleads.com: IBAN, ISIN, VAT/tax ID, GTIN barcode and 29 more pay-per-call utilities. Each call costs $0.05 in USDC on Base, paid with x402 v2. No API key, no signup.
|
|
27
|
+
|
|
28
|
+
Also crypto derivatives data from https://derivs.duoleads.com, $0.003 or $0.005 per call: perp funding rates, funding history and arbitrage, perp screener, open interest, futures basis, implied volatility, liquidations and order book depth across Bybit, Hyperliquid, dYdX, Gate, Kraken, Bitfinex, Deribit and OKX. Use `DuoClient(account).derivs.funding_rates(coin="BTC")` or `DerivsClient(account)`; its price guard caps each route at its own listed price ($0.003 or $0.005) unless you pass `max_price_usd` (`derivs_max_price_usd` on `DuoClient`).
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
pip install "git+https://github.com/hiesem/duo-data-utilities#subdirectory=python"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The package is not published to PyPI. The full documentation, the method table and the price guard are in the repository README.
|
|
35
|
+
|
|
36
|
+
The client sends a User-Agent naming the SDK and version (`duo-data-utilities-py/<version> (+https://github.com/hiesem/duo-data-utilities)`); the API stores only a coarse client class, see https://api.duoleads.com/privacy. Pass `user_agent=` to `DuoClient` to change it.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# duo-data-utilities (Python)
|
|
2
|
+
|
|
3
|
+
Typed Python client for https://api.duoleads.com: IBAN, ISIN, VAT/tax ID, GTIN barcode and 29 more pay-per-call utilities. Each call costs $0.05 in USDC on Base, paid with x402 v2. No API key, no signup.
|
|
4
|
+
|
|
5
|
+
Also crypto derivatives data from https://derivs.duoleads.com, $0.003 or $0.005 per call: perp funding rates, funding history and arbitrage, perp screener, open interest, futures basis, implied volatility, liquidations and order book depth across Bybit, Hyperliquid, dYdX, Gate, Kraken, Bitfinex, Deribit and OKX. Use `DuoClient(account).derivs.funding_rates(coin="BTC")` or `DerivsClient(account)`; its price guard caps each route at its own listed price ($0.003 or $0.005) unless you pass `max_price_usd` (`derivs_max_price_usd` on `DuoClient`).
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pip install "git+https://github.com/hiesem/duo-data-utilities#subdirectory=python"
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The package is not published to PyPI. The full documentation, the method table and the price guard are in the repository README.
|
|
12
|
+
|
|
13
|
+
The client sends a User-Agent naming the SDK and version (`duo-data-utilities-py/<version> (+https://github.com/hiesem/duo-data-utilities)`); the API stores only a coarse client class, see https://api.duoleads.com/privacy. Pass `user_agent=` to `DuoClient` to change it.
|
|
@@ -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)
|