jetv-clearing-sdk 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,75 @@
1
+ Metadata-Version: 2.4
2
+ Name: jetv-clearing-sdk
3
+ Version: 1.0.0
4
+ Summary: Official Python SDK for the Clearing economic-principal (EPID) service
5
+ License: Apache-2.0
6
+ Project-URL: Homepage, https://github.com/jetv/clearing-sdk-python
7
+ Project-URL: Source, https://github.com/jetv/clearing-sdk-python
8
+ Keywords: clearing,epid,identity,sdk,jetv
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: Apache Software License
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Typing :: Typed
13
+ Requires-Python: >=3.10
14
+ Description-Content-Type: text/markdown
15
+ Requires-Dist: httpx>=0.27
16
+ Requires-Dist: cryptography>=42
17
+ Provides-Extra: test
18
+ Requires-Dist: pytest>=8; extra == "test"
19
+ Requires-Dist: pytest-asyncio>=0.23; extra == "test"
20
+
21
+ # jetv-clearing-sdk
22
+
23
+ Official Python SDK for the **Clearing** economic-principal (EPID) service.
24
+
25
+ Clearing assigns every economic principal — humans, services, agents, organizations,
26
+ and providers — a stable **EPID** and a canonical kind, and exposes signed, auditable
27
+ operations for resolution, sourced writes, and identity unification.
28
+
29
+ ## Install
30
+
31
+ ```bash
32
+ pip install jetv-clearing-sdk
33
+ ```
34
+
35
+ ## Tiers
36
+
37
+ The SDK is layered so each caller only takes the capability (and trust) it needs:
38
+
39
+ | Tier | Constructor | Capability |
40
+ | --- | --- | --- |
41
+ | L1 — read | `ClearingClient.read_only(...)` | resolve EPIDs, read canonical kinds (no signing keys) |
42
+ | L2 — source | `ClearingClient.source(...)` | F4-signed `ensure` / `link` / `affiliate` writes |
43
+ | L3 — unify | `ClearingClient.unify(...)` | key-proof, verified-attribute, bind, and realm-link flows |
44
+
45
+ ## Quick start (L1)
46
+
47
+ ```python
48
+ import asyncio
49
+ from clearing_sdk import ClearingClient
50
+
51
+ async def main():
52
+ client = ClearingClient.read_only(base_url="https://clearing.internal")
53
+ resolved = await client.resolve("user", "alice@example.com")
54
+ print(resolved.epid, resolved.canonical_kind, resolved.status)
55
+
56
+ asyncio.run(main())
57
+ ```
58
+
59
+ ## Canonical JSON & signing
60
+
61
+ For source-authenticated (F4) and unify (L2/L3) operations the SDK produces a
62
+ **Go-exact canonical JSON** encoding so signatures verify byte-for-byte against the
63
+ server reference implementation, including HTML escaping of `<`, `>`, `&` and
64
+ `U+2028` / `U+2029`. Floating-point numbers are rejected in signed bodies to keep
65
+ canonicalization deterministic.
66
+
67
+ ## Resilience
68
+
69
+ The L1 read client ships an in-process TTL cache, single-flight request
70
+ deduplication, and a circuit breaker with zero required third-party runtime deps
71
+ beyond `httpx` and `cryptography`.
72
+
73
+ ## License
74
+
75
+ Apache-2.0
@@ -0,0 +1,55 @@
1
+ # jetv-clearing-sdk
2
+
3
+ Official Python SDK for the **Clearing** economic-principal (EPID) service.
4
+
5
+ Clearing assigns every economic principal — humans, services, agents, organizations,
6
+ and providers — a stable **EPID** and a canonical kind, and exposes signed, auditable
7
+ operations for resolution, sourced writes, and identity unification.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pip install jetv-clearing-sdk
13
+ ```
14
+
15
+ ## Tiers
16
+
17
+ The SDK is layered so each caller only takes the capability (and trust) it needs:
18
+
19
+ | Tier | Constructor | Capability |
20
+ | --- | --- | --- |
21
+ | L1 — read | `ClearingClient.read_only(...)` | resolve EPIDs, read canonical kinds (no signing keys) |
22
+ | L2 — source | `ClearingClient.source(...)` | F4-signed `ensure` / `link` / `affiliate` writes |
23
+ | L3 — unify | `ClearingClient.unify(...)` | key-proof, verified-attribute, bind, and realm-link flows |
24
+
25
+ ## Quick start (L1)
26
+
27
+ ```python
28
+ import asyncio
29
+ from clearing_sdk import ClearingClient
30
+
31
+ async def main():
32
+ client = ClearingClient.read_only(base_url="https://clearing.internal")
33
+ resolved = await client.resolve("user", "alice@example.com")
34
+ print(resolved.epid, resolved.canonical_kind, resolved.status)
35
+
36
+ asyncio.run(main())
37
+ ```
38
+
39
+ ## Canonical JSON & signing
40
+
41
+ For source-authenticated (F4) and unify (L2/L3) operations the SDK produces a
42
+ **Go-exact canonical JSON** encoding so signatures verify byte-for-byte against the
43
+ server reference implementation, including HTML escaping of `<`, `>`, `&` and
44
+ `U+2028` / `U+2029`. Floating-point numbers are rejected in signed bodies to keep
45
+ canonicalization deterministic.
46
+
47
+ ## Resilience
48
+
49
+ The L1 read client ships an in-process TTL cache, single-flight request
50
+ deduplication, and a circuit breaker with zero required third-party runtime deps
51
+ beyond `httpx` and `cryptography`.
52
+
53
+ ## License
54
+
55
+ Apache-2.0
@@ -0,0 +1,60 @@
1
+ """clearing-sdk-py — official Python SDK for the Clearing EPID service.
2
+
3
+ Three capability tiers with a uniform shape across the Go / Python / TS SDKs:
4
+
5
+ L1 ResolveClient — read: resolve / get_by_epid / kinds (cache + breaker)
6
+ L2 SourceClient — register: ensure / link / affiliate (F4 RS256 auto-sign)
7
+ L3 UnifyClient — unify: prove_key / submit_verified_attr / bind / link_realm
8
+
9
+ Tier == permission boundary: building L2/L3 requires a source RSA key, so a
10
+ read-only consumer cannot obtain the write/unify handles.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from .canonical import FloatInSignedBodyError, canonical_bytes
16
+ from .client import CONTRACT_VERSION, ClearingClient
17
+ from .errors import (
18
+ APIError,
19
+ ClearingError,
20
+ Conflict,
21
+ InvalidRequest,
22
+ NotRegistered,
23
+ PermissionDenied,
24
+ RateLimited,
25
+ Unavailable,
26
+ )
27
+ from .signing import RSASigner, load_rsa_private_key
28
+ from .types import (
29
+ RELATION_ACCOUNTABLE_FOR,
30
+ RELATION_MEMBER_OF,
31
+ AttrAssertion,
32
+ DedupResult,
33
+ Ensured,
34
+ Identity,
35
+ Resolved,
36
+ )
37
+
38
+ __all__ = [
39
+ "ClearingClient",
40
+ "CONTRACT_VERSION",
41
+ "RSASigner",
42
+ "load_rsa_private_key",
43
+ "canonical_bytes",
44
+ "FloatInSignedBodyError",
45
+ "Identity",
46
+ "Resolved",
47
+ "Ensured",
48
+ "AttrAssertion",
49
+ "DedupResult",
50
+ "RELATION_MEMBER_OF",
51
+ "RELATION_ACCOUNTABLE_FOR",
52
+ "ClearingError",
53
+ "NotRegistered",
54
+ "Unavailable",
55
+ "PermissionDenied",
56
+ "Conflict",
57
+ "InvalidRequest",
58
+ "RateLimited",
59
+ "APIError",
60
+ ]
@@ -0,0 +1,83 @@
1
+ """Minimal resilience primitives (TTL cache + circuit breaker), mirroring the Go
2
+ epidclient defaults. Kept dependency-free (no cachetools) for a small footprint."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import threading
7
+ import time
8
+ from dataclasses import dataclass
9
+ from typing import Callable, Generic, Optional, TypeVar
10
+
11
+ __all__ = ["TTLCache", "Breaker", "CacheEntry"]
12
+
13
+ T = TypeVar("T")
14
+
15
+
16
+ @dataclass
17
+ class CacheEntry(Generic[T]):
18
+ value: T
19
+ registered: bool
20
+ expires_at: float
21
+
22
+
23
+ class TTLCache(Generic[T]):
24
+ """Thread-safe TTL cache with positive/negative entries."""
25
+
26
+ def __init__(self, now: Callable[[], float] = time.monotonic):
27
+ self._now = now
28
+ self._lock = threading.Lock()
29
+ self._data: dict[str, CacheEntry[T]] = {}
30
+
31
+ def get(self, key: str) -> Optional[CacheEntry[T]]:
32
+ with self._lock:
33
+ e = self._data.get(key)
34
+ if e is None:
35
+ return None
36
+ if self._now() > e.expires_at:
37
+ del self._data[key]
38
+ return None
39
+ return e
40
+
41
+ def put(self, key: str, value: T, registered: bool, ttl: float) -> None:
42
+ with self._lock:
43
+ self._data[key] = CacheEntry(value, registered, self._now() + ttl)
44
+
45
+ def invalidate(self, key: str) -> None:
46
+ with self._lock:
47
+ self._data.pop(key, None)
48
+
49
+
50
+ class Breaker:
51
+ """Consecutive-failure circuit breaker (mirrors epidclient.breaker)."""
52
+
53
+ def __init__(self, threshold: int, open_timeout: float, now: Callable[[], float] = time.monotonic):
54
+ self._threshold = threshold
55
+ self._open_timeout = open_timeout
56
+ self._now = now
57
+ self._lock = threading.Lock()
58
+ self._failures = 0
59
+ self._opened_at = 0.0
60
+ self._open = False
61
+
62
+ def allow(self) -> bool:
63
+ with self._lock:
64
+ if not self._open:
65
+ return True
66
+ # half-open after the open window elapses.
67
+ if self._now() - self._opened_at >= self._open_timeout:
68
+ self._open = False
69
+ self._failures = 0
70
+ return True
71
+ return False
72
+
73
+ def success(self) -> None:
74
+ with self._lock:
75
+ self._failures = 0
76
+ self._open = False
77
+
78
+ def failure(self) -> None:
79
+ with self._lock:
80
+ self._failures += 1
81
+ if self._failures >= self._threshold:
82
+ self._open = True
83
+ self._opened_at = self._now()
@@ -0,0 +1,149 @@
1
+ """Go-exact canonical JSON for cross-language F4 signing.
2
+
3
+ The Clearing server canonicalizes with Go ``encoding/json`` (pkg/sourcesign):
4
+ keys sorted, no insignificant whitespace, number literals preserved, and — the
5
+ subtle part — Go's HTML-safe string escaping (``<`` ``>`` ``&`` -> ``\\u003c``
6
+ ``\\u003e`` ``\\u0026``) plus ``\\u2028``/``\\u2029``. Python's ``json.dumps``
7
+ does NOT escape those, so a naive implementation would byte-diverge and produce
8
+ signatures the server rejects. This module reproduces Go byte-for-byte; the
9
+ ``html_escape`` golden vector guards it.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ from typing import Any
16
+
17
+ __all__ = ["canonical_bytes", "FloatInSignedBodyError", "reject_float"]
18
+
19
+
20
+ class FloatInSignedBodyError(ValueError):
21
+ """Raised when a signed body contains a JSON float (precision drift guard)."""
22
+
23
+
24
+ class _Num(str):
25
+ """A JSON number token, kept verbatim (mirrors Go json.Number).
26
+
27
+ Subclasses str so we can distinguish a JSON number from a JSON string after
28
+ parsing (both would otherwise collapse to str under parse_int/parse_float).
29
+ """
30
+
31
+ __slots__ = ()
32
+
33
+ @property
34
+ def is_float(self) -> bool:
35
+ return "." in self or "e" in self or "E" in self
36
+
37
+
38
+ # Short escapes match Go's encodeState.string exactly (no \b/\f shorthand: Go
39
+ # emits \u0008 / \u000c for those).
40
+ _SHORT = {
41
+ '"': '\\"',
42
+ "\\": "\\\\",
43
+ "\n": "\\n",
44
+ "\r": "\\r",
45
+ "\t": "\\t",
46
+ }
47
+
48
+
49
+ def _escape_string(s: str) -> str:
50
+ out = ['"']
51
+ for ch in s:
52
+ o = ord(ch)
53
+ short = _SHORT.get(ch)
54
+ if short is not None:
55
+ out.append(short)
56
+ elif ch in "<>&" or o < 0x20:
57
+ out.append("\\u%04x" % o)
58
+ elif o == 0x2028 or o == 0x2029:
59
+ out.append("\\u%04x" % o)
60
+ else:
61
+ out.append(ch)
62
+ out.append('"')
63
+ return "".join(out)
64
+
65
+
66
+ def _write(v: Any, out: list[str]) -> None:
67
+ if isinstance(v, dict):
68
+ out.append("{")
69
+ first = True
70
+ for k in sorted(v.keys()): # code-point order == Go UTF-8 byte order
71
+ if not first:
72
+ out.append(",")
73
+ first = False
74
+ out.append(_escape_string(k))
75
+ out.append(":")
76
+ _write(v[k], out)
77
+ out.append("}")
78
+ elif isinstance(v, list):
79
+ out.append("[")
80
+ for i, e in enumerate(v):
81
+ if i:
82
+ out.append(",")
83
+ _write(e, out)
84
+ out.append("]")
85
+ elif isinstance(v, _Num):
86
+ out.append(str(v))
87
+ elif v is True:
88
+ out.append("true")
89
+ elif v is False:
90
+ out.append("false")
91
+ elif v is None:
92
+ out.append("null")
93
+ elif isinstance(v, str):
94
+ out.append(_escape_string(v))
95
+ elif isinstance(v, bool): # unreachable (handled above) but defensive
96
+ out.append("true" if v else "false")
97
+ elif isinstance(v, int):
98
+ out.append(str(v))
99
+ else:
100
+ raise TypeError(f"canonical: unsupported type {type(v)!r}")
101
+
102
+
103
+ def _parse(body: bytes | str) -> Any:
104
+ if isinstance(body, bytes):
105
+ body = body.decode("utf-8")
106
+ # parse_int/parse_float keep the verbatim numeric token (Go json.Number).
107
+ return json.loads(body, parse_int=_Num, parse_float=_Num)
108
+
109
+
110
+ def canonical_bytes(body: bytes | str | dict | list) -> bytes:
111
+ """Return the deterministic canonical byte sequence for a JSON value.
112
+
113
+ Accepts raw JSON (bytes/str) or an already-decoded dict/list. Raw input is
114
+ preferred for signing so number literals are preserved exactly.
115
+ """
116
+ if isinstance(body, (bytes, str)):
117
+ v = _parse(body)
118
+ else:
119
+ v = body
120
+ out: list[str] = []
121
+ _write(v, out)
122
+ return "".join(out).encode("utf-8")
123
+
124
+
125
+ def reject_float(body: bytes | str | dict | list) -> None:
126
+ """Raise FloatInSignedBodyError if the value contains any JSON float.
127
+
128
+ Signed bodies must not contain floats (cross-language precision drift): use
129
+ integer minor units or strings.
130
+ """
131
+ if isinstance(body, (bytes, str)):
132
+ v = _parse(body)
133
+ else:
134
+ v = body
135
+ _walk_reject(v)
136
+
137
+
138
+ def _walk_reject(v: Any) -> None:
139
+ if isinstance(v, dict):
140
+ for e in v.values():
141
+ _walk_reject(e)
142
+ elif isinstance(v, list):
143
+ for e in v:
144
+ _walk_reject(e)
145
+ elif isinstance(v, _Num):
146
+ if v.is_float:
147
+ raise FloatInSignedBodyError(str(v))
148
+ elif isinstance(v, float):
149
+ raise FloatInSignedBodyError(repr(v))
@@ -0,0 +1,79 @@
1
+ """ClearingClient facade. Capability == construction credential: read-only
2
+ callers cannot obtain L2/L3 (no source key), mirroring the Go SDK tiers."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import time
7
+ from typing import Callable, Optional
8
+
9
+ import httpx
10
+
11
+ from .resolve import ResolveClient
12
+ from .signing import RSASigner
13
+ from .source import SourceClient
14
+ from .transport import Transport
15
+ from .unify import UnifyClient
16
+
17
+ __all__ = ["ClearingClient", "CONTRACT_VERSION"]
18
+
19
+ # OpenAPI contract version this SDK targets (compared against live info.version).
20
+ CONTRACT_VERSION = "1.0.0"
21
+
22
+
23
+ class ClearingClient:
24
+ def __init__(
25
+ self,
26
+ base_url: str,
27
+ *,
28
+ signer: Optional[RSASigner] = None,
29
+ with_unify: bool = False,
30
+ http_client: Optional[httpx.AsyncClient] = None,
31
+ write_timeout: float = 5.0,
32
+ ttl: float = 300.0,
33
+ negative_ttl: float = 30.0,
34
+ failure_threshold: int = 5,
35
+ open_timeout: float = 10.0,
36
+ now: Callable[[], float] = time.time,
37
+ mono: Callable[[], float] = time.monotonic,
38
+ ):
39
+ self._owns_client = http_client is None
40
+ self._client = http_client or httpx.AsyncClient(timeout=write_timeout)
41
+ tr = Transport(base_url, self._client, now=now)
42
+ self.l1 = ResolveClient(
43
+ tr,
44
+ ttl=ttl,
45
+ negative_ttl=negative_ttl,
46
+ failure_threshold=failure_threshold,
47
+ open_timeout=open_timeout,
48
+ now=mono,
49
+ )
50
+ self.l2: Optional[SourceClient] = SourceClient(tr, signer) if signer is not None else None
51
+ self.l3: Optional[UnifyClient] = (
52
+ UnifyClient(tr, signer) if (signer is not None and with_unify) else None
53
+ )
54
+
55
+ @classmethod
56
+ def read_only(cls, base_url: str, **kw) -> "ClearingClient":
57
+ return cls(base_url, **kw)
58
+
59
+ @classmethod
60
+ def source(cls, base_url: str, signer: RSASigner, **kw) -> "ClearingClient":
61
+ return cls(base_url, signer=signer, **kw)
62
+
63
+ @classmethod
64
+ def unify(cls, base_url: str, signer: RSASigner, **kw) -> "ClearingClient":
65
+ return cls(base_url, signer=signer, with_unify=True, **kw)
66
+
67
+ @property
68
+ def version(self) -> str:
69
+ return CONTRACT_VERSION
70
+
71
+ async def aclose(self) -> None:
72
+ if self._owns_client:
73
+ await self._client.aclose()
74
+
75
+ async def __aenter__(self) -> "ClearingClient":
76
+ return self
77
+
78
+ async def __aexit__(self, *exc) -> None:
79
+ await self.aclose()
@@ -0,0 +1,105 @@
1
+ """SDK error taxonomy (mirrors the Go SDK sentinels).
2
+
3
+ Consumers branch on these to choose fail-open/closed; the SDK never silently
4
+ fabricates a fallback.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ __all__ = [
10
+ "ClearingError",
11
+ "NotRegistered",
12
+ "Unavailable",
13
+ "PermissionDenied",
14
+ "Conflict",
15
+ "InvalidRequest",
16
+ "RateLimited",
17
+ "APIError",
18
+ "classify_status",
19
+ ]
20
+
21
+
22
+ class ClearingError(Exception):
23
+ """Base class for all SDK errors."""
24
+
25
+
26
+ class NotRegistered(ClearingError):
27
+ """Principal/identity authoritatively absent (404, safe to negative-cache)."""
28
+
29
+
30
+ class Unavailable(ClearingError):
31
+ """Clearing unreachable / circuit open / 5xx — not silently masked."""
32
+
33
+
34
+ class PermissionDenied(ClearingError):
35
+ """Source unauthorized or admin/governance gate rejected (401/403)."""
36
+
37
+
38
+ class Conflict(ClearingError):
39
+ """Unification/registration conflict (409)."""
40
+
41
+
42
+ class InvalidRequest(ClearingError):
43
+ """Semantically invalid request (400/422)."""
44
+
45
+
46
+ class RateLimited(ClearingError):
47
+ """Too many challenges/requests (429)."""
48
+
49
+
50
+ class APIError(ClearingError):
51
+ """Carries the server's stable code + HTTP status. Subclass of the classified
52
+ error so ``except PermissionDenied`` and precise logging both work."""
53
+
54
+ def __init__(self, status: int, code: str, detail: str = ""):
55
+ self.status = status
56
+ self.code = code
57
+ self.detail = detail
58
+ msg = f"clearing: {code} (http {status})"
59
+ if detail:
60
+ msg += f": {detail}"
61
+ super().__init__(msg)
62
+
63
+
64
+ # Concrete API error classes carrying the envelope, one per sentinel.
65
+ class _APINotRegistered(APIError, NotRegistered):
66
+ pass
67
+
68
+
69
+ class _APIPermission(APIError, PermissionDenied):
70
+ pass
71
+
72
+
73
+ class _APIConflict(APIError, Conflict):
74
+ pass
75
+
76
+
77
+ class _APIInvalid(APIError, InvalidRequest):
78
+ pass
79
+
80
+
81
+ class _APIRateLimited(APIError, RateLimited):
82
+ pass
83
+
84
+
85
+ class _APIUnavailable(APIError, Unavailable):
86
+ pass
87
+
88
+
89
+ def classify_status(status: int, code: str, detail: str = "") -> APIError | None:
90
+ """Map an HTTP status + envelope code to a classified APIError (or None on
91
+ 200). Single source of status->sentinel truth, aligned with the server's
92
+ writeRegistryError / writeUnifyError mappings."""
93
+ if status == 200:
94
+ return None
95
+ if status == 404:
96
+ return _APINotRegistered(status, code, detail)
97
+ if status in (401, 403):
98
+ return _APIPermission(status, code, detail)
99
+ if status in (409, 428):
100
+ return _APIConflict(status, code, detail)
101
+ if status in (400, 422):
102
+ return _APIInvalid(status, code, detail)
103
+ if status == 429:
104
+ return _APIRateLimited(status, code, detail)
105
+ return _APIUnavailable(status, code, detail)
@@ -0,0 +1,63 @@
1
+ """external->canonical kind mapping cache, with a compiled-in degraded fallback.
2
+
3
+ The server is authoritative; the fallback is a last resort with a TTL and a
4
+ ``degraded`` flag (never silently authoritative)."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import time
10
+ from typing import Callable
11
+
12
+ from .errors import ClearingError
13
+ from .transport import Transport
14
+
15
+ __all__ = ["KindsCache", "COMPILED_MAPPING"]
16
+
17
+ # Compiled-in fallback (mirrors clearing/pkg/canonicalkind.externalToCanonical
18
+ # exactly — 5 entries, no invented keys). Used only when the server is
19
+ # unreachable; flagged degraded.
20
+ COMPILED_MAPPING: dict[str, str] = {
21
+ "user": "human",
22
+ "client": "service",
23
+ "agent": "agent",
24
+ "realm": "org",
25
+ "provider": "provider",
26
+ }
27
+
28
+
29
+ class KindsCache:
30
+ def __init__(self, transport: Transport, *, ttl: float = 300.0, now: Callable[[], float] = time.monotonic):
31
+ self._tr = transport
32
+ self._ttl = ttl
33
+ self._now = now
34
+ self._lock = asyncio.Lock()
35
+ self._mapping: dict[str, str] | None = None
36
+ self._expires_at = 0.0
37
+ self._degraded = False
38
+
39
+ async def get(self) -> dict[str, str]:
40
+ if self._mapping is not None and self._now() < self._expires_at:
41
+ return dict(self._mapping)
42
+ async with self._lock:
43
+ if self._mapping is not None and self._now() < self._expires_at:
44
+ return dict(self._mapping)
45
+ try:
46
+ data = await self._tr.get_json("/v1/kinds")
47
+ mapping = data.get("mapping") or {}
48
+ if mapping:
49
+ self._store(dict(mapping), degraded=False)
50
+ return dict(mapping)
51
+ except ClearingError:
52
+ pass
53
+ self._store(dict(COMPILED_MAPPING), degraded=True)
54
+ return dict(COMPILED_MAPPING)
55
+
56
+ def _store(self, mapping: dict[str, str], *, degraded: bool) -> None:
57
+ self._mapping = mapping
58
+ self._degraded = degraded
59
+ self._expires_at = self._now() + self._ttl
60
+
61
+ @property
62
+ def degraded(self) -> bool:
63
+ return self._degraded
File without changes