ibanforge 1.1.0__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.
@@ -0,0 +1,18 @@
1
+ node_modules/
2
+ dist/
3
+ .env
4
+ *.log
5
+ .DS_Store
6
+ data/*.sqlite-wal
7
+ data/*.sqlite-shm
8
+ coverage/
9
+ *.tsbuildinfo
10
+ .superpowers/
11
+ # Append to .gitignore
12
+ mcp/node_modules/
13
+ mcp/dist/
14
+
15
+ # Secret tokens generated by mcp-publisher login
16
+ .mcpregistry_*_token
17
+ mcp/.mcpregistry_*_token
18
+ .vercel
@@ -0,0 +1,197 @@
1
+ Metadata-Version: 2.4
2
+ Name: ibanforge
3
+ Version: 1.1.0
4
+ Summary: Official Python SDK for the IBANforge API — IBAN validation, BIC/SWIFT lookup, Swiss BC-Nummer, sanctions/SEPA/VoP compliance triage.
5
+ Project-URL: Homepage, https://ibanforge.com
6
+ Project-URL: Documentation, https://ibanforge.com/docs
7
+ Project-URL: Agent Guide, https://ibanforge.com/agents
8
+ Project-URL: OpenAPI Reference, https://ibanforge.com/openapi
9
+ Project-URL: Repository, https://github.com/cammac-creator/ibanforge
10
+ Project-URL: Issue Tracker, https://github.com/cammac-creator/ibanforge/issues
11
+ Project-URL: Changelog, https://github.com/cammac-creator/ibanforge/blob/main/CHANGELOG.md
12
+ Author-email: IBANforge <support@ibanforge.com>
13
+ License-Expression: MIT
14
+ Keywords: ai-agent,api,banking,bic,compliance,fintech,iban,sanctions,sepa,swift,swiss,validation,vop,x402
15
+ Classifier: Development Status :: 5 - Production/Stable
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: Intended Audience :: Financial and Insurance Industry
18
+ Classifier: License :: OSI Approved :: MIT License
19
+ Classifier: Operating System :: OS Independent
20
+ Classifier: Programming Language :: Python :: 3
21
+ Classifier: Programming Language :: Python :: 3 :: Only
22
+ Classifier: Programming Language :: Python :: 3.9
23
+ Classifier: Programming Language :: Python :: 3.10
24
+ Classifier: Programming Language :: Python :: 3.11
25
+ Classifier: Programming Language :: Python :: 3.12
26
+ Classifier: Programming Language :: Python :: 3.13
27
+ Classifier: Topic :: Internet :: WWW/HTTP
28
+ Classifier: Topic :: Office/Business :: Financial
29
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
30
+ Classifier: Typing :: Typed
31
+ Requires-Python: >=3.9
32
+ Requires-Dist: httpx>=0.24.0
33
+ Provides-Extra: test
34
+ Requires-Dist: pytest-asyncio>=0.21; extra == 'test'
35
+ Requires-Dist: pytest>=7; extra == 'test'
36
+ Requires-Dist: respx>=0.20; extra == 'test'
37
+ Description-Content-Type: text/markdown
38
+
39
+ # IBANforge Python SDK
40
+
41
+ [![PyPI](https://img.shields.io/pypi/v/ibanforge.svg)](https://pypi.org/project/ibanforge/)
42
+ [![Python](https://img.shields.io/pypi/pyversions/ibanforge.svg)](https://pypi.org/project/ibanforge/)
43
+ [![License](https://img.shields.io/pypi/l/ibanforge.svg)](https://pypi.org/project/ibanforge/)
44
+
45
+ Official Python SDK for the [IBANforge API](https://ibanforge.com) — IBAN validation, BIC/SWIFT lookup, Swiss BC-Nummer, and sanctions/SEPA/VoP compliance triage.
46
+
47
+ Built for AI finance agents and fintech developers. Sync + async clients, full type hints, typed exceptions.
48
+
49
+ ## Install
50
+
51
+ ```bash
52
+ pip install ibanforge
53
+ ```
54
+
55
+ ## Get a free API key (1 line, no signup form)
56
+
57
+ ```python
58
+ from ibanforge import IBANforge
59
+
60
+ key = IBANforge.generate_api_key("you@example.com")
61
+ print(key["api_key"]) # ifk_… — store it, it's shown only once
62
+ print(key["monthly_limit"]) # 200 free requests/month
63
+ ```
64
+
65
+ When the monthly quota is exhausted, the API automatically falls back to advertising x402 payment requirements (no dead-end). Your key resumes working at the start of the next month.
66
+
67
+ ## Quick start (sync)
68
+
69
+ ```python
70
+ from ibanforge import IBANforge
71
+
72
+ with IBANforge(api_key="ifk_...") as client:
73
+ out = client.validate_iban("CH9300762011623852957")
74
+
75
+ print(out["valid"]) # True
76
+ print(out["country"]) # {"code": "CH", "name": "Switzerland"}
77
+ print(out["bic"]["bankName"]) # "UBS Switzerland AG"
78
+ print(out["bic"]["lei"]) # "BFM8T61CT2L1QCEMIK50"
79
+ print(out["sepa"]) # {"reachable": True, "instant": True}
80
+ print(out["vop"]["participant"]) # True
81
+ print(out["ch_clearing"]["bc_nummer"]) # "762"
82
+ print(out["risk_score"]) # 5
83
+ ```
84
+
85
+ ## Quick start (async)
86
+
87
+ ```python
88
+ import asyncio
89
+ from ibanforge import AsyncIBANforge
90
+
91
+ async def main():
92
+ async with AsyncIBANforge(api_key="ifk_...") as ibanforge:
93
+ # Fan out 100 validations concurrently
94
+ results = await asyncio.gather(*[
95
+ ibanforge.validate_iban(iban) for iban in ibans
96
+ ])
97
+ valid = sum(1 for r in results if r["valid"])
98
+ print(f"{valid}/{len(results)} valid")
99
+
100
+ asyncio.run(main())
101
+ ```
102
+
103
+ ## All endpoints
104
+
105
+ | Method | Cost | What it does |
106
+ |---|---|---|
107
+ | `format_iban(iban)` | **free** | Pure mod-97 + structure check. Use to pre-filter malformed IBANs before paying. |
108
+ | `validate_iban(iban)` | $0.005 | Full enrichment — BIC, country, EMI/vIBAN flag, SEPA + VoP, risk score, Swiss BC-Nummer for CH/LI |
109
+ | `validate_batch([iban, ...])` | $0.002 / IBAN | Up to 100 IBANs in one call. CSV cleanup, payout list triage. |
110
+ | `lookup_bic(code)` | $0.003 | Resolve BIC/SWIFT into bank name, country, city, LEI, address. 121,197 GLEIF entries. |
111
+ | `lookup_ch_clearing(iid)` | $0.003 | Resolve a Swiss BC-Nummer / IID. The only API with this data. |
112
+ | `check_compliance(iban)` | $0.02 | Pre-flight risk triage: sanctions (OFAC/EU/UN), FATF, SEPA Instant, VoP, risk score 0-100, recommended_action ∈ {allow, review, block} |
113
+ | `usage()` | free | Current month quota usage for your key |
114
+ | `health()` | free | API version, BIC count, uptime |
115
+
116
+ ## Free format check (no key needed)
117
+
118
+ Save money by pre-filtering bad IBANs before paying for enrichment:
119
+
120
+ ```python
121
+ from ibanforge import IBANforge
122
+
123
+ with IBANforge() as client: # no api_key required
124
+ out = client.format_iban("CH9300762011623852957")
125
+ if not out["valid"]:
126
+ print("Skip:", out["error"]) # e.g. "checksum_failed"
127
+ else:
128
+ print(out["bban"]["bank_code"]) # "00762"
129
+ ```
130
+
131
+ ## Compliance triage (the killer feature)
132
+
133
+ ```python
134
+ out = client.check_compliance("RU1234567890123456789012345678") # made-up RU
135
+ print(out["risk_score"]) # high (Russia + FATF flag)
136
+ print(out["recommended_action"]) # "block" | "review" | "allow"
137
+ print(out["sanctions"]["lists"]) # ["OFAC", "EU"] etc.
138
+ print(out["fatf"]["list"]) # "grey" | "black" | "none"
139
+ print(out["sepa"]["reachable"])
140
+ ```
141
+
142
+ ## Error handling
143
+
144
+ The SDK raises typed exceptions — catch the specific class you care about, or the base `IBANforgeError`:
145
+
146
+ ```python
147
+ from ibanforge import (
148
+ IBANforge,
149
+ AuthError, PaymentRequiredError, QuotaExhaustedError,
150
+ RateLimitError, InvalidInputError, APIError, IBANforgeError,
151
+ )
152
+
153
+ with IBANforge(api_key="ifk_...") as client:
154
+ try:
155
+ out = client.validate_iban("not-an-iban")
156
+ except InvalidInputError as e:
157
+ print(e.body["error"]) # "invalid_format"
158
+ except AuthError:
159
+ print("Bad or revoked API key")
160
+ except PaymentRequiredError as e:
161
+ # Your quota is exhausted — switch to x402 to keep going
162
+ print(e.body["accepts"]) # x402 payment requirements
163
+ except RateLimitError:
164
+ print("Slow down")
165
+ except APIError as e:
166
+ print(f"Server error {e.status} — retry with backoff")
167
+ except IBANforgeError as e:
168
+ print(f"Other: {e}")
169
+ ```
170
+
171
+ ## For LLM agents (LangChain, LlamaIndex, CrewAI, AutoGen)
172
+
173
+ The IBANforge API is also available as a native MCP server (`npx -y ibanforge-mcp`) and via x402 micropayments — see the [agent guide](https://ibanforge.com/agents). For Python-first agents, the SDK above is usually enough.
174
+
175
+ ## Configuration
176
+
177
+ ```python
178
+ client = IBANforge(
179
+ api_key="ifk_...",
180
+ base_url="https://api.ibanforge.com", # default
181
+ timeout=30.0, # seconds, default
182
+ user_agent="my-app/1.2", # custom UA
183
+ )
184
+ ```
185
+
186
+ ## Links
187
+
188
+ - API documentation: <https://ibanforge.com/docs>
189
+ - Interactive OpenAPI: <https://ibanforge.com/openapi>
190
+ - Agent guide: <https://ibanforge.com/agents>
191
+ - TypeScript SDK: [`@ibanforge/sdk`](https://www.npmjs.com/package/@ibanforge/sdk)
192
+ - MCP server: [`ibanforge-mcp`](https://www.npmjs.com/package/ibanforge-mcp)
193
+ - Source: <https://github.com/cammac-creator/ibanforge>
194
+
195
+ ## License
196
+
197
+ MIT.
@@ -0,0 +1,159 @@
1
+ # IBANforge Python SDK
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/ibanforge.svg)](https://pypi.org/project/ibanforge/)
4
+ [![Python](https://img.shields.io/pypi/pyversions/ibanforge.svg)](https://pypi.org/project/ibanforge/)
5
+ [![License](https://img.shields.io/pypi/l/ibanforge.svg)](https://pypi.org/project/ibanforge/)
6
+
7
+ Official Python SDK for the [IBANforge API](https://ibanforge.com) — IBAN validation, BIC/SWIFT lookup, Swiss BC-Nummer, and sanctions/SEPA/VoP compliance triage.
8
+
9
+ Built for AI finance agents and fintech developers. Sync + async clients, full type hints, typed exceptions.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pip install ibanforge
15
+ ```
16
+
17
+ ## Get a free API key (1 line, no signup form)
18
+
19
+ ```python
20
+ from ibanforge import IBANforge
21
+
22
+ key = IBANforge.generate_api_key("you@example.com")
23
+ print(key["api_key"]) # ifk_… — store it, it's shown only once
24
+ print(key["monthly_limit"]) # 200 free requests/month
25
+ ```
26
+
27
+ When the monthly quota is exhausted, the API automatically falls back to advertising x402 payment requirements (no dead-end). Your key resumes working at the start of the next month.
28
+
29
+ ## Quick start (sync)
30
+
31
+ ```python
32
+ from ibanforge import IBANforge
33
+
34
+ with IBANforge(api_key="ifk_...") as client:
35
+ out = client.validate_iban("CH9300762011623852957")
36
+
37
+ print(out["valid"]) # True
38
+ print(out["country"]) # {"code": "CH", "name": "Switzerland"}
39
+ print(out["bic"]["bankName"]) # "UBS Switzerland AG"
40
+ print(out["bic"]["lei"]) # "BFM8T61CT2L1QCEMIK50"
41
+ print(out["sepa"]) # {"reachable": True, "instant": True}
42
+ print(out["vop"]["participant"]) # True
43
+ print(out["ch_clearing"]["bc_nummer"]) # "762"
44
+ print(out["risk_score"]) # 5
45
+ ```
46
+
47
+ ## Quick start (async)
48
+
49
+ ```python
50
+ import asyncio
51
+ from ibanforge import AsyncIBANforge
52
+
53
+ async def main():
54
+ async with AsyncIBANforge(api_key="ifk_...") as ibanforge:
55
+ # Fan out 100 validations concurrently
56
+ results = await asyncio.gather(*[
57
+ ibanforge.validate_iban(iban) for iban in ibans
58
+ ])
59
+ valid = sum(1 for r in results if r["valid"])
60
+ print(f"{valid}/{len(results)} valid")
61
+
62
+ asyncio.run(main())
63
+ ```
64
+
65
+ ## All endpoints
66
+
67
+ | Method | Cost | What it does |
68
+ |---|---|---|
69
+ | `format_iban(iban)` | **free** | Pure mod-97 + structure check. Use to pre-filter malformed IBANs before paying. |
70
+ | `validate_iban(iban)` | $0.005 | Full enrichment — BIC, country, EMI/vIBAN flag, SEPA + VoP, risk score, Swiss BC-Nummer for CH/LI |
71
+ | `validate_batch([iban, ...])` | $0.002 / IBAN | Up to 100 IBANs in one call. CSV cleanup, payout list triage. |
72
+ | `lookup_bic(code)` | $0.003 | Resolve BIC/SWIFT into bank name, country, city, LEI, address. 121,197 GLEIF entries. |
73
+ | `lookup_ch_clearing(iid)` | $0.003 | Resolve a Swiss BC-Nummer / IID. The only API with this data. |
74
+ | `check_compliance(iban)` | $0.02 | Pre-flight risk triage: sanctions (OFAC/EU/UN), FATF, SEPA Instant, VoP, risk score 0-100, recommended_action ∈ {allow, review, block} |
75
+ | `usage()` | free | Current month quota usage for your key |
76
+ | `health()` | free | API version, BIC count, uptime |
77
+
78
+ ## Free format check (no key needed)
79
+
80
+ Save money by pre-filtering bad IBANs before paying for enrichment:
81
+
82
+ ```python
83
+ from ibanforge import IBANforge
84
+
85
+ with IBANforge() as client: # no api_key required
86
+ out = client.format_iban("CH9300762011623852957")
87
+ if not out["valid"]:
88
+ print("Skip:", out["error"]) # e.g. "checksum_failed"
89
+ else:
90
+ print(out["bban"]["bank_code"]) # "00762"
91
+ ```
92
+
93
+ ## Compliance triage (the killer feature)
94
+
95
+ ```python
96
+ out = client.check_compliance("RU1234567890123456789012345678") # made-up RU
97
+ print(out["risk_score"]) # high (Russia + FATF flag)
98
+ print(out["recommended_action"]) # "block" | "review" | "allow"
99
+ print(out["sanctions"]["lists"]) # ["OFAC", "EU"] etc.
100
+ print(out["fatf"]["list"]) # "grey" | "black" | "none"
101
+ print(out["sepa"]["reachable"])
102
+ ```
103
+
104
+ ## Error handling
105
+
106
+ The SDK raises typed exceptions — catch the specific class you care about, or the base `IBANforgeError`:
107
+
108
+ ```python
109
+ from ibanforge import (
110
+ IBANforge,
111
+ AuthError, PaymentRequiredError, QuotaExhaustedError,
112
+ RateLimitError, InvalidInputError, APIError, IBANforgeError,
113
+ )
114
+
115
+ with IBANforge(api_key="ifk_...") as client:
116
+ try:
117
+ out = client.validate_iban("not-an-iban")
118
+ except InvalidInputError as e:
119
+ print(e.body["error"]) # "invalid_format"
120
+ except AuthError:
121
+ print("Bad or revoked API key")
122
+ except PaymentRequiredError as e:
123
+ # Your quota is exhausted — switch to x402 to keep going
124
+ print(e.body["accepts"]) # x402 payment requirements
125
+ except RateLimitError:
126
+ print("Slow down")
127
+ except APIError as e:
128
+ print(f"Server error {e.status} — retry with backoff")
129
+ except IBANforgeError as e:
130
+ print(f"Other: {e}")
131
+ ```
132
+
133
+ ## For LLM agents (LangChain, LlamaIndex, CrewAI, AutoGen)
134
+
135
+ The IBANforge API is also available as a native MCP server (`npx -y ibanforge-mcp`) and via x402 micropayments — see the [agent guide](https://ibanforge.com/agents). For Python-first agents, the SDK above is usually enough.
136
+
137
+ ## Configuration
138
+
139
+ ```python
140
+ client = IBANforge(
141
+ api_key="ifk_...",
142
+ base_url="https://api.ibanforge.com", # default
143
+ timeout=30.0, # seconds, default
144
+ user_agent="my-app/1.2", # custom UA
145
+ )
146
+ ```
147
+
148
+ ## Links
149
+
150
+ - API documentation: <https://ibanforge.com/docs>
151
+ - Interactive OpenAPI: <https://ibanforge.com/openapi>
152
+ - Agent guide: <https://ibanforge.com/agents>
153
+ - TypeScript SDK: [`@ibanforge/sdk`](https://www.npmjs.com/package/@ibanforge/sdk)
154
+ - MCP server: [`ibanforge-mcp`](https://www.npmjs.com/package/ibanforge-mcp)
155
+ - Source: <https://github.com/cammac-creator/ibanforge>
156
+
157
+ ## License
158
+
159
+ MIT.
@@ -0,0 +1,46 @@
1
+ """IBANforge Python SDK.
2
+
3
+ Official client for the IBANforge REST API — IBAN validation, BIC/SWIFT
4
+ lookup, Swiss BC-Nummer, compliance triage. Made for AI finance agents and
5
+ fintech developers.
6
+
7
+ Quick start:
8
+
9
+ pip install ibanforge
10
+
11
+ from ibanforge import IBANforge
12
+ client = IBANforge(api_key="ifk_...")
13
+ out = client.validate_iban("CH9300762011623852957")
14
+ print(out["valid"], out.get("bic", {}).get("bankName"))
15
+
16
+ For asyncio (FastAPI, langchain async, fan-out concurrency):
17
+
18
+ from ibanforge import AsyncIBANforge
19
+ async with AsyncIBANforge(api_key="ifk_...") as client:
20
+ out = await client.validate_iban("...")
21
+ """
22
+
23
+ from .async_client import AsyncIBANforge
24
+ from .client import IBANforge
25
+ from .exceptions import (
26
+ APIError,
27
+ AuthError,
28
+ IBANforgeError,
29
+ InvalidInputError,
30
+ PaymentRequiredError,
31
+ QuotaExhaustedError,
32
+ RateLimitError,
33
+ )
34
+
35
+ __all__ = [
36
+ "IBANforge",
37
+ "AsyncIBANforge",
38
+ "IBANforgeError",
39
+ "AuthError",
40
+ "PaymentRequiredError",
41
+ "QuotaExhaustedError",
42
+ "RateLimitError",
43
+ "InvalidInputError",
44
+ "APIError",
45
+ ]
46
+ __version__ = "1.1.0"
@@ -0,0 +1,174 @@
1
+ """Asynchronous IBANforge API client (asyncio-friendly).
2
+
3
+ Mirrors the sync `IBANforge` API one-for-one but returns awaitables and uses
4
+ `httpx.AsyncClient` under the hood — pick this when you're calling the API
5
+ from inside an async framework (FastAPI, aiohttp, langchain async tools, etc.)
6
+ or when you need to fan-out 100s of validations concurrently.
7
+
8
+ Usage:
9
+
10
+ import asyncio
11
+ from ibanforge import AsyncIBANforge
12
+
13
+ async def main():
14
+ async with AsyncIBANforge(api_key="ifk_...") as ibanforge:
15
+ out = await ibanforge.validate_iban("CH9300762011623852957")
16
+ print(out["valid"])
17
+
18
+ asyncio.run(main())
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from typing import Any, Iterable, Optional, Union
24
+
25
+ import httpx
26
+
27
+ from .exceptions import (
28
+ APIError,
29
+ AuthError,
30
+ IBANforgeError,
31
+ InvalidInputError,
32
+ PaymentRequiredError,
33
+ QuotaExhaustedError,
34
+ RateLimitError,
35
+ )
36
+ from .types import (
37
+ APIKey,
38
+ APIKeyUsage,
39
+ BICLookupResult,
40
+ CHClearingResult,
41
+ ComplianceResult,
42
+ HealthInfo,
43
+ IBANBatchResult,
44
+ IBANFormatResult,
45
+ IBANValidationResult,
46
+ )
47
+
48
+ DEFAULT_BASE_URL = "https://api.ibanforge.com"
49
+ DEFAULT_TIMEOUT = 30.0
50
+ USER_AGENT = "ibanforge-python/1.1.0"
51
+
52
+
53
+ def _raise_for_status(res: httpx.Response) -> None:
54
+ if res.is_success:
55
+ return
56
+ body: Any
57
+ try:
58
+ body = res.json()
59
+ except Exception:
60
+ body = res.text
61
+ msg_obj = body if isinstance(body, dict) else {}
62
+ msg = (
63
+ msg_obj.get("message")
64
+ or msg_obj.get("error_detail")
65
+ or msg_obj.get("error")
66
+ or res.reason_phrase
67
+ or "Unknown error"
68
+ )
69
+ if res.status_code == 401:
70
+ raise AuthError(msg, status=401, body=body)
71
+ if res.status_code == 402:
72
+ raise PaymentRequiredError(msg, status=402, body=body)
73
+ if res.status_code == 403:
74
+ raise AuthError(msg, status=403, body=body)
75
+ if res.status_code == 429:
76
+ if msg_obj.get("error") == "quota_exceeded":
77
+ raise QuotaExhaustedError(msg, status=429, body=body)
78
+ raise RateLimitError(msg, status=429, body=body)
79
+ if 400 <= res.status_code < 500:
80
+ raise InvalidInputError(msg, status=res.status_code, body=body)
81
+ if res.status_code >= 500:
82
+ raise APIError(msg, status=res.status_code, body=body)
83
+ raise IBANforgeError(msg, status=res.status_code, body=body)
84
+
85
+
86
+ class AsyncIBANforge:
87
+ """Async client for the IBANforge REST API."""
88
+
89
+ def __init__(
90
+ self,
91
+ api_key: Optional[str] = None,
92
+ *,
93
+ base_url: str = DEFAULT_BASE_URL,
94
+ timeout: float = DEFAULT_TIMEOUT,
95
+ user_agent: str = USER_AGENT,
96
+ ) -> None:
97
+ self.base_url = base_url.rstrip("/")
98
+ self.api_key = api_key
99
+ headers = {"User-Agent": user_agent}
100
+ if api_key:
101
+ headers["Authorization"] = f"Bearer {api_key}"
102
+ self._client = httpx.AsyncClient(
103
+ base_url=self.base_url, timeout=timeout, headers=headers
104
+ )
105
+
106
+ async def __aenter__(self) -> "AsyncIBANforge":
107
+ return self
108
+
109
+ async def __aexit__(self, *_: object) -> None:
110
+ await self.aclose()
111
+
112
+ async def aclose(self) -> None:
113
+ await self._client.aclose()
114
+
115
+ async def format_iban(self, iban: str) -> IBANFormatResult:
116
+ res = await self._client.get("/v1/iban/format", params={"iban": iban})
117
+ _raise_for_status(res)
118
+ return res.json()
119
+
120
+ async def validate_iban(self, iban: str) -> IBANValidationResult:
121
+ res = await self._client.post("/v1/iban/validate", json={"iban": iban})
122
+ _raise_for_status(res)
123
+ return res.json()
124
+
125
+ async def validate_batch(self, ibans: Iterable[str]) -> IBANBatchResult:
126
+ ibans_list = list(ibans)
127
+ if not ibans_list:
128
+ raise InvalidInputError("ibans must contain at least one IBAN")
129
+ if len(ibans_list) > 100:
130
+ raise InvalidInputError(
131
+ "ibans must be at most 100 entries (got {})".format(len(ibans_list))
132
+ )
133
+ res = await self._client.post("/v1/iban/batch", json={"ibans": ibans_list})
134
+ _raise_for_status(res)
135
+ return res.json()
136
+
137
+ async def check_compliance(self, iban: str) -> ComplianceResult:
138
+ res = await self._client.post("/v1/iban/compliance", json={"iban": iban})
139
+ _raise_for_status(res)
140
+ return res.json()
141
+
142
+ async def lookup_bic(self, code: str) -> BICLookupResult:
143
+ res = await self._client.get(f"/v1/bic/{code}")
144
+ _raise_for_status(res)
145
+ return res.json()
146
+
147
+ async def lookup_ch_clearing(self, iid: Union[str, int]) -> CHClearingResult:
148
+ res = await self._client.get(f"/v1/ch/clearing/{iid}")
149
+ _raise_for_status(res)
150
+ return res.json()
151
+
152
+ @staticmethod
153
+ async def generate_api_key(
154
+ email: str,
155
+ *,
156
+ base_url: str = DEFAULT_BASE_URL,
157
+ timeout: float = DEFAULT_TIMEOUT,
158
+ ) -> APIKey:
159
+ async with httpx.AsyncClient(base_url=base_url.rstrip("/"), timeout=timeout) as cl:
160
+ res = await cl.post("/v1/keys/generate", json={"email": email})
161
+ _raise_for_status(res)
162
+ return res.json()
163
+
164
+ async def usage(self) -> APIKeyUsage:
165
+ if not self.api_key:
166
+ raise AuthError("usage() requires an API key — pass api_key='ifk_...' to the constructor")
167
+ res = await self._client.get("/v1/keys/usage")
168
+ _raise_for_status(res)
169
+ return res.json()
170
+
171
+ async def health(self) -> HealthInfo:
172
+ res = await self._client.get("/health")
173
+ _raise_for_status(res)
174
+ return res.json()
@@ -0,0 +1,228 @@
1
+ """Synchronous IBANforge API client.
2
+
3
+ Usage:
4
+
5
+ from ibanforge import IBANforge
6
+
7
+ # Free format check (no key needed)
8
+ client = IBANforge()
9
+ out = client.format_iban("CH9300762011623852957")
10
+
11
+ # Authenticated calls (required for paid endpoints unless you go x402)
12
+ client = IBANforge(api_key="ifk_...")
13
+ out = client.validate_iban("CH9300762011623852957")
14
+
15
+ # Generate a free key in 1 line
16
+ key = IBANforge.generate_api_key("you@example.com")
17
+ client = IBANforge(api_key=key["api_key"])
18
+
19
+ The client raises typed exceptions from `ibanforge.exceptions` — catch the
20
+ specific class you care about (PaymentRequiredError, QuotaExhaustedError,
21
+ InvalidInputError, AuthError, RateLimitError, APIError) or the base
22
+ IBANforgeError to catch them all.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from typing import Any, Iterable, Optional, Union
28
+
29
+ import httpx
30
+
31
+ from .exceptions import (
32
+ APIError,
33
+ AuthError,
34
+ IBANforgeError,
35
+ InvalidInputError,
36
+ PaymentRequiredError,
37
+ QuotaExhaustedError,
38
+ RateLimitError,
39
+ )
40
+ from .types import (
41
+ APIKey,
42
+ APIKeyUsage,
43
+ BICLookupResult,
44
+ CHClearingResult,
45
+ ComplianceResult,
46
+ HealthInfo,
47
+ IBANBatchResult,
48
+ IBANFormatResult,
49
+ IBANValidationResult,
50
+ )
51
+
52
+ DEFAULT_BASE_URL = "https://api.ibanforge.com"
53
+ DEFAULT_TIMEOUT = 30.0
54
+ USER_AGENT = "ibanforge-python/1.1.0"
55
+
56
+
57
+ def _raise_for_status(res: httpx.Response) -> None:
58
+ """Map HTTP status codes to typed exceptions."""
59
+ if res.is_success:
60
+ return
61
+
62
+ body: Any
63
+ try:
64
+ body = res.json()
65
+ except Exception:
66
+ body = res.text
67
+
68
+ msg_obj = body if isinstance(body, dict) else {}
69
+ msg = (
70
+ msg_obj.get("message")
71
+ or msg_obj.get("error_detail")
72
+ or msg_obj.get("error")
73
+ or res.reason_phrase
74
+ or "Unknown error"
75
+ )
76
+
77
+ if res.status_code == 401:
78
+ raise AuthError(msg, status=401, body=body)
79
+ if res.status_code == 402:
80
+ raise PaymentRequiredError(msg, status=402, body=body)
81
+ if res.status_code == 403:
82
+ raise AuthError(msg, status=403, body=body)
83
+ if res.status_code == 429:
84
+ # IBANforge usually falls through to 402 instead of 429 when an api-key's
85
+ # quota is exhausted, but we still distinguish here.
86
+ if msg_obj.get("error") == "quota_exceeded":
87
+ raise QuotaExhaustedError(msg, status=429, body=body)
88
+ raise RateLimitError(msg, status=429, body=body)
89
+ if 400 <= res.status_code < 500:
90
+ raise InvalidInputError(msg, status=res.status_code, body=body)
91
+ if res.status_code >= 500:
92
+ raise APIError(msg, status=res.status_code, body=body)
93
+ raise IBANforgeError(msg, status=res.status_code, body=body)
94
+
95
+
96
+ class IBANforge:
97
+ """Synchronous client for the IBANforge REST API."""
98
+
99
+ def __init__(
100
+ self,
101
+ api_key: Optional[str] = None,
102
+ *,
103
+ base_url: str = DEFAULT_BASE_URL,
104
+ timeout: float = DEFAULT_TIMEOUT,
105
+ user_agent: str = USER_AGENT,
106
+ ) -> None:
107
+ self.base_url = base_url.rstrip("/")
108
+ self.api_key = api_key
109
+ headers = {"User-Agent": user_agent}
110
+ if api_key:
111
+ headers["Authorization"] = f"Bearer {api_key}"
112
+ self._client = httpx.Client(base_url=self.base_url, timeout=timeout, headers=headers)
113
+
114
+ # ---- context manager ----
115
+
116
+ def __enter__(self) -> "IBANforge":
117
+ return self
118
+
119
+ def __exit__(self, *_: object) -> None:
120
+ self.close()
121
+
122
+ def close(self) -> None:
123
+ self._client.close()
124
+
125
+ # ---- IBAN ----
126
+
127
+ def format_iban(self, iban: str) -> IBANFormatResult:
128
+ """FREE pre-flight check (mod-97 + structure). No API key required.
129
+
130
+ Use this to filter malformed IBANs before paying for full enrichment.
131
+ Returns valid/invalid + error code + bban breakdown only — no BIC,
132
+ no SEPA, no compliance data.
133
+ """
134
+ res = self._client.get("/v1/iban/format", params={"iban": iban})
135
+ _raise_for_status(res)
136
+ return res.json()
137
+
138
+ def validate_iban(self, iban: str) -> IBANValidationResult:
139
+ """Validate one IBAN with full enrichment ($0.005 / call with API key).
140
+
141
+ Returns BIC, country, EMI/vIBAN classification, SEPA + VoP flags,
142
+ risk score, Swiss BC-Nummer for CH/LI accounts.
143
+ """
144
+ res = self._client.post("/v1/iban/validate", json={"iban": iban})
145
+ _raise_for_status(res)
146
+ return res.json()
147
+
148
+ def validate_batch(self, ibans: Iterable[str]) -> IBANBatchResult:
149
+ """Validate up to 100 IBANs in one call ($0.002 / IBAN with API key)."""
150
+ ibans_list = list(ibans)
151
+ if not ibans_list:
152
+ raise InvalidInputError("ibans must contain at least one IBAN")
153
+ if len(ibans_list) > 100:
154
+ raise InvalidInputError(
155
+ "ibans must be at most 100 entries (got {})".format(len(ibans_list))
156
+ )
157
+ res = self._client.post("/v1/iban/batch", json={"ibans": ibans_list})
158
+ _raise_for_status(res)
159
+ return res.json()
160
+
161
+ def check_compliance(self, iban: str) -> ComplianceResult:
162
+ """Pre-flight compliance triage on an IBAN ($0.02 / call with API key).
163
+
164
+ Returns sanctions screening (OFAC/EU/UN), FATF jurisdiction flag,
165
+ SEPA Instant reachability, VoP participant status, risk score 0-100,
166
+ and a recommended_action of "allow" | "review" | "block".
167
+
168
+ Informational, not a regulated AML/CFT product.
169
+ """
170
+ res = self._client.post("/v1/iban/compliance", json={"iban": iban})
171
+ _raise_for_status(res)
172
+ return res.json()
173
+
174
+ # ---- BIC / SWIFT ----
175
+
176
+ def lookup_bic(self, code: str) -> BICLookupResult:
177
+ """Resolve a BIC/SWIFT code into bank, country, city, LEI ($0.003 / call)."""
178
+ res = self._client.get(f"/v1/bic/{code}")
179
+ _raise_for_status(res)
180
+ return res.json()
181
+
182
+ # ---- Swiss clearing ----
183
+
184
+ def lookup_ch_clearing(self, iid: Union[str, int]) -> CHClearingResult:
185
+ """Resolve a Swiss BC-Nummer / IID into institution data ($0.003 / call).
186
+
187
+ Backed by 1,190 SIX BankMaster entries — the only public API exposing
188
+ this data.
189
+ """
190
+ res = self._client.get(f"/v1/ch/clearing/{iid}")
191
+ _raise_for_status(res)
192
+ return res.json()
193
+
194
+ # ---- API keys ----
195
+
196
+ @staticmethod
197
+ def generate_api_key(
198
+ email: str,
199
+ *,
200
+ base_url: str = DEFAULT_BASE_URL,
201
+ timeout: float = DEFAULT_TIMEOUT,
202
+ ) -> APIKey:
203
+ """Create a free API key (200 requests/month).
204
+
205
+ The key is shown ONCE — store it securely. After the monthly quota the
206
+ IBANforge API falls back to advertising x402 payment requirements; the
207
+ same key continues to work next month.
208
+ """
209
+ with httpx.Client(base_url=base_url.rstrip("/"), timeout=timeout) as cl:
210
+ res = cl.post("/v1/keys/generate", json={"email": email})
211
+ _raise_for_status(res)
212
+ return res.json()
213
+
214
+ def usage(self) -> APIKeyUsage:
215
+ """Get the current month's quota usage for the configured API key."""
216
+ if not self.api_key:
217
+ raise AuthError("usage() requires an API key — pass api_key='ifk_...' to the constructor")
218
+ res = self._client.get("/v1/keys/usage")
219
+ _raise_for_status(res)
220
+ return res.json()
221
+
222
+ # ---- Misc ----
223
+
224
+ def health(self) -> HealthInfo:
225
+ """Public health endpoint — version, BIC count, uptime, basic stats."""
226
+ res = self._client.get("/health")
227
+ _raise_for_status(res)
228
+ return res.json()
@@ -0,0 +1,51 @@
1
+ """Custom exception classes for the IBANforge SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ class IBANforgeError(Exception):
9
+ """Base class for all IBANforge SDK errors."""
10
+
11
+ def __init__(self, message: str, *, status: int | None = None, body: Any | None = None) -> None:
12
+ super().__init__(message)
13
+ self.status = status
14
+ self.body = body
15
+
16
+
17
+ class AuthError(IBANforgeError):
18
+ """API key is missing, invalid, or revoked."""
19
+
20
+
21
+ class PaymentRequiredError(IBANforgeError):
22
+ """402 returned by the API. The agent must either authenticate with an API key
23
+ (Authorization: Bearer ifk_...) or pay per call via the x402 protocol on Base.
24
+
25
+ The 402 body is in `self.body` and includes `accepts` (payment requirements),
26
+ `free_tier` (instructions to obtain a free key), and `x402` (protocol pointers).
27
+ """
28
+
29
+
30
+ class QuotaExhaustedError(IBANforgeError):
31
+ """The API key's monthly quota is exhausted.
32
+
33
+ Note: by default the IBANforge API will fall back to advertising x402 payment
34
+ requirements (HTTP 402) instead of returning 429 — agents that scale beyond
35
+ their quota can keep calling and pay per request. This exception is only
36
+ raised if the server explicitly returns 429.
37
+ """
38
+
39
+
40
+ class RateLimitError(IBANforgeError):
41
+ """Global rate limit exceeded (per-IP)."""
42
+
43
+
44
+ class InvalidInputError(IBANforgeError):
45
+ """4xx returned for an obviously malformed request — bad IBAN length, bad BIC
46
+ format, missing query param, etc. Carries the server's `error_detail` if any.
47
+ """
48
+
49
+
50
+ class APIError(IBANforgeError):
51
+ """5xx — server-side failure. Retry with backoff."""
@@ -0,0 +1,149 @@
1
+ """Typed dicts mirroring the IBANforge REST API response shapes.
2
+
3
+ These are duck-typed (TypedDict, not dataclass) so they accept the raw JSON
4
+ returned by the API without an extra parsing step. If you want strict
5
+ validation, wrap them in pydantic models on your end.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, List, Optional, TypedDict
11
+
12
+
13
+ class Country(TypedDict, total=False):
14
+ code: str
15
+ name: str
16
+
17
+
18
+ class BBAN(TypedDict, total=False):
19
+ bank_code: str
20
+ branch_code: str
21
+ account_number: str
22
+
23
+
24
+ class BIC(TypedDict, total=False):
25
+ bic: str
26
+ bankName: str
27
+ city: str
28
+ lei: str
29
+
30
+
31
+ class Issuer(TypedDict, total=False):
32
+ type: str # "bank" | "emi" | "viban" | "neobank" | "unknown"
33
+ name: str
34
+
35
+
36
+ class SEPA(TypedDict, total=False):
37
+ reachable: bool
38
+ instant: bool
39
+
40
+
41
+ class VoP(TypedDict, total=False):
42
+ participant: bool
43
+
44
+
45
+ class CHClearing(TypedDict, total=False):
46
+ bc_nummer: str
47
+ sic: bool
48
+ qr_iid: bool
49
+
50
+
51
+ class IBANValidationResult(TypedDict, total=False):
52
+ iban: str
53
+ formatted: str
54
+ valid: bool
55
+ country: Country
56
+ check_digits: str
57
+ bban: BBAN
58
+ bic: BIC
59
+ issuer: Issuer
60
+ sepa: SEPA
61
+ vop: VoP
62
+ ch_clearing: CHClearing
63
+ risk_score: float
64
+ cost_usdc: float
65
+ processing_ms: int
66
+ error: str
67
+ error_detail: str
68
+
69
+
70
+ class IBANFormatResult(TypedDict, total=False):
71
+ """Free /v1/iban/format response — pure mod-97 + structure check, no DB lookups."""
72
+
73
+ iban: str
74
+ formatted: str
75
+ valid: bool
76
+ country: Country
77
+ check_digits: str
78
+ bban: BBAN
79
+ error: str
80
+ error_detail: str
81
+ upgrade_to_full_validation: str
82
+
83
+
84
+ class IBANBatchResult(TypedDict, total=False):
85
+ results: List[IBANValidationResult]
86
+ summary: Any
87
+ cost_usdc: float
88
+
89
+
90
+ class BICLookupResult(TypedDict, total=False):
91
+ bic: str
92
+ bic8: str
93
+ bic11: str
94
+ found: bool
95
+ valid_format: bool
96
+ institution: str
97
+ country: Country
98
+ city: str
99
+ lei: str
100
+ address: str
101
+ cost_usdc: float
102
+
103
+
104
+ class CHClearingResult(TypedDict, total=False):
105
+ iid: str
106
+ found: bool
107
+ institution: Any
108
+ participation: Any
109
+ bic: str
110
+ cost_usdc: float
111
+
112
+
113
+ class ComplianceResult(TypedDict, total=False):
114
+ iban: str
115
+ valid: bool
116
+ risk_score: float
117
+ recommended_action: str # "allow" | "review" | "block"
118
+ sanctions: Any
119
+ fatf: Any
120
+ sepa: SEPA
121
+ vop: VoP
122
+ flags: Any
123
+ cost_usdc: float
124
+
125
+
126
+ class APIKey(TypedDict, total=False):
127
+ api_key: str
128
+ key_prefix: str
129
+ email: str
130
+ monthly_limit: int
131
+ message: str
132
+
133
+
134
+ class APIKeyUsage(TypedDict, total=False):
135
+ key_prefix: str
136
+ email: str
137
+ monthly_limit: int
138
+ used_this_month: int
139
+ remaining: int
140
+ month: str
141
+
142
+
143
+ class HealthInfo(TypedDict, total=False):
144
+ status: str
145
+ version: str
146
+ uptime_seconds: float
147
+ bic_database_entries: int
148
+ ch_clearing_entries: int
149
+ stats: Any
@@ -0,0 +1,62 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "ibanforge"
7
+ version = "1.1.0"
8
+ description = "Official Python SDK for the IBANforge API — IBAN validation, BIC/SWIFT lookup, Swiss BC-Nummer, sanctions/SEPA/VoP compliance triage."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.9"
12
+ authors = [{ name = "IBANforge", email = "support@ibanforge.com" }]
13
+ dependencies = ["httpx>=0.24.0"]
14
+ keywords = [
15
+ "iban",
16
+ "bic",
17
+ "swift",
18
+ "validation",
19
+ "banking",
20
+ "api",
21
+ "sepa",
22
+ "vop",
23
+ "fintech",
24
+ "ai-agent",
25
+ "x402",
26
+ "compliance",
27
+ "sanctions",
28
+ "swiss",
29
+ ]
30
+ classifiers = [
31
+ "Development Status :: 5 - Production/Stable",
32
+ "Intended Audience :: Developers",
33
+ "Intended Audience :: Financial and Insurance Industry",
34
+ "License :: OSI Approved :: MIT License",
35
+ "Operating System :: OS Independent",
36
+ "Programming Language :: Python :: 3",
37
+ "Programming Language :: Python :: 3 :: Only",
38
+ "Programming Language :: Python :: 3.9",
39
+ "Programming Language :: Python :: 3.10",
40
+ "Programming Language :: Python :: 3.11",
41
+ "Programming Language :: Python :: 3.12",
42
+ "Programming Language :: Python :: 3.13",
43
+ "Topic :: Internet :: WWW/HTTP",
44
+ "Topic :: Office/Business :: Financial",
45
+ "Topic :: Software Development :: Libraries :: Python Modules",
46
+ "Typing :: Typed",
47
+ ]
48
+
49
+ [project.optional-dependencies]
50
+ test = ["pytest>=7", "pytest-asyncio>=0.21", "respx>=0.20"]
51
+
52
+ [project.urls]
53
+ Homepage = "https://ibanforge.com"
54
+ Documentation = "https://ibanforge.com/docs"
55
+ "Agent Guide" = "https://ibanforge.com/agents"
56
+ "OpenAPI Reference" = "https://ibanforge.com/openapi"
57
+ Repository = "https://github.com/cammac-creator/ibanforge"
58
+ "Issue Tracker" = "https://github.com/cammac-creator/ibanforge/issues"
59
+ Changelog = "https://github.com/cammac-creator/ibanforge/blob/main/CHANGELOG.md"
60
+
61
+ [tool.hatch.build.targets.wheel]
62
+ packages = ["ibanforge"]
File without changes
@@ -0,0 +1,190 @@
1
+ """Unit tests for the sync IBANforge client.
2
+
3
+ Uses respx to mock httpx — no real network calls.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import httpx
9
+ import pytest
10
+ import respx
11
+
12
+ from ibanforge import (
13
+ APIError,
14
+ AsyncIBANforge,
15
+ AuthError,
16
+ IBANforge,
17
+ InvalidInputError,
18
+ PaymentRequiredError,
19
+ QuotaExhaustedError,
20
+ RateLimitError,
21
+ )
22
+
23
+ BASE = "https://api.ibanforge.com"
24
+ SAMPLE_IBAN = "CH9300762011623852957"
25
+
26
+
27
+ @pytest.fixture
28
+ def client() -> IBANforge:
29
+ return IBANforge(api_key="ifk_test_key")
30
+
31
+
32
+ @respx.mock
33
+ def test_format_iban_no_key_required():
34
+ cl = IBANforge() # no api_key
35
+ respx.get(f"{BASE}/v1/iban/format").mock(
36
+ return_value=httpx.Response(200, json={"iban": SAMPLE_IBAN, "valid": True})
37
+ )
38
+ out = cl.format_iban(SAMPLE_IBAN)
39
+ assert out["valid"] is True
40
+ cl.close()
41
+
42
+
43
+ @respx.mock
44
+ def test_validate_iban_authorization_header(client: IBANforge):
45
+ route = respx.post(f"{BASE}/v1/iban/validate").mock(
46
+ return_value=httpx.Response(200, json={"iban": SAMPLE_IBAN, "valid": True})
47
+ )
48
+ out = client.validate_iban(SAMPLE_IBAN)
49
+ assert out["valid"] is True
50
+ sent = route.calls.last.request
51
+ assert sent.headers.get("authorization") == "Bearer ifk_test_key"
52
+
53
+
54
+ @respx.mock
55
+ def test_validate_batch_too_many_raises_local(client: IBANforge):
56
+ too_many = [SAMPLE_IBAN] * 101
57
+ with pytest.raises(InvalidInputError):
58
+ client.validate_batch(too_many)
59
+
60
+
61
+ @respx.mock
62
+ def test_lookup_bic(client: IBANforge):
63
+ respx.get(f"{BASE}/v1/bic/UBSWCHZH80A").mock(
64
+ return_value=httpx.Response(200, json={"bic": "UBSWCHZH80A", "found": True})
65
+ )
66
+ out = client.lookup_bic("UBSWCHZH80A")
67
+ assert out["found"] is True
68
+
69
+
70
+ @respx.mock
71
+ def test_lookup_ch_clearing(client: IBANforge):
72
+ respx.get(f"{BASE}/v1/ch/clearing/762").mock(
73
+ return_value=httpx.Response(200, json={"iid": "00762", "found": True})
74
+ )
75
+ out = client.lookup_ch_clearing(762)
76
+ assert out["found"] is True
77
+
78
+
79
+ @respx.mock
80
+ def test_compliance(client: IBANforge):
81
+ respx.post(f"{BASE}/v1/iban/compliance").mock(
82
+ return_value=httpx.Response(
83
+ 200, json={"iban": SAMPLE_IBAN, "valid": True, "risk_score": 5, "recommended_action": "allow"}
84
+ )
85
+ )
86
+ out = client.check_compliance(SAMPLE_IBAN)
87
+ assert out["recommended_action"] == "allow"
88
+
89
+
90
+ @respx.mock
91
+ def test_402_raises_payment_required(client: IBANforge):
92
+ respx.post(f"{BASE}/v1/iban/validate").mock(
93
+ return_value=httpx.Response(
94
+ 402,
95
+ json={
96
+ "x402Version": 1,
97
+ "error": "payment_required",
98
+ "message": "Pay or use a key",
99
+ "accepts": [{"scheme": "exact", "network": "base"}],
100
+ },
101
+ )
102
+ )
103
+ with pytest.raises(PaymentRequiredError) as exc:
104
+ client.validate_iban(SAMPLE_IBAN)
105
+ assert exc.value.status == 402
106
+ assert exc.value.body["accepts"][0]["network"] == "base"
107
+
108
+
109
+ @respx.mock
110
+ def test_401_raises_auth_error(client: IBANforge):
111
+ respx.post(f"{BASE}/v1/iban/validate").mock(
112
+ return_value=httpx.Response(401, json={"error": "invalid_api_key"})
113
+ )
114
+ with pytest.raises(AuthError):
115
+ client.validate_iban(SAMPLE_IBAN)
116
+
117
+
118
+ @respx.mock
119
+ def test_429_quota_exceeded_distinct_from_rate_limit(client: IBANforge):
120
+ respx.post(f"{BASE}/v1/iban/validate").mock(
121
+ return_value=httpx.Response(429, json={"error": "quota_exceeded", "limit": 200, "used": 200})
122
+ )
123
+ with pytest.raises(QuotaExhaustedError):
124
+ client.validate_iban(SAMPLE_IBAN)
125
+
126
+
127
+ @respx.mock
128
+ def test_429_generic_rate_limit(client: IBANforge):
129
+ respx.post(f"{BASE}/v1/iban/validate").mock(
130
+ return_value=httpx.Response(429, json={"error": "rate_limited"})
131
+ )
132
+ with pytest.raises(RateLimitError):
133
+ client.validate_iban(SAMPLE_IBAN)
134
+
135
+
136
+ @respx.mock
137
+ def test_400_raises_invalid_input(client: IBANforge):
138
+ respx.post(f"{BASE}/v1/iban/validate").mock(
139
+ return_value=httpx.Response(400, json={"error": "invalid_iban", "error_detail": "too short"})
140
+ )
141
+ with pytest.raises(InvalidInputError) as exc:
142
+ client.validate_iban("CH")
143
+ assert "too short" in str(exc.value)
144
+
145
+
146
+ @respx.mock
147
+ def test_500_raises_api_error(client: IBANforge):
148
+ respx.post(f"{BASE}/v1/iban/validate").mock(return_value=httpx.Response(500, text="boom"))
149
+ with pytest.raises(APIError):
150
+ client.validate_iban(SAMPLE_IBAN)
151
+
152
+
153
+ @respx.mock
154
+ def test_generate_api_key_static_method():
155
+ respx.post(f"{BASE}/v1/keys/generate").mock(
156
+ return_value=httpx.Response(
157
+ 200, json={"api_key": "ifk_xxx", "monthly_limit": 200, "email": "a@b.c"}
158
+ )
159
+ )
160
+ out = IBANforge.generate_api_key("a@b.c")
161
+ assert out["api_key"].startswith("ifk_")
162
+
163
+
164
+ def test_usage_without_key_raises():
165
+ cl = IBANforge() # no api_key
166
+ with pytest.raises(AuthError):
167
+ cl.usage()
168
+ cl.close()
169
+
170
+
171
+ @pytest.mark.asyncio
172
+ @respx.mock
173
+ async def test_async_validate_iban():
174
+ respx.post(f"{BASE}/v1/iban/validate").mock(
175
+ return_value=httpx.Response(200, json={"iban": SAMPLE_IBAN, "valid": True})
176
+ )
177
+ async with AsyncIBANforge(api_key="ifk_test_key") as cl:
178
+ out = await cl.validate_iban(SAMPLE_IBAN)
179
+ assert out["valid"] is True
180
+
181
+
182
+ @pytest.mark.asyncio
183
+ @respx.mock
184
+ async def test_async_402():
185
+ respx.post(f"{BASE}/v1/iban/validate").mock(
186
+ return_value=httpx.Response(402, json={"error": "payment_required", "accepts": []})
187
+ )
188
+ async with AsyncIBANforge() as cl:
189
+ with pytest.raises(PaymentRequiredError):
190
+ await cl.validate_iban(SAMPLE_IBAN)