diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b24b189 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.8", "3.10", "3.12"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install + run: | + python -m pip install --upgrade pip + pip install ".[test]" + - name: Tests + run: pytest -q + - name: Examples import cleanly + run: | + python -m py_compile examples/*.py + python -c "import infinihash_kyc as k; print(k.__version__)" diff --git a/README.md b/README.md index dcf05fa..b78de56 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,77 @@ -# kyc-python -Python SDK for the Infinihash KYC API +# infinihash-kyc + +Python SDK for the [Infinihash KYC API](https://kyc.infinihash.com): KYC and KYB cases, +sanctions screening, and case reports. + +```bash +pip install "git+https://github.com/Infinihash/kyc-python@v0.1.0" +export INFINIHASH_KYC_KEY=... # your API key +``` + +## Screen a name + +```python +from infinihash_kyc import KYC, ScreeningUnavailable + +kyc = KYC() +try: + r = kyc.screen("Vladimir Putin", country="RU") + print(r["status"], r["totalHits"]) # HIT 6 +except ScreeningUnavailable: + ... # lists were NOT searched -- never treat as clear +``` + +## Onboard a company + +```python +from infinihash_kyc import KYC, is_unscreened + +kyc = KYC() +case = kyc.submissions.create( + entity_type="private_corp", + entity_name="Acme Widgets Ltd", + country="GB", + registration_number="01234567", +) +kyc.submissions.run(case["id"]) +result = kyc.submissions.wait(case["id"]) # complete | review | failed + +if is_unscreened(result): + print("manual review:", result["summary"]["error"]) +else: + print(result["summary"]["action"]) # approve | enhanced_review | manual_review | block_report +pdf = kyc.submissions.export_pdf(case["id"]) +``` + +Runnable versions are in [`examples/`](examples/). + +## Fail-closed by design + +- **`screen()` never returns an unscreened result.** It raises `ScreeningUnavailable` + on HTTP 503 or when the server reports `screened: false`. +- **A case the API could not screen ends in `review` with `summary.error` set.** It + never ends in `complete`. `is_unscreened(case)` tells you which happened. +- **`submissions.create()` rejects unknown fields locally,** because the API would + silently drop them. It also validates `entity_type` and `risk_tier` against the + same lists the API uses. + +## Reference + +| Method | API | +|---|---| +| `submissions.create(entity_type, entity_name, risk_tier="low", **fields)` | `POST /api/kyc` | +| `submissions.run(id)` | `POST /api/kyc/{id}/run` | +| `submissions.get(id)` / `wait(id, timeout=300)` | `GET /api/kyc/{id}` | +| `submissions.list()` | `GET /api/kyc` | +| `submissions.export_pdf(id)` | `GET /api/kyc/{id}/export/pdf` | +| `screen(name, country=None, dob=None)` | `POST /api/kyc/screen` | + +Entity types: `individual, listed_public, wholly_owned_sub, pension_erisa, governmental, +nonprofit, trust, spv, complex, private_corp`. The full schema is the +[OpenAPI spec](https://kyc-api.infinihash.com/api/v1/openapi.json). + +Errors: any non-2xx raises `KYCError(status, message, body)`. + +## License + +MIT diff --git a/examples/onboard_company.py b/examples/onboard_company.py new file mode 100644 index 0000000..fd0fd7a --- /dev/null +++ b/examples/onboard_company.py @@ -0,0 +1,30 @@ +"""Onboard a company end to end: create the case, run the checks, act on the decision. + + INFINIHASH_KYC_KEY=... python examples/onboard_company.py +""" +from infinihash_kyc import KYC, is_unscreened + +kyc = KYC() + +case = kyc.submissions.create( + entity_type="private_corp", + entity_name="Acme Widgets Ltd", + country="GB", + registration_number="01234567", + address_line1="1 High Street", + city="London", + postal_code="EC1A 1AA", +) +print("created", case["id"], "required checks:", ", ".join(case["requiredChecks"])) + +kyc.submissions.run(case["id"]) +result = kyc.submissions.wait(case["id"], timeout=300) + +summary = result.get("summary") or {} +if is_unscreened(result): + print("NOT SCREENED -- manual review required:", summary["error"]) +else: + print(f"decision: {summary.get('action')} (status {result['status']}, " + f"risk {summary.get('riskScore')} / {summary.get('riskTier')})") + for c in result["checks"]: + print(f" {c['checkType']:<24} {c['status']}") diff --git a/examples/screen_name.py b/examples/screen_name.py new file mode 100644 index 0000000..d081aa7 --- /dev/null +++ b/examples/screen_name.py @@ -0,0 +1,21 @@ +"""Screen one name against the sanctions lists. + + INFINIHASH_KYC_KEY=... python examples/screen_name.py "Vladimir Putin" RU +""" +import sys + +from infinihash_kyc import KYC, ScreeningUnavailable + +name = sys.argv[1] if len(sys.argv) > 1 else "Vladimir Putin" +country = sys.argv[2] if len(sys.argv) > 2 else None + +kyc = KYC() +try: + r = kyc.screen(name, country=country) +except ScreeningUnavailable as e: + # Never treat this as clear: the lists were not searched. + sys.exit(f"NOT SCREENED -- retry later ({e})") + +print(f"{r['name']}: {r['status']} ({r['totalHits']} hit(s))") +for m in r["matches"][:5]: + print(f" - {m.get('list')}: {m.get('name')} (score {m.get('score')})") diff --git a/infinihash_kyc.egg-info/PKG-INFO b/infinihash_kyc.egg-info/PKG-INFO new file mode 100644 index 0000000..a41bde1 --- /dev/null +++ b/infinihash_kyc.egg-info/PKG-INFO @@ -0,0 +1,19 @@ +Metadata-Version: 2.4 +Name: infinihash-kyc +Version: 0.1.0 +Summary: Python SDK for the Infinihash KYC API (KYC/KYB cases, sanctions screening). +Author: Infinihash +License: MIT +Project-URL: Homepage, https://kyc.infinihash.com +Project-URL: API reference, https://kyc-api.infinihash.com/api/v1/openapi.json +Project-URL: Source, https://github.com/Infinihash/kyc-python +Requires-Python: >=3.8 +Description-Content-Type: text/markdown +License-File: LICENSE +Requires-Dist: requests>=2.28 +Provides-Extra: test +Requires-Dist: pytest>=7; extra == "test" +Dynamic: license-file + +# kyc-python +Python SDK for the Infinihash KYC API diff --git a/infinihash_kyc.egg-info/SOURCES.txt b/infinihash_kyc.egg-info/SOURCES.txt new file mode 100644 index 0000000..bd8aca0 --- /dev/null +++ b/infinihash_kyc.egg-info/SOURCES.txt @@ -0,0 +1,11 @@ +LICENSE +README.md +pyproject.toml +infinihash_kyc/__init__.py +infinihash_kyc/client.py +infinihash_kyc.egg-info/PKG-INFO +infinihash_kyc.egg-info/SOURCES.txt +infinihash_kyc.egg-info/dependency_links.txt +infinihash_kyc.egg-info/requires.txt +infinihash_kyc.egg-info/top_level.txt +tests/test_client.py \ No newline at end of file diff --git a/infinihash_kyc.egg-info/dependency_links.txt b/infinihash_kyc.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/infinihash_kyc.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/infinihash_kyc.egg-info/requires.txt b/infinihash_kyc.egg-info/requires.txt new file mode 100644 index 0000000..327a7ce --- /dev/null +++ b/infinihash_kyc.egg-info/requires.txt @@ -0,0 +1,4 @@ +requests>=2.28 + +[test] +pytest>=7 diff --git a/infinihash_kyc.egg-info/top_level.txt b/infinihash_kyc.egg-info/top_level.txt new file mode 100644 index 0000000..90c47fc --- /dev/null +++ b/infinihash_kyc.egg-info/top_level.txt @@ -0,0 +1 @@ +infinihash_kyc diff --git a/infinihash_kyc/__init__.py b/infinihash_kyc/__init__.py new file mode 100644 index 0000000..7433b74 --- /dev/null +++ b/infinihash_kyc/__init__.py @@ -0,0 +1,5 @@ +"""Infinihash KYC Python SDK. See client.py for the full guide.""" +from .client import KYC, KYCError, ScreeningUnavailable, is_unscreened, ENTITY_TYPES, RISK_TIERS + +__all__ = ["KYC", "KYCError", "ScreeningUnavailable", "is_unscreened", "ENTITY_TYPES", "RISK_TIERS"] +__version__ = "0.1.0" diff --git a/infinihash_kyc/__pycache__/__init__.cpython-311.pyc b/infinihash_kyc/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..b0f302b Binary files /dev/null and b/infinihash_kyc/__pycache__/__init__.cpython-311.pyc differ diff --git a/infinihash_kyc/__pycache__/client.cpython-311.pyc b/infinihash_kyc/__pycache__/client.cpython-311.pyc new file mode 100644 index 0000000..4769726 Binary files /dev/null and b/infinihash_kyc/__pycache__/client.cpython-311.pyc differ diff --git a/infinihash_kyc/client.py b/infinihash_kyc/client.py new file mode 100644 index 0000000..a3e3366 --- /dev/null +++ b/infinihash_kyc/client.py @@ -0,0 +1,174 @@ +"""Infinihash KYC Python SDK. + +Thin `requests` wrapper around the Infinihash KYC API (https://kyc-api.infinihash.com). +Every call returns the JSON the server sent; errors surface as exceptions. + +Quick start +----------- + from infinihash_kyc import KYC + + kyc = KYC(api_key="...") # or set INFINIHASH_KYC_KEY + + # Onboard a company: create the case, run the checks, wait for the decision + case = kyc.submissions.create( + entity_type="private_corp", + entity_name="Acme Widgets Ltd", + country="GB", + registration_number="01234567", + ) + kyc.submissions.run(case["id"]) + result = kyc.submissions.wait(case["id"]) + print(result["status"], result["summary"]["action"]) + + # Screen one name against the sanctions lists + hit = kyc.screen("Vladimir Putin", country="RU") + print(hit["status"], hit["totalHits"]) + +Fail-closed by design +--------------------- +* `screen()` raises ScreeningUnavailable when the server says it did not screen + (HTTP 503 or `screened: false`). It never returns a result you could mistake + for "clear". +* A case that could not be screened finishes in status `review` with + `summary.error` set. `wait()` returns it; `is_unscreened()` tells you. + +Errors +------ +Non-2xx responses raise KYCError(status, message, body). +""" +from __future__ import annotations + +import os +import time +from typing import Any, Dict, List, Optional + +import requests + +DEFAULT_BASE_URL = "https://kyc-api.infinihash.com" +DEFAULT_TIMEOUT = 30 + +ENTITY_TYPES = [ + "individual", "listed_public", "wholly_owned_sub", "pension_erisa", "governmental", + "nonprofit", "trust", "spv", "complex", "private_corp", +] +RISK_TIERS = ["low", "medium", "high"] +TERMINAL_STATUSES = {"complete", "review", "failed"} + +# snake_case keyword -> API field. Anything not listed is rejected locally, +# because the server silently drops unknown fields. +_FIELDS = { + "lei": "lei", "crd": "crd", "jurisdiction": "jurisdiction", "ticker": "ticker", + "country": "country", "email": "email", "naics_code": "naicsCode", + "business_description": "businessDescription", "registration_number": "registrationNumber", + "incorporation_date": "incorporationDate", "principal_place_of_business": "principalPlaceOfBusiness", + "dob": "dob", "nationality": "nationality", "smart_import": "smartImport", "source_doc": "sourceDoc", + "address_line1": "addressLine1", "address_line2": "addressLine2", "city": "city", + "region": "region", "postal_code": "postalCode", + "officers": "officers", "owners": "owners", "signatory": "signatory", +} + + +class KYCError(Exception): + """Any non-2xx response. Attributes: status (int), body (dict | str | None).""" + + def __init__(self, status: int, message: str, body: Any = None): + super().__init__(f"HTTP {status}: {message}") + self.status = status + self.body = body + + +class ScreeningUnavailable(KYCError): + """The server did not screen the name. Never treat this as a clear result.""" + + +class _Submissions: + def __init__(self, client: "KYC"): + self._c = client + + def create(self, entity_type: str, entity_name: str, risk_tier: str = "low", **fields: Any) -> Dict[str, Any]: + """POST /api/kyc. Returns {id, status, requiredChecks, _links, ...}.""" + if entity_type not in ENTITY_TYPES: + raise ValueError(f"entity_type must be one of {ENTITY_TYPES}") + if risk_tier not in RISK_TIERS: + raise ValueError(f"risk_tier must be one of {RISK_TIERS}") + unknown = sorted(set(fields) - set(_FIELDS)) + if unknown: + raise ValueError(f"unknown field(s) {unknown}; the API would silently drop them") + body = {"entityType": entity_type, "entityName": entity_name, "riskTier": risk_tier} + body.update({_FIELDS[k]: v for k, v in fields.items() if v is not None}) + return self._c._request("POST", "/api/kyc", json=body) + + def run(self, submission_id: str) -> Dict[str, Any]: + """POST /api/kyc/{id}/run. Starts the checks (asynchronous).""" + return self._c._request("POST", f"/api/kyc/{submission_id}/run") + + def get(self, submission_id: str) -> Dict[str, Any]: + """GET /api/kyc/{id}. Status, checks and (once finished) the summary.""" + return self._c._request("GET", f"/api/kyc/{submission_id}") + + def list(self) -> List[Dict[str, Any]]: + """GET /api/kyc. Your submissions, newest first.""" + return self._c._request("GET", "/api/kyc") + + def wait(self, submission_id: str, timeout: float = 300, poll: float = 3) -> Dict[str, Any]: + """Poll until the case reaches complete / review / failed, or raise TimeoutError.""" + deadline = time.monotonic() + timeout + while True: + sub = self.get(submission_id) + if sub.get("status") in TERMINAL_STATUSES: + return sub + if time.monotonic() >= deadline: + raise TimeoutError(f"submission {submission_id} still {sub.get('status')!r} after {timeout}s") + time.sleep(poll) + + def export_pdf(self, submission_id: str) -> bytes: + """GET /api/kyc/{id}/export/pdf. The case report as PDF bytes.""" + return self._c._request("GET", f"/api/kyc/{submission_id}/export/pdf", raw=True) + + +def is_unscreened(submission: Dict[str, Any]) -> bool: + """True when the case finished without being screened (fail-closed review).""" + return bool(((submission or {}).get("summary") or {}).get("error")) + + +class KYC: + def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None, + timeout: float = DEFAULT_TIMEOUT, session: Optional[requests.Session] = None): + self.api_key = api_key or os.environ.get("INFINIHASH_KYC_KEY") + if not self.api_key: + raise ValueError("api_key is required (or set INFINIHASH_KYC_KEY)") + self.base_url = (base_url or os.environ.get("INFINIHASH_KYC_URL") or DEFAULT_BASE_URL).rstrip("/") + self.timeout = timeout + self._s = session or requests.Session() + self._s.headers.update({"X-API-Key": self.api_key, "User-Agent": "infinihash-kyc-python/0.1.0"}) + self.submissions = _Submissions(self) + + def screen(self, name: str, country: Optional[str] = None, dob: Optional[str] = None) -> Dict[str, Any]: + """POST /api/kyc/screen. Raises ScreeningUnavailable if the name was not screened.""" + body = {"name": name, **({"country": country} if country else {}), **({"dob": dob} if dob else {})} + try: + out = self._request("POST", "/api/kyc/screen", json=body) + except KYCError as e: + if e.status == 503: + raise ScreeningUnavailable(e.status, "screening unavailable -- NOT a clear result", e.body) from None + raise + if out.get("screened") is False or out.get("status") == "ERROR": + raise ScreeningUnavailable(200, "server reports the name was not screened", out) + return out + + def health(self) -> Dict[str, Any]: + return self._request("GET", "/health", auth=False) + + def _request(self, method: str, path: str, json: Any = None, raw: bool = False, auth: bool = True): + headers = None if auth else {"X-API-Key": None} + r = self._s.request(method, self.base_url + path, json=json, timeout=self.timeout, headers=headers) + if not 200 <= r.status_code < 300: + try: + body = r.json() + msg = body.get("error") if isinstance(body, dict) else str(body) + except ValueError: + body, msg = r.text, r.text[:200] + raise KYCError(r.status_code, msg or r.reason, body) + if raw: + return r.content + return r.json() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..8456ce5 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,24 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "infinihash-kyc" +version = "0.1.0" +description = "Python SDK for the Infinihash KYC API (KYC/KYB cases, sanctions screening)." +readme = "README.md" +requires-python = ">=3.8" +license = { text = "MIT" } +authors = [{ name = "Infinihash" }] +dependencies = ["requests>=2.28"] + +[project.optional-dependencies] +test = ["pytest>=7"] + +[project.urls] +Homepage = "https://kyc.infinihash.com" +"API reference" = "https://kyc-api.infinihash.com/api/v1/openapi.json" +Source = "https://github.com/Infinihash/kyc-python" + +[tool.setuptools.packages.find] +include = ["infinihash_kyc*"] diff --git a/tests/__pycache__/test_client.cpython-311-pytest-9.1.1.pyc b/tests/__pycache__/test_client.cpython-311-pytest-9.1.1.pyc new file mode 100644 index 0000000..0a14978 Binary files /dev/null and b/tests/__pycache__/test_client.cpython-311-pytest-9.1.1.pyc differ diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..95528ef --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,148 @@ +"""SDK behaviour against a local stub that speaks the API's documented shapes +(KYC-API openapi.json components: CreateSubmission, SubmissionCreated, +RunStarted, Submission, ScreenResult). No network.""" +import json +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer + +import pytest + +from infinihash_kyc import KYC, KYCError, ScreeningUnavailable, is_unscreened + +STATE = {} + + +class Stub(BaseHTTPRequestHandler): + def log_message(self, *a): + pass + + def _send(self, code, body, ctype="application/json"): + data = body if isinstance(body, bytes) else json.dumps(body).encode() + self.send_response(code) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def _body(self): + n = int(self.headers.get("Content-Length") or 0) + return json.loads(self.rfile.read(n) or b"{}") + + def do_GET(self): + STATE.setdefault("keys", []).append(self.headers.get("X-API-Key")) + if self.path == "/api/kyc/sub_1": + STATE["polls"] = STATE.get("polls", 0) + 1 + if STATE["polls"] < 3: + return self._send(200, {"id": "sub_1", "status": "running", "summary": None, "checks": []}) + return self._send(200, STATE.get("final") or { + "id": "sub_1", "status": "complete", "riskScore": 12, + "summary": {"action": "approve", "riskScore": 12, "riskTier": "low"}, + "checks": [{"checkType": "sanctions_ofac", "status": "pass"}]}) + if self.path == "/api/kyc": + return self._send(200, [{"id": "sub_1", "status": "complete"}]) + if self.path == "/api/kyc/sub_1/export/pdf": + return self._send(200, b"%PDF-1.7 stub", "application/pdf") + if self.path == "/api/kyc/missing": + return self._send(404, {"error": "Submission not found"}) + return self._send(404, {"error": "no route"}) + + def do_POST(self): + STATE.setdefault("keys", []).append(self.headers.get("X-API-Key")) + b = self._body() + if self.path == "/api/kyc": + STATE["created"] = b + if b.get("entityName", "").strip() == "x": + return self._send(400, {"error": "entityName must be 2–300 characters"}) + return self._send(201, {"id": "sub_1", "status": "pending", "entityType": b["entityType"], + "requiredChecks": ["sanctions_ofac", "pep"], "_links": {}}) + if self.path == "/api/kyc/sub_1/run": + return self._send(200, {"message": "Checks started", "submissionId": "sub_1"}) + if self.path == "/api/kyc/screen": + if b["name"] == "down": + return self._send(503, {"name": "down", "status": "ERROR", "screened": False, "matches": []}) + if b["name"] == "soft-fail": + return self._send(200, {"name": "soft-fail", "status": "PARTIAL", "screened": False, "matches": []}) + hit = b["name"].lower().startswith("vladimir") + return self._send(200, {"name": b["name"], "status": "HIT" if hit else "CLEAR", "screened": True, + "totalHits": 1 if hit else 0, + "matches": [{"list": "OFAC SDN", "name": "PUTIN, Vladimir", "score": 0.97}] if hit else []}) + return self._send(404, {"error": "no route"}) + + +@pytest.fixture +def kyc(): + STATE.clear() + srv = HTTPServer(("127.0.0.1", 0), Stub) + threading.Thread(target=srv.serve_forever, daemon=True).start() + yield KYC(api_key="test-key", base_url=f"http://127.0.0.1:{srv.server_port}") + srv.shutdown() + + +def test_create_maps_snake_case_fields_and_sends_the_key(kyc): + out = kyc.submissions.create("private_corp", "Acme Widgets Ltd", country="GB", + registration_number="01234567", postal_code="EC1A 1AA") + assert out["id"] == "sub_1" + assert STATE["created"] == {"entityType": "private_corp", "entityName": "Acme Widgets Ltd", "riskTier": "low", + "country": "GB", "registrationNumber": "01234567", "postalCode": "EC1A 1AA"} + assert STATE["keys"] == ["test-key"] + + +def test_unknown_fields_and_bad_enums_are_rejected_before_the_request(kyc): + with pytest.raises(ValueError, match="silently drop"): + kyc.submissions.create("private_corp", "Acme", company_number="1") + with pytest.raises(ValueError, match="entity_type"): + kyc.submissions.create("business", "Acme") + with pytest.raises(ValueError, match="risk_tier"): + kyc.submissions.create("individual", "Jane Doe", risk_tier="extreme") + assert "created" not in STATE + + +def test_server_validation_error_is_a_kycerror_with_the_message(kyc): + with pytest.raises(KYCError) as e: + kyc.submissions.create("individual", "x") + assert e.value.status == 400 and "2–300" in str(e.value) + + +def test_run_then_wait_returns_the_finished_case(kyc): + kyc.submissions.run("sub_1") + res = kyc.submissions.wait("sub_1", poll=0.01) + assert res["status"] == "complete" and res["summary"]["action"] == "approve" + assert STATE["polls"] == 3 + assert not is_unscreened(res) + + +def test_wait_returns_a_fail_closed_review_and_flags_it(kyc): + STATE["final"] = {"id": "sub_1", "status": "review", + "summary": {"action": "manual_review", "error": "checklist_unresolved"}, "checks": []} + res = kyc.submissions.wait("sub_1", poll=0.01) + assert res["status"] == "review" and is_unscreened(res) + + +def test_wait_times_out(kyc): + with pytest.raises(TimeoutError): + kyc.submissions.wait("sub_1", timeout=0, poll=0) + + +def test_screen_hit_and_clear(kyc): + assert kyc.screen("Vladimir Putin", country="RU")["status"] == "HIT" + assert kyc.screen("Jane Q Public")["status"] == "CLEAR" + + +@pytest.mark.parametrize("name", ["down", "soft-fail"]) +def test_screen_never_returns_an_unscreened_result(kyc, name): + with pytest.raises(ScreeningUnavailable): + kyc.screen(name) + + +def test_not_found_list_and_pdf(kyc): + with pytest.raises(KYCError) as e: + kyc.submissions.get("missing") + assert e.value.status == 404 + assert kyc.submissions.list()[0]["id"] == "sub_1" + assert kyc.submissions.export_pdf("sub_1").startswith(b"%PDF") + + +def test_api_key_is_required(monkeypatch): + monkeypatch.delenv("INFINIHASH_KYC_KEY", raising=False) + with pytest.raises(ValueError): + KYC()