cleanlib-sdk 0.4.1__py3-none-any.whl

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,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
+ ]
cleanlib_sdk/client.py ADDED
@@ -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"
@@ -0,0 +1,95 @@
1
+ """DeriveStatus — canonical algorithm per App dispatch §4 binding contract.
2
+
3
+ Each CleanLibrary SDK (sdk-js, sdk-py, sdk-go, cleanlib-client Rust) implements
4
+ this algorithm independently from the spec; cross-SDK contract test verifies
5
+ byte-identical `(status, reason_code)` across all four implementations on every
6
+ fixture in `cleanlib-contract-fixtures` v1.0.0.
7
+
8
+ Precedence:
9
+
10
+ 1. **Substance** (signals top-to-bottom; `unavailable` substrate causes fall-through):
11
+ - `exploitability.exploitation_likelihood == "CRITICAL"` → DENY + VERDICT_EXPLOITATION_CRITICAL
12
+ - `exploitability.in_kev` AND `availability.kev == "available"` AND `exploit_risk_score >= 70` → DENY + VERDICT_KEV_LISTED
13
+ - `rich_data.has_obfuscation` → DENY + VERDICT_OBFUSCATED
14
+ - `remediation.fix` OR `remediation.recommended_version` present → WARN + VERDICT_HAS_REMEDIATION
15
+ - `rich_data.abandonment_score >= 0.7` → WARN + VERDICT_ABANDONED
16
+ - `rich_data.recommended_version` present (under rich_data, not remediation) → ALLOW + VERDICT_RECOMMENDED_VERSION_NEWER
17
+ - default → ALLOW + VERDICT_CLEAN
18
+
19
+ 2. **Freshness override**: when the substance-driving signal's `availability ==
20
+ "degraded_stale"`, the substance-derived status tier is PRESERVED and the
21
+ `reason_code` overrides to `VERDICT_DEGRADED_STALE`.
22
+
23
+ 3. **Block tie-break in remediation**: `fix` > `recommended_version` > other blocks.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from typing import Any, Literal, NamedTuple, Optional
29
+
30
+ from cleanlib_sdk.reason_codes import ReasonCode
31
+
32
+ Status = Literal["ALLOW", "WARN", "DENY"]
33
+
34
+
35
+ class StatusResult(NamedTuple):
36
+ status: Status
37
+ reason_code: ReasonCode
38
+
39
+
40
+ def _get(d: Optional[dict[str, Any]], key: str) -> Any:
41
+ if d is None:
42
+ return None
43
+ return d.get(key)
44
+
45
+
46
+ def derive_status(envelope: dict[str, Any]) -> StatusResult:
47
+ """Apply the substance-precedence + freshness-override rule.
48
+
49
+ Accepts a parsed `VerdictEnvelopeV1` dict. Returns `(status, reason_code)`.
50
+
51
+ Empirical contract: identical output across all 4 SDK implementations for
52
+ every fixture in `cleanlib-contract-fixtures` v1.0.0.
53
+ """
54
+ rich = envelope.get("rich_data") or {}
55
+ rem = envelope.get("remediation") or {}
56
+ exp = envelope.get("exploitability") or {}
57
+ avail = envelope.get("availability") or {}
58
+
59
+ # Substance precedence — first match wins. Each branch also captures the
60
+ # `freshness_signal` (the substrate availability tag for the driving signal)
61
+ # so the freshness-override step can rewrite the reason_code without
62
+ # disturbing the status tier.
63
+ status: Status
64
+ reason: ReasonCode
65
+ freshness_signal: Optional[str] = None
66
+
67
+ if exp.get("exploitation_likelihood") == "CRITICAL":
68
+ status, reason = "DENY", ReasonCode.VERDICT_EXPLOITATION_CRITICAL
69
+ freshness_signal = avail.get("exploitation_fusion")
70
+ elif (
71
+ exp.get("in_kev")
72
+ and avail.get("kev") == "available"
73
+ and (exp.get("exploit_risk_score") or 0) >= 70
74
+ ):
75
+ status, reason = "DENY", ReasonCode.VERDICT_KEV_LISTED
76
+ freshness_signal = avail.get("kev")
77
+ elif rich.get("has_obfuscation"):
78
+ status, reason = "DENY", ReasonCode.VERDICT_OBFUSCATED
79
+ elif rem.get("fix") is not None or rem.get("recommended_version") is not None:
80
+ status, reason = "WARN", ReasonCode.VERDICT_HAS_REMEDIATION
81
+ # Block tie-break: fix > recommended_version > other blocks
82
+ block = rem.get("fix") or rem.get("recommended_version") or {}
83
+ freshness_signal = block.get("availability") if isinstance(block, dict) else None
84
+ elif (rich.get("abandonment_score") or 0) >= 0.7:
85
+ status, reason = "WARN", ReasonCode.VERDICT_ABANDONED
86
+ elif rich.get("recommended_version") is not None:
87
+ status, reason = "ALLOW", ReasonCode.VERDICT_RECOMMENDED_VERSION_NEWER
88
+ else:
89
+ status, reason = "ALLOW", ReasonCode.VERDICT_CLEAN
90
+
91
+ # Freshness override: keep status tier, rewrite reason_code.
92
+ if freshness_signal == "degraded_stale":
93
+ reason = ReasonCode.VERDICT_DEGRADED_STALE
94
+
95
+ return StatusResult(status=status, reason_code=reason)
cleanlib_sdk/errors.py ADDED
@@ -0,0 +1,119 @@
1
+ """CleanLibraryError hierarchy — mirrors cleanlib-client::errors per
2
+ App Rev 4 §9.2 reason-code enum surfaced via X-CleanLibrary-Reason."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import Optional
7
+
8
+
9
+ class CleanLibraryError(Exception):
10
+ """Base for all CleanLibrary SDK errors. Mirrors Rust cleanlib-client
11
+ CleanLibraryError enum."""
12
+
13
+ def __init__(self, message: str, reason_code: Optional[str] = None) -> None:
14
+ super().__init__(message)
15
+ self.message = message
16
+ self.reason_code = reason_code
17
+
18
+ def is_retryable(self) -> bool:
19
+ """True if a transient retry could plausibly succeed."""
20
+ return False
21
+
22
+
23
+ class PolicyDenyError(CleanLibraryError):
24
+ """403 + POLICY_DENY_VERDICT / POLICY_DENY_RULE_EXPLICIT, or 451."""
25
+
26
+
27
+ class IntegrityFailureError(CleanLibraryError):
28
+ """403 + INTEGRITY_FAILURE — package hash mismatch on serve path."""
29
+
30
+
31
+ class RateLimitExceededError(CleanLibraryError):
32
+ """429 + Retry-After header."""
33
+
34
+ def __init__(self, message: str, retry_after_seconds: int = 60) -> None:
35
+ super().__init__(message, reason_code="RATE_LIMITED")
36
+ self.retry_after_seconds = retry_after_seconds
37
+
38
+ def is_retryable(self) -> bool:
39
+ return True
40
+
41
+
42
+ class RiskAcceptanceRequiredError(CleanLibraryError):
43
+ """403 + RISK_ACCEPTANCE_REQUIRED — policy permits ALLOW but customer
44
+ hasn't signed risk-acceptance for this package/version."""
45
+
46
+ def __init__(
47
+ self,
48
+ message: str,
49
+ reason_code: str = "RISK_ACCEPTANCE_REQUIRED",
50
+ guidance: Optional[str] = None,
51
+ docs_url: Optional[str] = "https://docs.cleanlibrary.io/risk-acceptance",
52
+ ) -> None:
53
+ super().__init__(message, reason_code=reason_code)
54
+ self.guidance = guidance
55
+ self.docs_url = docs_url
56
+
57
+
58
+ class AuthenticationError(CleanLibraryError):
59
+ """401, or 403 + KEY_INVALID / KEY_EXPIRED / KEY_SCOPE_INSUFFICIENT."""
60
+
61
+
62
+ class InsufficientDataError(CleanLibraryError):
63
+ """403 + INSUFFICIENT_DATA_FAIL_CLOSED — verdict unavailable + customer
64
+ policy fails closed on missing data."""
65
+
66
+
67
+ class PackageNotFoundError(CleanLibraryError):
68
+ """404 — package not in catalog + ingest declined or unavailable."""
69
+
70
+
71
+ class ServerError(CleanLibraryError):
72
+ """5xx — server error. Retryable on 502/503/504."""
73
+
74
+ def __init__(self, message: str, status: int, reason_code: Optional[str] = None) -> None:
75
+ super().__init__(message, reason_code=reason_code)
76
+ self.status = status
77
+
78
+ def is_retryable(self) -> bool:
79
+ return self.status in (502, 503, 504)
80
+
81
+
82
+ class TransportError(CleanLibraryError):
83
+ """Network / TLS / timeout / DNS — pre-response transport-layer failure."""
84
+
85
+ def is_retryable(self) -> bool:
86
+ return True
87
+
88
+
89
+ class ParseError(CleanLibraryError):
90
+ """Response body parse failure (malformed JSON, schema mismatch)."""
91
+
92
+
93
+ def from_http(status: int, reason_code: Optional[str], message: str, retry_after: Optional[int] = None) -> CleanLibraryError:
94
+ """Map HTTP status + X-CleanLibrary-Reason header into a typed exception.
95
+
96
+ Mirrors cleanlib-client::errors::from_http with the same dispatch table.
97
+ """
98
+ code = reason_code or "UNKNOWN"
99
+ body = message if message else f"HTTP {status}"
100
+
101
+ if status == 401:
102
+ return AuthenticationError(body, reason_code=code)
103
+ if status == 403 and code in ("KEY_INVALID", "KEY_EXPIRED", "KEY_SCOPE_INSUFFICIENT"):
104
+ return AuthenticationError(body, reason_code=code)
105
+ if (status == 403 and code in ("POLICY_DENY_VERDICT", "POLICY_DENY_RULE_EXPLICIT")) or status == 451:
106
+ return PolicyDenyError(body, reason_code=code)
107
+ if status == 403 and code == "RISK_ACCEPTANCE_REQUIRED":
108
+ return RiskAcceptanceRequiredError(body, reason_code=code)
109
+ if status == 403 and code == "INTEGRITY_FAILURE":
110
+ return IntegrityFailureError(body, reason_code=code)
111
+ if status == 403 and code == "INSUFFICIENT_DATA_FAIL_CLOSED":
112
+ return InsufficientDataError(body, reason_code=code)
113
+ if status == 429:
114
+ return RateLimitExceededError(body, retry_after_seconds=retry_after or 60)
115
+ if status == 404:
116
+ return PackageNotFoundError(body, reason_code=code)
117
+ if 500 <= status <= 599:
118
+ return ServerError(body, status=status, reason_code=code)
119
+ return ServerError(body, status=status, reason_code=code)
@@ -0,0 +1,30 @@
1
+ """Per-domain HTTP clients (sdk-js v0.4.1 sister-shape).
2
+
3
+ v0.4.1 introduces the per-domain client split alongside the existing
4
+ `cleanlib_sdk.client.Client` (which becomes deprecated; full removal in v1.0.0).
5
+
6
+ | Client | Domain | Endpoint default |
7
+ |---|---|---|
8
+ | `HttpRemediationClient` | sparse 7-block /remediation responses | vva URL (per F4 ratification; v0.4.2 flips to cleanlib-enrich) |
9
+ | (HttpVerdictClient / HttpEnrichClient — v0.4.2 ripple) | | |
10
+ """
11
+
12
+ from cleanlib_sdk.http.remediation_client import (
13
+ DEFAULT_REMEDIATION_BASE_URL,
14
+ REMEDIATION_CACHE_TTL_SECS,
15
+ HttpRemediationClient,
16
+ )
17
+ from cleanlib_sdk.http.types import (
18
+ RemediationBlock,
19
+ RemediationOrAbsent,
20
+ RemediationResponse,
21
+ )
22
+
23
+ __all__ = [
24
+ "HttpRemediationClient",
25
+ "DEFAULT_REMEDIATION_BASE_URL",
26
+ "REMEDIATION_CACHE_TTL_SECS",
27
+ "RemediationBlock",
28
+ "RemediationResponse",
29
+ "RemediationOrAbsent",
30
+ ]