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.
- jetv_clearing_sdk-1.0.0/PKG-INFO +75 -0
- jetv_clearing_sdk-1.0.0/README.md +55 -0
- jetv_clearing_sdk-1.0.0/clearing_sdk/__init__.py +60 -0
- jetv_clearing_sdk-1.0.0/clearing_sdk/_resilience.py +83 -0
- jetv_clearing_sdk-1.0.0/clearing_sdk/canonical.py +149 -0
- jetv_clearing_sdk-1.0.0/clearing_sdk/client.py +79 -0
- jetv_clearing_sdk-1.0.0/clearing_sdk/errors.py +105 -0
- jetv_clearing_sdk-1.0.0/clearing_sdk/kinds.py +63 -0
- jetv_clearing_sdk-1.0.0/clearing_sdk/py.typed +0 -0
- jetv_clearing_sdk-1.0.0/clearing_sdk/resolve.py +105 -0
- jetv_clearing_sdk-1.0.0/clearing_sdk/signing.py +72 -0
- jetv_clearing_sdk-1.0.0/clearing_sdk/source.py +46 -0
- jetv_clearing_sdk-1.0.0/clearing_sdk/transport.py +79 -0
- jetv_clearing_sdk-1.0.0/clearing_sdk/types.py +75 -0
- jetv_clearing_sdk-1.0.0/clearing_sdk/unify.py +122 -0
- jetv_clearing_sdk-1.0.0/jetv_clearing_sdk.egg-info/PKG-INFO +75 -0
- jetv_clearing_sdk-1.0.0/jetv_clearing_sdk.egg-info/SOURCES.txt +22 -0
- jetv_clearing_sdk-1.0.0/jetv_clearing_sdk.egg-info/dependency_links.txt +1 -0
- jetv_clearing_sdk-1.0.0/jetv_clearing_sdk.egg-info/requires.txt +6 -0
- jetv_clearing_sdk-1.0.0/jetv_clearing_sdk.egg-info/top_level.txt +1 -0
- jetv_clearing_sdk-1.0.0/pyproject.toml +45 -0
- jetv_clearing_sdk-1.0.0/setup.cfg +4 -0
- jetv_clearing_sdk-1.0.0/tests/test_canonical.py +60 -0
- jetv_clearing_sdk-1.0.0/tests/test_client.py +229 -0
|
@@ -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
|