From 00f327ebf4a30e4e52db7dd06341e3153fed1c30 Mon Sep 17 00:00:00 2001 From: TheDude4224 Date: Tue, 6 Oct 2026 11:54:55 -0400 Subject: [PATCH] infinihash-kyc 0.1.0: Python SDK for the KYC API Submissions (create/run/get/wait/list/export_pdf) and sanctions screen, fail-closed (ScreeningUnavailable on 503 or screened:false; is_unscreened for review-with-error cases), local validation of entity types, risk tiers and fields the API would silently drop. 11 tests against a stub of the documented shapes; screen example verified live against production (HIT 6 / CLEAR 0). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 32 ++++ README.md | 79 +++++++- examples/onboard_company.py | 30 +++ examples/screen_name.py | 21 +++ infinihash_kyc.egg-info/PKG-INFO | 19 ++ infinihash_kyc.egg-info/SOURCES.txt | 11 ++ infinihash_kyc.egg-info/dependency_links.txt | 1 + infinihash_kyc.egg-info/requires.txt | 4 + infinihash_kyc.egg-info/top_level.txt | 1 + infinihash_kyc/__init__.py | 5 + .../__pycache__/__init__.cpython-311.pyc | Bin 0 -> 521 bytes .../__pycache__/client.cpython-311.pyc | Bin 0 -> 12606 bytes infinihash_kyc/client.py | 174 ++++++++++++++++++ pyproject.toml | 24 +++ .../test_client.cpython-311-pytest-9.1.1.pyc | Bin 0 -> 25201 bytes tests/test_client.py | 148 +++++++++++++++ 16 files changed, 547 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 examples/onboard_company.py create mode 100644 examples/screen_name.py create mode 100644 infinihash_kyc.egg-info/PKG-INFO create mode 100644 infinihash_kyc.egg-info/SOURCES.txt create mode 100644 infinihash_kyc.egg-info/dependency_links.txt create mode 100644 infinihash_kyc.egg-info/requires.txt create mode 100644 infinihash_kyc.egg-info/top_level.txt create mode 100644 infinihash_kyc/__init__.py create mode 100644 infinihash_kyc/__pycache__/__init__.cpython-311.pyc create mode 100644 infinihash_kyc/__pycache__/client.cpython-311.pyc create mode 100644 infinihash_kyc/client.py create mode 100644 pyproject.toml create mode 100644 tests/__pycache__/test_client.cpython-311-pytest-9.1.1.pyc create mode 100644 tests/test_client.py 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 0000000000000000000000000000000000000000..b0f302bcedeb6af8e5ae7d012adc31620b3f1cdf GIT binary patch literal 521 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 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 0000000000000000000000000000000000000000..0a14978809510d7ed17829e5358a2c26aeb05d0b GIT binary patch literal 25201 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 literal 0 HcmV?d00001 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()