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.
Files changed (28) hide show
  1. duo_data_utilities-0.2.1/LICENSE +21 -0
  2. duo_data_utilities-0.2.1/PKG-INFO +36 -0
  3. duo_data_utilities-0.2.1/README.md +13 -0
  4. duo_data_utilities-0.2.1/duo_data_utilities/__init__.py +37 -0
  5. duo_data_utilities-0.2.1/duo_data_utilities/_http.py +116 -0
  6. duo_data_utilities-0.2.1/duo_data_utilities/_version.py +12 -0
  7. duo_data_utilities-0.2.1/duo_data_utilities/client.py +150 -0
  8. duo_data_utilities-0.2.1/duo_data_utilities/counterparty.py +118 -0
  9. duo_data_utilities-0.2.1/duo_data_utilities/derivs.py +165 -0
  10. duo_data_utilities-0.2.1/duo_data_utilities/derivs_generated.py +154 -0
  11. duo_data_utilities-0.2.1/duo_data_utilities/errors.py +38 -0
  12. duo_data_utilities-0.2.1/duo_data_utilities/generated.py +701 -0
  13. duo_data_utilities-0.2.1/duo_data_utilities/price_guard.py +77 -0
  14. duo_data_utilities-0.2.1/duo_data_utilities/py.typed +0 -0
  15. duo_data_utilities-0.2.1/duo_data_utilities.egg-info/PKG-INFO +36 -0
  16. duo_data_utilities-0.2.1/duo_data_utilities.egg-info/SOURCES.txt +26 -0
  17. duo_data_utilities-0.2.1/duo_data_utilities.egg-info/dependency_links.txt +1 -0
  18. duo_data_utilities-0.2.1/duo_data_utilities.egg-info/requires.txt +4 -0
  19. duo_data_utilities-0.2.1/duo_data_utilities.egg-info/top_level.txt +1 -0
  20. duo_data_utilities-0.2.1/pyproject.toml +40 -0
  21. duo_data_utilities-0.2.1/setup.cfg +4 -0
  22. duo_data_utilities-0.2.1/tests/test_client.py +211 -0
  23. duo_data_utilities-0.2.1/tests/test_counterparty.py +137 -0
  24. duo_data_utilities-0.2.1/tests/test_derivs.py +208 -0
  25. duo_data_utilities-0.2.1/tests/test_examples.py +129 -0
  26. duo_data_utilities-0.2.1/tests/test_price_guard.py +83 -0
  27. duo_data_utilities-0.2.1/tests/test_route_caps.py +144 -0
  28. 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)