sbn-sdk 0.3.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.
- sbn_sdk-0.3.0/PKG-INFO +100 -0
- sbn_sdk-0.3.0/README.md +72 -0
- sbn_sdk-0.3.0/pyproject.toml +38 -0
- sbn_sdk-0.3.0/sbn/__init__.py +102 -0
- sbn_sdk-0.3.0/sbn/_http.py +220 -0
- sbn_sdk-0.3.0/sbn/_version.py +1 -0
- sbn_sdk-0.3.0/sbn/auth.py +136 -0
- sbn_sdk-0.3.0/sbn/blocks.py +48 -0
- sbn_sdk-0.3.0/sbn/client.py +165 -0
- sbn_sdk-0.3.0/sbn/console.py +385 -0
- sbn_sdk-0.3.0/sbn/control_plane.py +223 -0
- sbn_sdk-0.3.0/sbn/gateway.py +171 -0
- sbn_sdk-0.3.0/sbn/gec.py +168 -0
- sbn_sdk-0.3.0/sbn/governance.py +175 -0
- sbn_sdk-0.3.0/sbn/lattice.py +245 -0
- sbn_sdk-0.3.0/sbn/reality.py +135 -0
- sbn_sdk-0.3.0/sbn/snapchore.py +199 -0
- sbn_sdk-0.3.0/tower/__init__.py +43 -0
- sbn_sdk-0.3.0/tower/_http.py +213 -0
- sbn_sdk-0.3.0/tower/client.py +241 -0
- sbn_sdk-0.3.0/tower/profiles/__init__.py +21 -0
- sbn_sdk-0.3.0/tower/profiles/_base.py +228 -0
- sbn_sdk-0.3.0/tower/profiles/dominion.py +188 -0
- sbn_sdk-0.3.0/tower/profiles/grid.py +145 -0
- sbn_sdk-0.3.0/tower/profiles/stillpoint.py +137 -0
- sbn_sdk-0.3.0/tower/types.py +84 -0
sbn_sdk-0.3.0/PKG-INFO
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sbn-sdk
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Python SDK for the SmartBlocks Network — attestation, GEC compute, SnapChore integrity, governance, and more.
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: smartblocks,sbn,snapchore,gec,attestation,integrity
|
|
7
|
+
Author: SmartBlocks Team
|
|
8
|
+
Author-email: devrel@smartblocks.network
|
|
9
|
+
Requires-Python: >=3.10,<4.0
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Requires-Dist: PyJWT (>=2.8)
|
|
21
|
+
Requires-Dist: cryptography (>=41.0)
|
|
22
|
+
Requires-Dist: httpx (>=0.27.0,<0.28.0)
|
|
23
|
+
Project-URL: Documentation, https://smartblocks.network/docs/sdk
|
|
24
|
+
Project-URL: Homepage, https://smartblocks.network
|
|
25
|
+
Project-URL: Repository, https://github.com/smartblocks-network/sbn-sdk
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# SmartBlocks Network Python SDK
|
|
29
|
+
|
|
30
|
+
Canonical Python client for the SBN infrastructure. Covers the full network
|
|
31
|
+
surface — gateway, SnapChore, console, and control plane.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install sbn-sdk
|
|
37
|
+
# or from source
|
|
38
|
+
cd sdk/python && pip install -e .
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Quick start
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from sbn import SbnClient
|
|
45
|
+
|
|
46
|
+
client = SbnClient(base_url="https://api.smartblocks.network")
|
|
47
|
+
client.authenticate_api_key("sbn_live_abc123")
|
|
48
|
+
|
|
49
|
+
# SnapChore — capture, verify, seal
|
|
50
|
+
block = client.snapchore.capture({"event": "signup", "user": "u-42"})
|
|
51
|
+
client.snapchore.verify(block["snapchore_hash"], {"event": "signup", "user": "u-42"})
|
|
52
|
+
client.snapchore.seal(block["snapchore_hash"], {"event": "signup", "user": "u-42"})
|
|
53
|
+
|
|
54
|
+
# Gateway — slots, receipts, attestations
|
|
55
|
+
slot = client.gateway.create_slot(worker_id="w-1", task_type="classify")
|
|
56
|
+
receipt = client.gateway.fetch_receipt(slot.receipt_id)
|
|
57
|
+
|
|
58
|
+
# Console — API keys, usage, billing
|
|
59
|
+
keys = client.console.list_api_keys("proj-123")
|
|
60
|
+
usage = client.console.get_usage("proj-123")
|
|
61
|
+
|
|
62
|
+
# Control plane — rate plans, tenants, validators
|
|
63
|
+
plans = client.control_plane.list_rate_plans()
|
|
64
|
+
client.control_plane.create_tenant(
|
|
65
|
+
name="Acme Corp",
|
|
66
|
+
contact_email="ops@acme.co",
|
|
67
|
+
aggregator_endpoint="https://agg.acme.co",
|
|
68
|
+
rate_plan_id=plans[0].id,
|
|
69
|
+
)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Auth methods
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
# API key (most common for external devs)
|
|
76
|
+
client.authenticate_api_key("sbn_live_...")
|
|
77
|
+
|
|
78
|
+
# Bearer token (console sessions, service-to-service)
|
|
79
|
+
client.authenticate_bearer("eyJ...")
|
|
80
|
+
|
|
81
|
+
# Ed25519 signing key (auto-refreshing JWTs for agents)
|
|
82
|
+
from sbn import SigningKey
|
|
83
|
+
key = SigningKey.from_pem("/path/to/key.pem", issuer="my-svc", audience="sbn")
|
|
84
|
+
client.authenticate_signing_key(key, scopes=["attest.write", "snapchore.seal"])
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Sub-clients
|
|
88
|
+
|
|
89
|
+
| Property | Domain | Key operations |
|
|
90
|
+
|----------|--------|----------------|
|
|
91
|
+
| `client.gateway` | Slots & receipts | `create_slot`, `close_slot`, `fetch_receipt`, `request_attestation` |
|
|
92
|
+
| `client.snapchore` | Hash capture | `capture`, `verify`, `seal`, `create_chain`, `append_to_chain` |
|
|
93
|
+
| `client.console` | Developer console | `list_api_keys`, `create_api_key`, `get_usage`, `get_billing_status` |
|
|
94
|
+
| `client.control_plane` | Multi-tenancy | `list_rate_plans`, `create_tenant`, `register_validator` |
|
|
95
|
+
|
|
96
|
+
## Legacy compatibility
|
|
97
|
+
|
|
98
|
+
The original `sbn_gateway.py` single-file SDK is preserved for backward
|
|
99
|
+
compatibility. New integrations should use `from sbn import SbnClient`.
|
|
100
|
+
|
sbn_sdk-0.3.0/README.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# SmartBlocks Network Python SDK
|
|
2
|
+
|
|
3
|
+
Canonical Python client for the SBN infrastructure. Covers the full network
|
|
4
|
+
surface — gateway, SnapChore, console, and control plane.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pip install sbn-sdk
|
|
10
|
+
# or from source
|
|
11
|
+
cd sdk/python && pip install -e .
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Quick start
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from sbn import SbnClient
|
|
18
|
+
|
|
19
|
+
client = SbnClient(base_url="https://api.smartblocks.network")
|
|
20
|
+
client.authenticate_api_key("sbn_live_abc123")
|
|
21
|
+
|
|
22
|
+
# SnapChore — capture, verify, seal
|
|
23
|
+
block = client.snapchore.capture({"event": "signup", "user": "u-42"})
|
|
24
|
+
client.snapchore.verify(block["snapchore_hash"], {"event": "signup", "user": "u-42"})
|
|
25
|
+
client.snapchore.seal(block["snapchore_hash"], {"event": "signup", "user": "u-42"})
|
|
26
|
+
|
|
27
|
+
# Gateway — slots, receipts, attestations
|
|
28
|
+
slot = client.gateway.create_slot(worker_id="w-1", task_type="classify")
|
|
29
|
+
receipt = client.gateway.fetch_receipt(slot.receipt_id)
|
|
30
|
+
|
|
31
|
+
# Console — API keys, usage, billing
|
|
32
|
+
keys = client.console.list_api_keys("proj-123")
|
|
33
|
+
usage = client.console.get_usage("proj-123")
|
|
34
|
+
|
|
35
|
+
# Control plane — rate plans, tenants, validators
|
|
36
|
+
plans = client.control_plane.list_rate_plans()
|
|
37
|
+
client.control_plane.create_tenant(
|
|
38
|
+
name="Acme Corp",
|
|
39
|
+
contact_email="ops@acme.co",
|
|
40
|
+
aggregator_endpoint="https://agg.acme.co",
|
|
41
|
+
rate_plan_id=plans[0].id,
|
|
42
|
+
)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Auth methods
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
# API key (most common for external devs)
|
|
49
|
+
client.authenticate_api_key("sbn_live_...")
|
|
50
|
+
|
|
51
|
+
# Bearer token (console sessions, service-to-service)
|
|
52
|
+
client.authenticate_bearer("eyJ...")
|
|
53
|
+
|
|
54
|
+
# Ed25519 signing key (auto-refreshing JWTs for agents)
|
|
55
|
+
from sbn import SigningKey
|
|
56
|
+
key = SigningKey.from_pem("/path/to/key.pem", issuer="my-svc", audience="sbn")
|
|
57
|
+
client.authenticate_signing_key(key, scopes=["attest.write", "snapchore.seal"])
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Sub-clients
|
|
61
|
+
|
|
62
|
+
| Property | Domain | Key operations |
|
|
63
|
+
|----------|--------|----------------|
|
|
64
|
+
| `client.gateway` | Slots & receipts | `create_slot`, `close_slot`, `fetch_receipt`, `request_attestation` |
|
|
65
|
+
| `client.snapchore` | Hash capture | `capture`, `verify`, `seal`, `create_chain`, `append_to_chain` |
|
|
66
|
+
| `client.console` | Developer console | `list_api_keys`, `create_api_key`, `get_usage`, `get_billing_status` |
|
|
67
|
+
| `client.control_plane` | Multi-tenancy | `list_rate_plans`, `create_tenant`, `register_validator` |
|
|
68
|
+
|
|
69
|
+
## Legacy compatibility
|
|
70
|
+
|
|
71
|
+
The original `sbn_gateway.py` single-file SDK is preserved for backward
|
|
72
|
+
compatibility. New integrations should use `from sbn import SbnClient`.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "sbn-sdk"
|
|
3
|
+
version = "0.3.0"
|
|
4
|
+
description = "Python SDK for the SmartBlocks Network — attestation, GEC compute, SnapChore integrity, governance, and more."
|
|
5
|
+
authors = ["SmartBlocks Team <devrel@smartblocks.network>"]
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
packages = [{include = "sbn"}, {include = "tower"}]
|
|
9
|
+
keywords = ["smartblocks", "sbn", "snapchore", "gec", "attestation", "integrity"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 4 - Beta",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"License :: OSI Approved :: MIT License",
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Programming Language :: Python :: 3.10",
|
|
16
|
+
"Programming Language :: Python :: 3.11",
|
|
17
|
+
"Programming Language :: Python :: 3.12",
|
|
18
|
+
"Programming Language :: Python :: 3.13",
|
|
19
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[tool.poetry.urls]
|
|
23
|
+
Homepage = "https://smartblocks.network"
|
|
24
|
+
Repository = "https://github.com/smartblocks-network/sbn-sdk"
|
|
25
|
+
Documentation = "https://smartblocks.network/docs/sdk"
|
|
26
|
+
|
|
27
|
+
[tool.poetry.dependencies]
|
|
28
|
+
python = "^3.10"
|
|
29
|
+
httpx = "^0.27.0"
|
|
30
|
+
cryptography = ">=41.0"
|
|
31
|
+
PyJWT = ">=2.8"
|
|
32
|
+
|
|
33
|
+
[tool.poetry.group.dev.dependencies]
|
|
34
|
+
pytest = "^8.2"
|
|
35
|
+
|
|
36
|
+
[build-system]
|
|
37
|
+
requires = ["poetry-core>=1.5.0"]
|
|
38
|
+
build-backend = "poetry.core.masonry.api"
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""SmartBlocks Network SDK — canonical client for the SBN infrastructure.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
from sbn import SbnClient
|
|
6
|
+
|
|
7
|
+
client = SbnClient(base_url="https://api.smartblocks.network")
|
|
8
|
+
client.authenticate_api_key("sbn_live_abc123")
|
|
9
|
+
|
|
10
|
+
# SnapChore
|
|
11
|
+
block = client.snapchore.capture({"event": "signup", "user": "u-42"})
|
|
12
|
+
ok = client.snapchore.verify(block["snapchore_hash"], {"event": "signup", "user": "u-42"})
|
|
13
|
+
|
|
14
|
+
# Gateway (slots / receipts / attestations)
|
|
15
|
+
slot = client.gateway.create_slot(worker_id="w-1", task_type="classify")
|
|
16
|
+
receipt = client.gateway.fetch_receipt(slot.receipt_id)
|
|
17
|
+
|
|
18
|
+
# Console (projects, API keys, usage)
|
|
19
|
+
keys = client.console.list_api_keys()
|
|
20
|
+
usage = client.console.get_usage()
|
|
21
|
+
|
|
22
|
+
# GEC (frontier registry, compute, health)
|
|
23
|
+
result = client.gec.compute(y=85.0, x=100.0, frontier_id="core-ops")
|
|
24
|
+
|
|
25
|
+
# Governance (proposal lifecycle)
|
|
26
|
+
proposals = client.governance.list_proposals()
|
|
27
|
+
|
|
28
|
+
# Control plane (tenants, rate plans, validators)
|
|
29
|
+
plans = client.control_plane.list_rate_plans()
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
__version__ = "0.3.0"
|
|
35
|
+
|
|
36
|
+
# Re-export top-level conveniences
|
|
37
|
+
from sbn.client import SbnClient
|
|
38
|
+
from sbn.auth import SigningKey, MintedToken
|
|
39
|
+
from sbn._http import SbnError, SbnTransportError, RetryConfig
|
|
40
|
+
from sbn.gateway import (
|
|
41
|
+
GatewayClient,
|
|
42
|
+
SlotCreateRequest,
|
|
43
|
+
SlotHandle,
|
|
44
|
+
SlotClosure,
|
|
45
|
+
SlotSummary,
|
|
46
|
+
Receipt,
|
|
47
|
+
)
|
|
48
|
+
from sbn.snapchore import SnapChoreClient
|
|
49
|
+
from sbn.console import ConsoleClient, ApiKeyRecord, ApiKeyLimits, ProjectUsage
|
|
50
|
+
from sbn.control_plane import (
|
|
51
|
+
ControlPlaneClient,
|
|
52
|
+
RatePlan,
|
|
53
|
+
TenantSummary,
|
|
54
|
+
TenantDetail,
|
|
55
|
+
Validator,
|
|
56
|
+
)
|
|
57
|
+
from sbn.lattice import LatticeClient
|
|
58
|
+
from sbn.reality import RealityClient
|
|
59
|
+
from sbn.blocks import BlocksClient
|
|
60
|
+
from sbn.gec import GecClient
|
|
61
|
+
from sbn.governance import GovernanceClient
|
|
62
|
+
|
|
63
|
+
__all__ = [
|
|
64
|
+
"SbnClient",
|
|
65
|
+
# Auth
|
|
66
|
+
"SigningKey",
|
|
67
|
+
"MintedToken",
|
|
68
|
+
# HTTP / errors
|
|
69
|
+
"SbnError",
|
|
70
|
+
"SbnTransportError",
|
|
71
|
+
"RetryConfig",
|
|
72
|
+
# Gateway
|
|
73
|
+
"GatewayClient",
|
|
74
|
+
"SlotCreateRequest",
|
|
75
|
+
"SlotHandle",
|
|
76
|
+
"SlotClosure",
|
|
77
|
+
"SlotSummary",
|
|
78
|
+
"Receipt",
|
|
79
|
+
# SnapChore
|
|
80
|
+
"SnapChoreClient",
|
|
81
|
+
# Console
|
|
82
|
+
"ConsoleClient",
|
|
83
|
+
"ApiKeyRecord",
|
|
84
|
+
"ApiKeyLimits",
|
|
85
|
+
"ProjectUsage",
|
|
86
|
+
# Control plane
|
|
87
|
+
"ControlPlaneClient",
|
|
88
|
+
"RatePlan",
|
|
89
|
+
"TenantSummary",
|
|
90
|
+
"TenantDetail",
|
|
91
|
+
"Validator",
|
|
92
|
+
# Lattice
|
|
93
|
+
"LatticeClient",
|
|
94
|
+
# Reality Check
|
|
95
|
+
"RealityClient",
|
|
96
|
+
# Blocks
|
|
97
|
+
"BlocksClient",
|
|
98
|
+
# GEC
|
|
99
|
+
"GecClient",
|
|
100
|
+
# Governance
|
|
101
|
+
"GovernanceClient",
|
|
102
|
+
]
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Shared HTTP transport with retry, error handling, and auth injection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any, Callable, Mapping, MutableMapping, Sequence
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# ---------------------------------------------------------------------------
|
|
13
|
+
# Error types
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SbnError(RuntimeError):
|
|
18
|
+
"""Raised when the SBN API responds with an error payload."""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
message: str,
|
|
23
|
+
*,
|
|
24
|
+
status_code: int,
|
|
25
|
+
code: str | None = None,
|
|
26
|
+
details: Mapping[str, Any] | None = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
super().__init__(message)
|
|
29
|
+
self.status_code = status_code
|
|
30
|
+
self.code = code
|
|
31
|
+
self.details = dict(details or {})
|
|
32
|
+
|
|
33
|
+
def __str__(self) -> str:
|
|
34
|
+
base = super().__str__()
|
|
35
|
+
parts = [f"status={self.status_code}"]
|
|
36
|
+
if self.code:
|
|
37
|
+
parts.append(f"code={self.code}")
|
|
38
|
+
if self.details:
|
|
39
|
+
parts.append(f"details={self.details}")
|
|
40
|
+
return f"{base} ({', '.join(parts)})"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SbnTransportError(RuntimeError):
|
|
44
|
+
"""Raised when the SDK cannot reach SBN after retries."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, message: str, *, last_exception: Exception | None = None) -> None:
|
|
47
|
+
super().__init__(message)
|
|
48
|
+
self.last_exception = last_exception
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
# Retry config
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(slots=True)
|
|
57
|
+
class RetryConfig:
|
|
58
|
+
"""Retry configuration for transient failures."""
|
|
59
|
+
|
|
60
|
+
attempts: int = 3
|
|
61
|
+
backoff_factor: float = 0.5
|
|
62
|
+
status_forcelist: Sequence[int] = (500, 502, 503, 504)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
# Auth state
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class AuthState:
|
|
72
|
+
"""Mutable auth state shared across sub-clients."""
|
|
73
|
+
|
|
74
|
+
api_key: str | None = None
|
|
75
|
+
bearer_token: str | None = None
|
|
76
|
+
tenant_id: str | None = None
|
|
77
|
+
token_provider: Callable[[], str] | None = None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
# HTTP transport
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class HttpTransport:
|
|
86
|
+
"""Thin wrapper around httpx.Client with retry and auth injection."""
|
|
87
|
+
|
|
88
|
+
def __init__(
|
|
89
|
+
self,
|
|
90
|
+
*,
|
|
91
|
+
base_url: str,
|
|
92
|
+
auth: AuthState,
|
|
93
|
+
retry: RetryConfig | None = None,
|
|
94
|
+
timeout: float = 10.0,
|
|
95
|
+
user_agent: str = "sbn-sdk/0.2",
|
|
96
|
+
transport: httpx.BaseTransport | None = None,
|
|
97
|
+
) -> None:
|
|
98
|
+
self._base_url = base_url.rstrip("/")
|
|
99
|
+
self._auth = auth
|
|
100
|
+
self._retry = retry or RetryConfig()
|
|
101
|
+
self._client = httpx.Client(
|
|
102
|
+
base_url=self._base_url,
|
|
103
|
+
timeout=timeout,
|
|
104
|
+
headers={"User-Agent": user_agent},
|
|
105
|
+
transport=transport,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def base_url(self) -> str:
|
|
110
|
+
return self._base_url
|
|
111
|
+
|
|
112
|
+
def close(self) -> None:
|
|
113
|
+
self._client.close()
|
|
114
|
+
|
|
115
|
+
# ------------------------------ auth headers ---------------------------
|
|
116
|
+
|
|
117
|
+
def _headers(self, extra: Mapping[str, str] | None = None) -> dict[str, str]:
|
|
118
|
+
headers: dict[str, str] = {}
|
|
119
|
+
if self._auth.tenant_id:
|
|
120
|
+
headers["X-Tenant-ID"] = self._auth.tenant_id
|
|
121
|
+
|
|
122
|
+
# API key auth takes precedence
|
|
123
|
+
if self._auth.api_key:
|
|
124
|
+
headers["x-api-key"] = self._auth.api_key
|
|
125
|
+
elif self._auth.token_provider:
|
|
126
|
+
headers["Authorization"] = f"Bearer {self._auth.token_provider()}"
|
|
127
|
+
elif self._auth.bearer_token:
|
|
128
|
+
headers["Authorization"] = f"Bearer {self._auth.bearer_token}"
|
|
129
|
+
|
|
130
|
+
if extra:
|
|
131
|
+
headers.update(extra)
|
|
132
|
+
return headers
|
|
133
|
+
|
|
134
|
+
# ------------------------------ request core ---------------------------
|
|
135
|
+
|
|
136
|
+
def request(
|
|
137
|
+
self,
|
|
138
|
+
method: str,
|
|
139
|
+
path: str,
|
|
140
|
+
*,
|
|
141
|
+
headers: Mapping[str, str] | None = None,
|
|
142
|
+
**kwargs: Any,
|
|
143
|
+
) -> httpx.Response:
|
|
144
|
+
attempts = max(1, self._retry.attempts)
|
|
145
|
+
backoff = max(0.0, self._retry.backoff_factor)
|
|
146
|
+
method_upper = method.upper()
|
|
147
|
+
|
|
148
|
+
for attempt in range(1, attempts + 1):
|
|
149
|
+
try:
|
|
150
|
+
response = self._client.request(
|
|
151
|
+
method_upper,
|
|
152
|
+
path,
|
|
153
|
+
headers=self._headers(headers),
|
|
154
|
+
**kwargs,
|
|
155
|
+
)
|
|
156
|
+
except httpx.RequestError as exc:
|
|
157
|
+
if attempt >= attempts:
|
|
158
|
+
raise SbnTransportError(
|
|
159
|
+
"Failed to reach SBN", last_exception=exc
|
|
160
|
+
) from exc
|
|
161
|
+
time.sleep(backoff)
|
|
162
|
+
backoff *= 2
|
|
163
|
+
continue
|
|
164
|
+
|
|
165
|
+
if (
|
|
166
|
+
response.status_code in self._retry.status_forcelist
|
|
167
|
+
and attempt < attempts
|
|
168
|
+
):
|
|
169
|
+
response.close()
|
|
170
|
+
time.sleep(backoff)
|
|
171
|
+
backoff *= 2
|
|
172
|
+
continue
|
|
173
|
+
|
|
174
|
+
if response.status_code >= 400:
|
|
175
|
+
raise _error_from_response(response)
|
|
176
|
+
return response
|
|
177
|
+
|
|
178
|
+
raise SbnTransportError("Exhausted retries reaching SBN")
|
|
179
|
+
|
|
180
|
+
def get(self, path: str, **kwargs: Any) -> httpx.Response:
|
|
181
|
+
return self.request("GET", path, **kwargs)
|
|
182
|
+
|
|
183
|
+
def post(self, path: str, **kwargs: Any) -> httpx.Response:
|
|
184
|
+
return self.request("POST", path, **kwargs)
|
|
185
|
+
|
|
186
|
+
def patch(self, path: str, **kwargs: Any) -> httpx.Response:
|
|
187
|
+
return self.request("PATCH", path, **kwargs)
|
|
188
|
+
|
|
189
|
+
def delete(self, path: str, **kwargs: Any) -> httpx.Response:
|
|
190
|
+
return self.request("DELETE", path, **kwargs)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _error_from_response(response: httpx.Response) -> SbnError:
|
|
194
|
+
try:
|
|
195
|
+
payload = response.json()
|
|
196
|
+
except ValueError:
|
|
197
|
+
payload = None
|
|
198
|
+
|
|
199
|
+
message = response.reason_phrase or "SBN request failed"
|
|
200
|
+
code: str | None = None
|
|
201
|
+
details: MutableMapping[str, Any] = {}
|
|
202
|
+
|
|
203
|
+
if isinstance(payload, Mapping):
|
|
204
|
+
if isinstance(payload.get("error"), Mapping):
|
|
205
|
+
err = payload["error"]
|
|
206
|
+
message = str(err.get("message") or message)
|
|
207
|
+
code = err.get("code")
|
|
208
|
+
if isinstance(err.get("details"), Mapping):
|
|
209
|
+
details.update(err["details"])
|
|
210
|
+
elif isinstance(payload.get("error"), str):
|
|
211
|
+
code = payload["error"]
|
|
212
|
+
message = str(payload.get("detail") or payload.get("message") or message)
|
|
213
|
+
else:
|
|
214
|
+
message = str(payload.get("message") or payload.get("detail") or message)
|
|
215
|
+
if isinstance(payload.get("details"), Mapping):
|
|
216
|
+
details.update(payload["details"])
|
|
217
|
+
elif payload is not None:
|
|
218
|
+
details["response"] = payload
|
|
219
|
+
|
|
220
|
+
return SbnError(message, status_code=response.status_code, code=code, details=details)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.0"
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Ed25519 signing and JWT minting for SBN service-to-service auth."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import uuid
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from datetime import datetime, timedelta, timezone
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Mapping, Sequence
|
|
13
|
+
|
|
14
|
+
from cryptography.hazmat.primitives import serialization
|
|
15
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _b64url(data: bytes) -> str:
|
|
19
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _canonical_json(data: Mapping[str, Any]) -> bytes:
|
|
23
|
+
return json.dumps(data, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _now_utc() -> datetime:
|
|
27
|
+
return datetime.now(timezone.utc)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(slots=True)
|
|
31
|
+
class MintedToken:
|
|
32
|
+
"""A freshly minted bearer token and its expiry."""
|
|
33
|
+
|
|
34
|
+
token: str
|
|
35
|
+
expires_at: datetime
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def from_ttl(cls, token: str, ttl_seconds: int) -> MintedToken:
|
|
39
|
+
return cls(token=token, expires_at=_now_utc() + timedelta(seconds=ttl_seconds))
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def expired(self) -> bool:
|
|
43
|
+
return _now_utc() >= self.expires_at
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(slots=True)
|
|
47
|
+
class SigningKey:
|
|
48
|
+
"""Ed25519 signing helper for JWT minting and payload signatures."""
|
|
49
|
+
|
|
50
|
+
private_key: Ed25519PrivateKey
|
|
51
|
+
issuer: str
|
|
52
|
+
audience: str
|
|
53
|
+
kid: str = "sbn-ed25519"
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def from_pem(
|
|
57
|
+
cls,
|
|
58
|
+
source: str | bytes | os.PathLike[str],
|
|
59
|
+
*,
|
|
60
|
+
issuer: str,
|
|
61
|
+
audience: str,
|
|
62
|
+
kid: str = "sbn-ed25519",
|
|
63
|
+
) -> SigningKey:
|
|
64
|
+
if isinstance(source, (str, os.PathLike)):
|
|
65
|
+
text = str(source)
|
|
66
|
+
path = Path(text)
|
|
67
|
+
pem_data = path.read_bytes() if path.exists() else text.encode("utf-8")
|
|
68
|
+
else:
|
|
69
|
+
pem_data = bytes(source)
|
|
70
|
+
key = serialization.load_pem_private_key(pem_data, password=None)
|
|
71
|
+
if not isinstance(key, Ed25519PrivateKey):
|
|
72
|
+
raise TypeError("Expected an Ed25519 private key")
|
|
73
|
+
return cls(private_key=key, issuer=issuer, audience=audience, kid=kid)
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def generate(
|
|
77
|
+
cls,
|
|
78
|
+
*,
|
|
79
|
+
issuer: str,
|
|
80
|
+
audience: str,
|
|
81
|
+
kid: str = "sbn-ed25519",
|
|
82
|
+
) -> SigningKey:
|
|
83
|
+
return cls(
|
|
84
|
+
private_key=Ed25519PrivateKey.generate(),
|
|
85
|
+
issuer=issuer,
|
|
86
|
+
audience=audience,
|
|
87
|
+
kid=kid,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def issue_token(
|
|
91
|
+
self,
|
|
92
|
+
*,
|
|
93
|
+
subject: str,
|
|
94
|
+
scopes: Sequence[str],
|
|
95
|
+
cdna: str,
|
|
96
|
+
ttl_seconds: int,
|
|
97
|
+
tenant_id: str | None = None,
|
|
98
|
+
role: str = "agent",
|
|
99
|
+
) -> MintedToken:
|
|
100
|
+
if ttl_seconds <= 0:
|
|
101
|
+
raise ValueError("ttl_seconds must be positive")
|
|
102
|
+
|
|
103
|
+
issued_at = _now_utc()
|
|
104
|
+
scope_list = sorted({s.strip() for s in scopes if s})
|
|
105
|
+
header = {"alg": "EdDSA", "typ": "JWT", "kid": self.kid}
|
|
106
|
+
payload: dict[str, Any] = {
|
|
107
|
+
"iss": self.issuer,
|
|
108
|
+
"aud": self.audience,
|
|
109
|
+
"sub": subject,
|
|
110
|
+
"role": role,
|
|
111
|
+
"scope": scope_list,
|
|
112
|
+
"cdna": cdna,
|
|
113
|
+
"iat": int(issued_at.timestamp()),
|
|
114
|
+
"exp": int((issued_at + timedelta(seconds=ttl_seconds)).timestamp()),
|
|
115
|
+
"jti": uuid.uuid4().hex,
|
|
116
|
+
}
|
|
117
|
+
if tenant_id:
|
|
118
|
+
payload["tenant_id"] = tenant_id
|
|
119
|
+
|
|
120
|
+
signing_input = f"{_b64url(_canonical_json(header))}.{_b64url(_canonical_json(payload))}"
|
|
121
|
+
signature = self.private_key.sign(signing_input.encode("utf-8"))
|
|
122
|
+
token = f"{signing_input}.{_b64url(signature)}"
|
|
123
|
+
return MintedToken.from_ttl(token, ttl_seconds)
|
|
124
|
+
|
|
125
|
+
def sign_payload(self, data: Mapping[str, Any]) -> str:
|
|
126
|
+
"""Sign arbitrary canonical JSON and return the base64url signature."""
|
|
127
|
+
canonical = _canonical_json(data)
|
|
128
|
+
signature = self.private_key.sign(canonical)
|
|
129
|
+
return _b64url(signature)
|
|
130
|
+
|
|
131
|
+
def private_key_pem(self) -> bytes:
|
|
132
|
+
return self.private_key.private_bytes(
|
|
133
|
+
encoding=serialization.Encoding.PEM,
|
|
134
|
+
format=serialization.PrivateFormat.PKCS8,
|
|
135
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
136
|
+
)
|