cleanlib-sdk 0.4.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 (33) hide show
  1. cleanlib_sdk-0.4.1/.gitignore +31 -0
  2. cleanlib_sdk-0.4.1/PKG-INFO +102 -0
  3. cleanlib_sdk-0.4.1/README.md +77 -0
  4. cleanlib_sdk-0.4.1/bitbucket-pipelines.yml +55 -0
  5. cleanlib_sdk-0.4.1/cleanlib_sdk/__init__.py +118 -0
  6. cleanlib_sdk-0.4.1/cleanlib_sdk/client.py +236 -0
  7. cleanlib_sdk-0.4.1/cleanlib_sdk/derive_status.py +95 -0
  8. cleanlib_sdk-0.4.1/cleanlib_sdk/errors.py +119 -0
  9. cleanlib_sdk-0.4.1/cleanlib_sdk/http/__init__.py +30 -0
  10. cleanlib_sdk-0.4.1/cleanlib_sdk/http/remediation_client.py +160 -0
  11. cleanlib_sdk-0.4.1/cleanlib_sdk/http/types.py +106 -0
  12. cleanlib_sdk-0.4.1/cleanlib_sdk/reason_codes.py +54 -0
  13. cleanlib_sdk-0.4.1/cleanlib_sdk/schema/__init__.py +15 -0
  14. cleanlib_sdk-0.4.1/cleanlib_sdk/schema/verdict_envelope_v1.py +84 -0
  15. cleanlib_sdk-0.4.1/cleanlib_sdk/transport.py +56 -0
  16. cleanlib_sdk-0.4.1/cleanlib_sdk/types.py +227 -0
  17. cleanlib_sdk-0.4.1/pyproject.toml +51 -0
  18. cleanlib_sdk-0.4.1/tests/__init__.py +0 -0
  19. cleanlib_sdk-0.4.1/tests/fixtures/contract-fixtures/CORPUS-README.md +96 -0
  20. cleanlib_sdk-0.4.1/tests/fixtures/contract-fixtures/EXPECTED.json +50 -0
  21. cleanlib_sdk-0.4.1/tests/fixtures/contract-fixtures/fixtures/verdict-allow-clean.json +7 -0
  22. cleanlib_sdk-0.4.1/tests/fixtures/contract-fixtures/fixtures/verdict-allow-recommended.json +17 -0
  23. cleanlib_sdk-0.4.1/tests/fixtures/contract-fixtures/fixtures/verdict-degraded-stale.json +18 -0
  24. cleanlib_sdk-0.4.1/tests/fixtures/contract-fixtures/fixtures/verdict-deny-exploitation-critical.json +21 -0
  25. cleanlib_sdk-0.4.1/tests/fixtures/contract-fixtures/fixtures/verdict-deny-kev.json +21 -0
  26. cleanlib_sdk-0.4.1/tests/fixtures/contract-fixtures/fixtures/verdict-unreachable.json +16 -0
  27. cleanlib_sdk-0.4.1/tests/fixtures/contract-fixtures/fixtures/verdict-warn-abandoned.json +14 -0
  28. cleanlib_sdk-0.4.1/tests/fixtures/contract-fixtures/fixtures/verdict-warn-has-remediation.json +17 -0
  29. cleanlib_sdk-0.4.1/tests/test_attestation.py +117 -0
  30. cleanlib_sdk-0.4.1/tests/test_client_construct.py +46 -0
  31. cleanlib_sdk-0.4.1/tests/test_error_mapping.py +90 -0
  32. cleanlib_sdk-0.4.1/tests/test_v041_contract_and_clients.py +221 -0
  33. cleanlib_sdk-0.4.1/tests/test_v04_rich_data_and_verbs.py +205 -0
@@ -0,0 +1,31 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *$py.class
4
+
5
+ # Distribution / packaging
6
+ build/
7
+ dist/
8
+ *.egg-info/
9
+ .eggs/
10
+ *.egg
11
+
12
+ # Virtual environments
13
+ .venv/
14
+ venv/
15
+ env/
16
+
17
+ # Tools
18
+ .pytest_cache/
19
+ .ruff_cache/
20
+ .mypy_cache/
21
+ .tox/
22
+ htmlcov/
23
+ .coverage
24
+ .coverage.*
25
+ coverage.xml
26
+
27
+ # IDE
28
+ .vscode/
29
+ .idea/
30
+ *.swp
31
+ .DS_Store
@@ -0,0 +1,102 @@
1
+ Metadata-Version: 2.4
2
+ Name: cleanlib-sdk
3
+ Version: 0.4.1
4
+ Summary: CleanLibrary Python SDK — mirrors cleanlib-client (Rust SDK) HTTP surface + cycle-5 cosign verdict-attestation
5
+ Project-URL: Homepage, https://bitbucket.org/triamsec/cleanlib-sdk-py
6
+ Project-URL: Source, https://bitbucket.org/triamsec/cleanlib-sdk-py
7
+ Project-URL: Documentation, https://bitbucket.org/triamsec/cleanlib/src/main/workstream-specs/05-app/sdk-substrate-rev1.md
8
+ Author: CleanStart
9
+ License: Proprietary
10
+ Classifier: Development Status :: 2 - Pre-Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Requires-Python: >=3.10
18
+ Requires-Dist: httpx>=0.27
19
+ Requires-Dist: pydantic>=2
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
22
+ Requires-Dist: pytest>=8; extra == 'dev'
23
+ Requires-Dist: ruff>=0.5; extra == 'dev'
24
+ Description-Content-Type: text/markdown
25
+
26
+ # cleanlib-sdk-py
27
+
28
+ CleanLibrary Python SDK — `asyncio` + `httpx`; mirrors the Rust `cleanlib-client` HTTP surface.
29
+
30
+ **Status**: `v0.1.0-substrate` (cycle-4 §D.1). Substrate-only — `fetch_verdict` ships; remaining verbs (`scan` / `audit_recent` / `policy_preview` / `fetch_bytes`) iterate cycle-5+.
31
+
32
+ ## Install (cycle-5+; not yet on PyPI)
33
+
34
+ ```bash
35
+ pip install cleanlib-sdk
36
+ ```
37
+
38
+ For cycle-4 substrate, install from this repo:
39
+
40
+ ```bash
41
+ pip install git+https://bitbucket.org/triamsec/cleanlib-sdk-py.git
42
+ ```
43
+
44
+ ## Usage
45
+
46
+ ```python
47
+ import asyncio
48
+ from cleanlib_sdk import Client, PolicyDenyError, RiskAcceptanceRequiredError
49
+
50
+ async def main() -> None:
51
+ async with Client(
52
+ endpoint="https://cleanapp.clnstrt.dev",
53
+ api_key="clk_std_...", # opaque CleanLibrary access key
54
+ ) as c:
55
+ try:
56
+ v = await c.fetch_verdict("npm", "lodash", "4.17.21")
57
+ print(f"{v.decision} composite_score={v.composite_score}")
58
+ print(f"reasoning: {v.reasoning}")
59
+ except PolicyDenyError as e:
60
+ print(f"DENIED [{e.reason_code}]: {e.message}")
61
+ except RiskAcceptanceRequiredError as e:
62
+ print(f"RISK ACCEPT REQUIRED: {e.message}")
63
+ if e.docs_url:
64
+ print(f"see: {e.docs_url}")
65
+
66
+ asyncio.run(main())
67
+ ```
68
+
69
+ ## Error hierarchy
70
+
71
+ All errors descend from `CleanLibraryError`. Subclasses:
72
+
73
+ | Exception | HTTP | Triggered by |
74
+ |---|---|---|
75
+ | `PolicyDenyError` | 403 / 451 | `POLICY_DENY_VERDICT` / `POLICY_DENY_RULE_EXPLICIT` |
76
+ | `IntegrityFailureError` | 403 | `INTEGRITY_FAILURE` |
77
+ | `RateLimitExceededError` | 429 | tier-throttled; carries `retry_after_seconds` |
78
+ | `RiskAcceptanceRequiredError` | 403 | `RISK_ACCEPTANCE_REQUIRED` |
79
+ | `AuthenticationError` | 401 / 403 | `KEY_INVALID` / `KEY_EXPIRED` / `KEY_SCOPE_INSUFFICIENT` |
80
+ | `InsufficientDataError` | 403 | `INSUFFICIENT_DATA_FAIL_CLOSED` |
81
+ | `PackageNotFoundError` | 404 | not in catalog + ingest declined |
82
+ | `ServerError` | 5xx | retryable on 502/503/504 |
83
+ | `TransportError` | — | network / TLS / timeout / DNS |
84
+ | `ParseError` | — | response body shape mismatch |
85
+
86
+ ## Development
87
+
88
+ ```bash
89
+ pip install -e ".[dev]"
90
+ pytest
91
+ ruff check .
92
+ ```
93
+
94
+ ## Cross-references
95
+
96
+ - [App workstream substrate spec](https://bitbucket.org/triamsec/cleanlib/src/main/workstream-specs/05-app/sdk-substrate-rev1.md) — canonical surface design
97
+ - [Rust cleanlib-client](https://bitbucket.org/triamsec/cleanlib/src/main/cleanlib-client/) — reference implementation
98
+ - [App customer endpoint contract](https://bitbucket.org/triamsec/cleanlib/src/main/workstream-specs/05-app/app-scope-outline-rev4.md) §9 — HTTP surface contract
99
+
100
+ ## License
101
+
102
+ Proprietary — CleanStart.
@@ -0,0 +1,77 @@
1
+ # cleanlib-sdk-py
2
+
3
+ CleanLibrary Python SDK — `asyncio` + `httpx`; mirrors the Rust `cleanlib-client` HTTP surface.
4
+
5
+ **Status**: `v0.1.0-substrate` (cycle-4 §D.1). Substrate-only — `fetch_verdict` ships; remaining verbs (`scan` / `audit_recent` / `policy_preview` / `fetch_bytes`) iterate cycle-5+.
6
+
7
+ ## Install (cycle-5+; not yet on PyPI)
8
+
9
+ ```bash
10
+ pip install cleanlib-sdk
11
+ ```
12
+
13
+ For cycle-4 substrate, install from this repo:
14
+
15
+ ```bash
16
+ pip install git+https://bitbucket.org/triamsec/cleanlib-sdk-py.git
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ ```python
22
+ import asyncio
23
+ from cleanlib_sdk import Client, PolicyDenyError, RiskAcceptanceRequiredError
24
+
25
+ async def main() -> None:
26
+ async with Client(
27
+ endpoint="https://cleanapp.clnstrt.dev",
28
+ api_key="clk_std_...", # opaque CleanLibrary access key
29
+ ) as c:
30
+ try:
31
+ v = await c.fetch_verdict("npm", "lodash", "4.17.21")
32
+ print(f"{v.decision} composite_score={v.composite_score}")
33
+ print(f"reasoning: {v.reasoning}")
34
+ except PolicyDenyError as e:
35
+ print(f"DENIED [{e.reason_code}]: {e.message}")
36
+ except RiskAcceptanceRequiredError as e:
37
+ print(f"RISK ACCEPT REQUIRED: {e.message}")
38
+ if e.docs_url:
39
+ print(f"see: {e.docs_url}")
40
+
41
+ asyncio.run(main())
42
+ ```
43
+
44
+ ## Error hierarchy
45
+
46
+ All errors descend from `CleanLibraryError`. Subclasses:
47
+
48
+ | Exception | HTTP | Triggered by |
49
+ |---|---|---|
50
+ | `PolicyDenyError` | 403 / 451 | `POLICY_DENY_VERDICT` / `POLICY_DENY_RULE_EXPLICIT` |
51
+ | `IntegrityFailureError` | 403 | `INTEGRITY_FAILURE` |
52
+ | `RateLimitExceededError` | 429 | tier-throttled; carries `retry_after_seconds` |
53
+ | `RiskAcceptanceRequiredError` | 403 | `RISK_ACCEPTANCE_REQUIRED` |
54
+ | `AuthenticationError` | 401 / 403 | `KEY_INVALID` / `KEY_EXPIRED` / `KEY_SCOPE_INSUFFICIENT` |
55
+ | `InsufficientDataError` | 403 | `INSUFFICIENT_DATA_FAIL_CLOSED` |
56
+ | `PackageNotFoundError` | 404 | not in catalog + ingest declined |
57
+ | `ServerError` | 5xx | retryable on 502/503/504 |
58
+ | `TransportError` | — | network / TLS / timeout / DNS |
59
+ | `ParseError` | — | response body shape mismatch |
60
+
61
+ ## Development
62
+
63
+ ```bash
64
+ pip install -e ".[dev]"
65
+ pytest
66
+ ruff check .
67
+ ```
68
+
69
+ ## Cross-references
70
+
71
+ - [App workstream substrate spec](https://bitbucket.org/triamsec/cleanlib/src/main/workstream-specs/05-app/sdk-substrate-rev1.md) — canonical surface design
72
+ - [Rust cleanlib-client](https://bitbucket.org/triamsec/cleanlib/src/main/cleanlib-client/) — reference implementation
73
+ - [App customer endpoint contract](https://bitbucket.org/triamsec/cleanlib/src/main/workstream-specs/05-app/app-scope-outline-rev4.md) §9 — HTTP surface contract
74
+
75
+ ## License
76
+
77
+ Proprietary — CleanStart.
@@ -0,0 +1,55 @@
1
+ # CleanLibrary Python SDK CI — per workspace §C.5 + §C.6 + pen #16 forward fix.
2
+ #
3
+ # Mirrors the cleanlib repo's commit-prefix discipline + rebase-required
4
+ # detector. Uses a separate CLEANLIB-N counter per sdk-substrate-rev1.md §8.
5
+
6
+ image: python:3.12-slim
7
+
8
+ pipelines:
9
+ default:
10
+ - step:
11
+ name: Validate commit message prefix
12
+ script:
13
+ # Each commit on the PR branch (since divergence from main) must start with
14
+ # a CLEANLIB-N prefix per workspace §6.9 discipline. --no-merges per pen #16
15
+ # avoids false-positives on BB-synthesized merge commits.
16
+ - |
17
+ apt-get update -qq && apt-get install -y -qq git >/dev/null
18
+ BASE_REF=${BITBUCKET_PR_DESTINATION_BRANCH:-main}
19
+ git fetch origin "$BASE_REF" || true
20
+ git log --no-merges "origin/$BASE_REF..HEAD" --pretty=format:"%H %s" | while read -r line; do
21
+ MSG=${line#* }
22
+ case "$MSG" in
23
+ CLEANLIB-*) ;;
24
+ *) echo "❌ commit missing CLEANLIB-N prefix: $line"; exit 1;;
25
+ esac
26
+ done
27
+ echo "✅ commit prefixes OK"
28
+
29
+ - step:
30
+ name: Rebase-required detector
31
+ script:
32
+ # Mirrors cleanlib repo §C.6: if merge-base is not on main's HEAD, ask for rebase.
33
+ - |
34
+ apt-get update -qq && apt-get install -y -qq git >/dev/null
35
+ BASE_REF=${BITBUCKET_PR_DESTINATION_BRANCH:-main}
36
+ git fetch origin "$BASE_REF"
37
+ MB=$(git merge-base "origin/$BASE_REF" HEAD)
38
+ HEAD_REMOTE=$(git rev-parse "origin/$BASE_REF")
39
+ if [ "$MB" != "$HEAD_REMOTE" ]; then
40
+ echo "❌ Branch is behind origin/$BASE_REF; rebase before merging."
41
+ echo " merge-base: $MB"
42
+ echo " origin/$BASE_REF: $HEAD_REMOTE"
43
+ exit 1
44
+ fi
45
+ echo "✅ branch current with origin/$BASE_REF"
46
+
47
+ - step:
48
+ name: Lint + test
49
+ caches:
50
+ - pip
51
+ script:
52
+ - pip install --quiet --upgrade pip
53
+ - pip install --quiet -e ".[dev]"
54
+ - ruff check .
55
+ - pytest -q
@@ -0,0 +1,118 @@
1
+ """CleanLibrary Python SDK — mirrors @cleanstart/cleanlib-sdk v0.4.x.
2
+
3
+ Cycle-7 v0.4.1:
4
+ - New per-domain HTTP client split (sister-shape with sdk-js v0.4.1)
5
+ - `HttpRemediationClient` — sparse 7-block /remediation responses
6
+ - (HttpVerdictClient + HttpEnrichClient land in v0.4.2 ripple)
7
+ - `cleanlib_sdk.reason_codes.ReasonCode` — 15-entry canonical registry (Enum)
8
+ - `cleanlib_sdk.schema` — `VerdictEnvelopeV1` JSON Schema mirror (Python dict literal)
9
+ - `cleanlib_sdk.derive_status.derive_status(envelope)` — algorithm per App
10
+ dispatch §4 binding contract (substance precedence + freshness override)
11
+ - `cleanlib_sdk.Client` flat-method class → `DeprecationWarning` on __init__
12
+ (per F5 ratification; scheduled removal v1.0.0)
13
+
14
+ Schema-locked against `@cleanstart/cleanlib-sdk@0.4.1` tarball sha-1
15
+ `b5f00c160907a6ea1f490f14a9bda6f6de34b8b6`. Cross-SDK contract test verifies
16
+ identical (status, reason_code) on every fixture in
17
+ `cleanlib-contract-fixtures` v1.0.0.
18
+
19
+ Earlier cycle context:
20
+ - Cycle-5 v0.3.0 added typed `Verdict.attestation`
21
+ - Cycle-6 v0.4.0 added 5-verb cascade + 9 rich-data Optional fields
22
+ """
23
+
24
+ from cleanlib_sdk.client import Client
25
+ from cleanlib_sdk.derive_status import StatusResult, derive_status
26
+ from cleanlib_sdk.errors import (
27
+ AuthenticationError,
28
+ CleanLibraryError,
29
+ InsufficientDataError,
30
+ IntegrityFailureError,
31
+ PackageNotFoundError,
32
+ ParseError,
33
+ PolicyDenyError,
34
+ RateLimitExceededError,
35
+ RiskAcceptanceRequiredError,
36
+ ServerError,
37
+ TransportError,
38
+ )
39
+ from cleanlib_sdk.http import (
40
+ DEFAULT_REMEDIATION_BASE_URL,
41
+ REMEDIATION_CACHE_TTL_SECS,
42
+ HttpRemediationClient,
43
+ RemediationBlock,
44
+ RemediationOrAbsent,
45
+ RemediationResponse,
46
+ )
47
+ from cleanlib_sdk.reason_codes import ALL_REASON_CODES, ReasonCode
48
+ from cleanlib_sdk.schema import (
49
+ AVAILABILITY_ENUM,
50
+ SCHEMA_ID,
51
+ STATUS_ENUM,
52
+ VERDICT_ENVELOPE_V1_SCHEMA,
53
+ )
54
+ from cleanlib_sdk.types import (
55
+ Attestation,
56
+ AuditWindow,
57
+ AuditWindowResponse,
58
+ PackageRef,
59
+ PackageRisk,
60
+ PolicyPreviewResponse,
61
+ PolicyPreviewResult,
62
+ RecommendedVersion,
63
+ RiskAcceptResponse,
64
+ ScanResponse,
65
+ ScanResult,
66
+ SignedAttestation,
67
+ Verdict,
68
+ )
69
+
70
+ __version__ = "0.4.1"
71
+
72
+ __all__ = [
73
+ # v0.4.1 per-domain HTTP clients (primary API)
74
+ "HttpRemediationClient",
75
+ "DEFAULT_REMEDIATION_BASE_URL",
76
+ "REMEDIATION_CACHE_TTL_SECS",
77
+ "RemediationBlock",
78
+ "RemediationResponse",
79
+ "RemediationOrAbsent",
80
+ # v0.4.1 contract enforcement
81
+ "ReasonCode",
82
+ "ALL_REASON_CODES",
83
+ "VERDICT_ENVELOPE_V1_SCHEMA",
84
+ "SCHEMA_ID",
85
+ "STATUS_ENUM",
86
+ "AVAILABILITY_ENUM",
87
+ "derive_status",
88
+ "StatusResult",
89
+ # cycle-6 v0.4.0 types
90
+ "Verdict",
91
+ "Attestation",
92
+ "SignedAttestation",
93
+ "RecommendedVersion",
94
+ "PackageRisk",
95
+ "PackageRef",
96
+ "ScanResult",
97
+ "ScanResponse",
98
+ "AuditWindow",
99
+ "AuditWindowResponse",
100
+ "PolicyPreviewResult",
101
+ "PolicyPreviewResponse",
102
+ "RiskAcceptResponse",
103
+ # Deprecated v0.4.1 (v1.0.0 removal); kept for source-compat with v0.4.0 callers
104
+ "Client",
105
+ # Errors
106
+ "CleanLibraryError",
107
+ "PolicyDenyError",
108
+ "IntegrityFailureError",
109
+ "RateLimitExceededError",
110
+ "RiskAcceptanceRequiredError",
111
+ "AuthenticationError",
112
+ "InsufficientDataError",
113
+ "PackageNotFoundError",
114
+ "ServerError",
115
+ "TransportError",
116
+ "ParseError",
117
+ "__version__",
118
+ ]
@@ -0,0 +1,236 @@
1
+ """Async Client — DEPRECATED as of v0.4.1; scheduled removal v1.0.0.
2
+
3
+ The flat-method `Client` class is superseded by the per-domain client split
4
+ in `cleanlib_sdk.http`:
5
+
6
+ - `HttpRemediationClient` (v0.4.1; sparse 7-block /remediation responses)
7
+ - `HttpVerdictClient` (v0.4.2 ripple; mirror sdk-js)
8
+ - `HttpEnrichClient` (v0.4.2 ripple; mirror sdk-js)
9
+
10
+ Existing callers continue to work — instantiating `Client` emits a
11
+ `DeprecationWarning` per F5 ratification (sister-shape with sdk-js v0.4.1
12
+ `CleanlibClient` `@deprecated` shim).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import warnings
18
+ from types import TracebackType
19
+ from typing import Optional, Type
20
+ from urllib.parse import quote
21
+
22
+ import httpx
23
+
24
+ from cleanlib_sdk.transport import (
25
+ DEFAULT_TIMEOUT_SECS,
26
+ auth_headers,
27
+ map_http_error,
28
+ validate_endpoint,
29
+ )
30
+ from cleanlib_sdk.types import (
31
+ AuditWindow,
32
+ AuditWindowResponse,
33
+ PackageRef,
34
+ PolicyPreviewResponse,
35
+ RiskAcceptResponse,
36
+ ScanResponse,
37
+ Verdict,
38
+ )
39
+
40
+
41
+ class Client:
42
+ """DEPRECATED — Async HTTP client for the CleanLibrary App customer endpoints.
43
+
44
+ .. deprecated:: 0.4.1
45
+ Use the per-domain clients in :mod:`cleanlib_sdk.http` instead.
46
+ Scheduled removal in v1.0.0. Per F5 cycle-7 ratification.
47
+
48
+ Example (still works; emits ``DeprecationWarning``)::
49
+
50
+ import asyncio
51
+ from cleanlib_sdk import Client
52
+
53
+ async def main():
54
+ async with Client(
55
+ endpoint="https://cleanapp.clnstrt.dev",
56
+ api_key="clk_std_test_001",
57
+ ) as c:
58
+ v = await c.fetch_verdict("npm", "lodash", "4.17.21")
59
+ print(v.decision, v.composite_score)
60
+
61
+ asyncio.run(main())
62
+ """
63
+
64
+ def __init__(
65
+ self,
66
+ endpoint: str,
67
+ api_key: Optional[str] = None,
68
+ api_version: str = "v1",
69
+ timeout_secs: float = DEFAULT_TIMEOUT_SECS,
70
+ ) -> None:
71
+ warnings.warn(
72
+ "cleanlib_sdk.Client is deprecated as of v0.4.1; use the per-domain "
73
+ "clients in cleanlib_sdk.http (HttpRemediationClient + HttpVerdictClient "
74
+ "+ HttpEnrichClient). Scheduled removal v1.0.0.",
75
+ DeprecationWarning,
76
+ stacklevel=2,
77
+ )
78
+ self.base_url = validate_endpoint(endpoint)
79
+ self.api_key = api_key
80
+ self.api_version = api_version
81
+ self._http = httpx.AsyncClient(
82
+ headers=auth_headers(api_key),
83
+ timeout=timeout_secs,
84
+ )
85
+
86
+ async def __aenter__(self) -> "Client":
87
+ return self
88
+
89
+ async def __aexit__(
90
+ self,
91
+ exc_type: Optional[Type[BaseException]],
92
+ exc: Optional[BaseException],
93
+ tb: Optional[TracebackType],
94
+ ) -> None:
95
+ await self._http.aclose()
96
+
97
+ async def aclose(self) -> None:
98
+ await self._http.aclose()
99
+
100
+ async def fetch_verdict(self, ecosystem: str, package: str, version: str) -> Verdict:
101
+ """GET /v1/customer/verdicts/{ecosystem}/{package}/{version}.
102
+
103
+ Returns the parsed Verdict on 2xx. Raises a typed CleanLibraryError
104
+ subclass on non-2xx per the App Rev 4 §9.2 reason-code dispatch.
105
+ """
106
+ path = (
107
+ f"{self.base_url}/{self.api_version}/customer/verdicts/"
108
+ f"{quote(ecosystem, safe='')}/"
109
+ f"{quote(package, safe='')}/"
110
+ f"{quote(version, safe='')}"
111
+ )
112
+ resp = await self._http.get(path)
113
+ if resp.is_error:
114
+ raise map_http_error(resp)
115
+ data = resp.json()
116
+ # Inject request-context fields the App doesn't echo back
117
+ # (App's customer-verdict endpoint returns canonical cleanlib_core::
118
+ # Verdict shape without ecosystem/package/version path-param echoes)
119
+ data.setdefault("ecosystem", ecosystem)
120
+ data.setdefault("package", package)
121
+ data.setdefault("version", version)
122
+ # Derive decision from verdict_label + severity per cycle-4 §D.7
123
+ # demo policy mapping
124
+ if data.get("decision") is None:
125
+ data["decision"] = _derive_decision(
126
+ data.get("verdict") or data.get("source"),
127
+ data.get("severity"),
128
+ )
129
+ return Verdict.model_validate(data)
130
+
131
+ async def scan(self, packages: list[PackageRef]) -> ScanResponse:
132
+ """POST /v1/scan — resolve verdicts for a batch of packages (PR #99).
133
+
134
+ Partial-success: a per-package failure surfaces as `error` on its
135
+ result entry rather than raising. Mirrors cleanlib-sdk-js `scan`.
136
+ """
137
+ body = {"packages": [p.model_dump(by_alias=True) for p in packages]}
138
+ resp = await self._http.post(f"{self.base_url}/{self.api_version}/scan", json=body)
139
+ if resp.is_error:
140
+ raise map_http_error(resp)
141
+ return ScanResponse.model_validate(resp.json())
142
+
143
+ async def audit(self, window: Optional[AuditWindow] = None) -> AuditWindowResponse:
144
+ """GET /v1/audit?since=&until= — audit-window query (PR #99).
145
+
146
+ `backend_status` is an honest signal: "not_wired" + empty records means
147
+ the endpoint is reachable but the audit store is not yet attached
148
+ App-side. Mirrors cleanlib-sdk-js `audit`.
149
+ """
150
+ params: dict[str, str] = {}
151
+ if window is not None:
152
+ if window.since is not None:
153
+ params["since"] = window.since
154
+ if window.until is not None:
155
+ params["until"] = window.until
156
+ resp = await self._http.get(
157
+ f"{self.base_url}/{self.api_version}/audit", params=params or None
158
+ )
159
+ if resp.is_error:
160
+ raise map_http_error(resp)
161
+ return AuditWindowResponse.model_validate(resp.json())
162
+
163
+ async def policy_preview(
164
+ self, policy_yaml: str, packages: list[PackageRef]
165
+ ) -> PolicyPreviewResponse:
166
+ """POST /v1/policy/preview — preview a candidate policy YAML against a
167
+ batch of packages WITHOUT persisting it (PR #99). Raises ParseError/
168
+ HttpError-equivalent on 400 when the YAML fails to parse. Mirrors
169
+ cleanlib-sdk-js `policyPreview`.
170
+ """
171
+ body = {
172
+ "policy_yaml": policy_yaml,
173
+ "packages": [p.model_dump(by_alias=True) for p in packages],
174
+ }
175
+ resp = await self._http.post(
176
+ f"{self.base_url}/{self.api_version}/policy/preview", json=body
177
+ )
178
+ if resp.is_error:
179
+ raise map_http_error(resp)
180
+ return PolicyPreviewResponse.model_validate(resp.json())
181
+
182
+ async def risk_accept(self, verdict_id: str, justification: str) -> RiskAcceptResponse:
183
+ """POST /v1/risk-accept — record a risk-acceptance for a verdict (PR #99).
184
+
185
+ `persistence` is "ephemeral" until a CDP accept-write lands. Raises on
186
+ 400 when verdict_id or justification is empty. Mirrors cleanlib-sdk-js
187
+ `riskAccept`.
188
+ """
189
+ body = {"verdict_id": verdict_id, "justification": justification}
190
+ resp = await self._http.post(
191
+ f"{self.base_url}/{self.api_version}/risk-accept", json=body
192
+ )
193
+ if resp.is_error:
194
+ raise map_http_error(resp)
195
+ return RiskAcceptResponse.model_validate(resp.json())
196
+
197
+ async def fetch_bytes(self, ecosystem: str, package: str, version: str) -> bytes:
198
+ """GET /v1/fetch/{ecosystem}/{package}/{version} — raw catalog bytes
199
+ (PR #99). Returns the application/octet-stream body. Does NOT run the
200
+ policy evaluator or sign an attestation. Mirrors cleanlib-sdk-js
201
+ `fetchBytes`.
202
+ """
203
+ path = (
204
+ f"{self.base_url}/{self.api_version}/fetch/"
205
+ f"{quote(ecosystem, safe='')}/"
206
+ f"{quote(package, safe='')}/"
207
+ f"{quote(version, safe='')}"
208
+ )
209
+ resp = await self._http.get(path)
210
+ if resp.is_error:
211
+ raise map_http_error(resp)
212
+ return resp.content
213
+
214
+
215
+ def _derive_decision(verdict_label: Optional[str], severity: Optional[str]) -> str:
216
+ """Map App canonical Verdict's verdict_label + severity to decision string.
217
+
218
+ Mapping rules per cycle-4 §D.7 static demo policy + cycle-5 fixtures:
219
+ - VECTOR_VERDICT + High/Critical severity → DENY
220
+ - VECTOR_VERDICT + lower severity → WARN
221
+ - DM_THRESHOLD_BLOCK → DENY
222
+ - INSUFFICIENT_DATA → WARN
223
+ - ALLOWED_NO_FINDINGS → ALLOW
224
+ - default → ALLOW
225
+ """
226
+ label = (verdict_label or "").upper()
227
+ sev = (severity or "").upper()
228
+ if label == "VECTOR_VERDICT":
229
+ return "DENY" if sev in ("HIGH", "CRITICAL") else "WARN"
230
+ if label == "DM_THRESHOLD_BLOCK":
231
+ return "DENY"
232
+ if label == "INSUFFICIENT_DATA":
233
+ return "WARN"
234
+ if label == "ALLOWED_NO_FINDINGS":
235
+ return "ALLOW"
236
+ return "ALLOW"