odcp-client 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.
- odcp_client-1.0.0/PKG-INFO +232 -0
- odcp_client-1.0.0/README.md +206 -0
- odcp_client-1.0.0/pyproject.toml +52 -0
- odcp_client-1.0.0/setup.cfg +4 -0
- odcp_client-1.0.0/src/odcp_client/__init__.py +23 -0
- odcp_client-1.0.0/src/odcp_client/_http.py +147 -0
- odcp_client-1.0.0/src/odcp_client/branding.py +150 -0
- odcp_client-1.0.0/src/odcp_client/client.py +291 -0
- odcp_client-1.0.0/src/odcp_client/exceptions.py +36 -0
- odcp_client-1.0.0/src/odcp_client/jwks.py +151 -0
- odcp_client-1.0.0/src/odcp_client/license.py +122 -0
- odcp_client-1.0.0/src/odcp_client/rbac.py +95 -0
- odcp_client-1.0.0/src/odcp_client/whoami.py +211 -0
- odcp_client-1.0.0/src/odcp_client.egg-info/PKG-INFO +232 -0
- odcp_client-1.0.0/src/odcp_client.egg-info/SOURCES.txt +16 -0
- odcp_client-1.0.0/src/odcp_client.egg-info/dependency_links.txt +1 -0
- odcp_client-1.0.0/src/odcp_client.egg-info/requires.txt +12 -0
- odcp_client-1.0.0/src/odcp_client.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: odcp-client
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Official Python SDK for the Owndivision Control Plane (license verify, JWKS, whoami caching).
|
|
5
|
+
Author: Owndivision
|
|
6
|
+
License: Proprietary
|
|
7
|
+
Project-URL: Homepage, https://github.com/owndivision/owndivision-control-plane
|
|
8
|
+
Keywords: owndivision,control-plane,sdk
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Requires-Python: >=3.12
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
Requires-Dist: odcp-contracts<3,>=2.2
|
|
16
|
+
Requires-Dist: httpx<0.29,>=0.27
|
|
17
|
+
Requires-Dist: pyjwt<3,>=2.10.1
|
|
18
|
+
Requires-Dist: cryptography>=42
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
21
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
22
|
+
Requires-Dist: pytest-cov>=5; extra == "dev"
|
|
23
|
+
Requires-Dist: respx>=0.21; extra == "dev"
|
|
24
|
+
Requires-Dist: freezegun>=1.5; extra == "dev"
|
|
25
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
26
|
+
|
|
27
|
+
# odcp-client
|
|
28
|
+
|
|
29
|
+
Official Python SDK for the Owndivision Control Plane. Provides offline-capable
|
|
30
|
+
license JWT verification with JWKS caching, whoami lookup with revision-aware
|
|
31
|
+
cache invalidation, and branding retrieval — all with both sync and async clients.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install odcp-client
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Requires Python 3.12+. Runtime dependencies: `odcp-contracts>=2.2,<3`, `httpx`,
|
|
40
|
+
`pyjwt`, `cryptography`.
|
|
41
|
+
|
|
42
|
+
## Quickstart (sync)
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from odcp_client import OdcpClient, LicenseInvalid, WhoamiNotFound
|
|
46
|
+
|
|
47
|
+
client = OdcpClient(
|
|
48
|
+
base_url="https://cp.your-domain.com",
|
|
49
|
+
api_token="your-cp-api-token",
|
|
50
|
+
license_token="eyJ...", # the signed license JWT
|
|
51
|
+
# Required when CP is configured with non-default iss/aud — must match
|
|
52
|
+
# CP's CP_SIGNING_ISS / CP_SIGNING_AUD. Defaults are
|
|
53
|
+
# "owndivision-control-plane" / "owndivision-dp".
|
|
54
|
+
signing_issuer="owndivision-control-plane",
|
|
55
|
+
signing_audience="owndivision-dp",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# Verify the license (offline after first JWKS fetch)
|
|
59
|
+
try:
|
|
60
|
+
claims = client.verify_license()
|
|
61
|
+
print(f"Seat cap: {claims.license.seat_cap}")
|
|
62
|
+
except LicenseInvalid as exc:
|
|
63
|
+
print(f"License invalid: {exc.reason}")
|
|
64
|
+
|
|
65
|
+
# Whoami lookup with revision-aware caching
|
|
66
|
+
try:
|
|
67
|
+
whoami = client.whoami(
|
|
68
|
+
provider="auth0",
|
|
69
|
+
subject="auth0|user_id",
|
|
70
|
+
workspace_id="ws-uuid",
|
|
71
|
+
)
|
|
72
|
+
print(whoami.user.email)
|
|
73
|
+
except WhoamiNotFound:
|
|
74
|
+
print("Unknown identity")
|
|
75
|
+
|
|
76
|
+
# Permission check (never raises)
|
|
77
|
+
can_read = client.has_permission(
|
|
78
|
+
provider="auth0",
|
|
79
|
+
subject="auth0|user_id",
|
|
80
|
+
workspace_id="ws-uuid",
|
|
81
|
+
permission="workspace.read",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
client.close() # or use as a context manager: `with OdcpClient(...) as client:`
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Quickstart (async)
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
import asyncio
|
|
91
|
+
from odcp_client import AsyncOdcpClient
|
|
92
|
+
|
|
93
|
+
async def main() -> None:
|
|
94
|
+
async with AsyncOdcpClient(
|
|
95
|
+
base_url="https://cp.your-domain.com",
|
|
96
|
+
api_token="your-cp-api-token",
|
|
97
|
+
license_token="eyJ...",
|
|
98
|
+
) as client:
|
|
99
|
+
claims = await client.verify_license()
|
|
100
|
+
whoami = await client.whoami(
|
|
101
|
+
provider="auth0", subject="auth0|user_id", workspace_id="ws-uuid",
|
|
102
|
+
)
|
|
103
|
+
can_write = await client.has_permission(
|
|
104
|
+
provider="auth0",
|
|
105
|
+
subject="auth0|user_id",
|
|
106
|
+
workspace_id="ws-uuid",
|
|
107
|
+
permission="workspace.write",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
asyncio.run(main())
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## FastAPI dependency recipe
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
from fastapi import Depends, HTTPException, Request
|
|
117
|
+
from odcp_client import AsyncOdcpClient, WhoamiNotFound
|
|
118
|
+
from odcp_contracts.whoami import WhoAmIResponse
|
|
119
|
+
|
|
120
|
+
# Construct once at app startup (shared across requests).
|
|
121
|
+
odcp = AsyncOdcpClient(
|
|
122
|
+
base_url=settings.CP_BASE_URL,
|
|
123
|
+
api_token=settings.CP_API_TOKEN,
|
|
124
|
+
license_token=settings.LICENSE_TOKEN,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
async def get_current_user(request: Request) -> WhoAmIResponse:
|
|
129
|
+
sub = extract_auth0_sub(request) # your Auth0 subject extractor
|
|
130
|
+
ws_id = extract_workspace_from_license(request) # your workspace resolver
|
|
131
|
+
try:
|
|
132
|
+
return await odcp.whoami(
|
|
133
|
+
provider="auth0", subject=sub, workspace_id=ws_id
|
|
134
|
+
)
|
|
135
|
+
except WhoamiNotFound:
|
|
136
|
+
raise HTTPException(status_code=401, detail="unknown_identity")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@app.get("/protected")
|
|
140
|
+
async def protected(user: WhoAmIResponse = Depends(get_current_user)) -> dict:
|
|
141
|
+
return {"email": user.user.email}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Cache backends
|
|
145
|
+
|
|
146
|
+
The default backend is an in-process LRU+TTL cache (`InMemoryTTLCache`). For
|
|
147
|
+
multi-process deployments, swap in a Redis backend:
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
import pickle
|
|
151
|
+
import redis
|
|
152
|
+
|
|
153
|
+
class RedisCacheBackend:
|
|
154
|
+
def __init__(self, redis_client: redis.Redis) -> None:
|
|
155
|
+
self._r = redis_client
|
|
156
|
+
|
|
157
|
+
def get(self, key: str):
|
|
158
|
+
value = self._r.get(key)
|
|
159
|
+
return None if value is None else pickle.loads(value)
|
|
160
|
+
|
|
161
|
+
def set(self, key: str, value, ttl_seconds: int) -> None:
|
|
162
|
+
self._r.setex(key, ttl_seconds, pickle.dumps(value))
|
|
163
|
+
|
|
164
|
+
def delete(self, key: str) -> None:
|
|
165
|
+
self._r.delete(key)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
client = OdcpClient(
|
|
169
|
+
base_url="https://cp.your-domain.com",
|
|
170
|
+
cache_backend=RedisCacheBackend(redis.Redis()),
|
|
171
|
+
)
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
> **Important:** Custom backends MUST be thread-safe and SHOULD be
|
|
175
|
+
> picklable-friendly. Async clients call backend methods via
|
|
176
|
+
> `asyncio.to_thread` automatically when the backend is not an
|
|
177
|
+
> `InMemoryTTLCache` instance.
|
|
178
|
+
|
|
179
|
+
## License verification semantics
|
|
180
|
+
|
|
181
|
+
`verify_license()` works **offline after the first JWKS fetch**. The SDK fetches
|
|
182
|
+
`/api/v1/.well-known/jwks.json` once (default TTL: 3600 s), caches RSA public
|
|
183
|
+
keys by `kid`, and verifies subsequent tokens locally.
|
|
184
|
+
|
|
185
|
+
Key rotation is handled transparently: if a token references an unknown `kid`, the
|
|
186
|
+
SDK forces a JWKS refresh before raising. `LicenseInvalid.reason` codes:
|
|
187
|
+
|
|
188
|
+
| Reason | Meaning |
|
|
189
|
+
|--------|---------|
|
|
190
|
+
| `expired` | Token has passed its `exp` claim |
|
|
191
|
+
| `bad_signature` | Signature verification failed |
|
|
192
|
+
| `wrong_issuer` | `iss` claim does not match |
|
|
193
|
+
| `wrong_audience` | `aud` claim does not match |
|
|
194
|
+
| `unknown_kid` | No signing key found for the token's `kid` |
|
|
195
|
+
| `malformed` | Token is structurally invalid or claims fail validation |
|
|
196
|
+
| `jwks_unavailable` | CP JWKS endpoint could not be reached |
|
|
197
|
+
|
|
198
|
+
## Revision-aware whoami
|
|
199
|
+
|
|
200
|
+
`whoami()` caches responses keyed by `(provider, subject, workspace_id, rbac_revision)`.
|
|
201
|
+
When CP increments `rbac_revision` for a workspace (on role/permission change), the
|
|
202
|
+
pointer entry expires or mismatches, causing the next call to re-fetch automatically.
|
|
203
|
+
|
|
204
|
+
No explicit cache invalidation is needed on the DP side.
|
|
205
|
+
|
|
206
|
+
> **Note:** During the Plan 02 rollout window, CP may omit `rbac_revision`. The
|
|
207
|
+
> SDK degrades gracefully to TTL-only caching in that case.
|
|
208
|
+
|
|
209
|
+
## Versioning & compatibility
|
|
210
|
+
|
|
211
|
+
`odcp-client 1.x` requires `odcp-contracts>=2.2,<3`. The public API is frozen at
|
|
212
|
+
v1.0:
|
|
213
|
+
|
|
214
|
+
```python
|
|
215
|
+
from odcp_client import OdcpClient, AsyncOdcpClient, OdcpError, LicenseInvalid, WhoamiNotFound
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Everything else (modules with a leading underscore, internal classes) may change
|
|
219
|
+
without a major version bump.
|
|
220
|
+
|
|
221
|
+
## Development
|
|
222
|
+
|
|
223
|
+
```bash
|
|
224
|
+
git clone https://github.com/owndivision/owndivision-control-plane
|
|
225
|
+
cd owndivision-control-plane
|
|
226
|
+
pip install -e odcp_client[dev] -e .
|
|
227
|
+
pytest tests/odcp_client --cov=odcp_client --cov-fail-under=90
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
See [docs/plans/06_odcp_client_package.md](../docs/plans/06_odcp_client_package.md)
|
|
231
|
+
and [docs/specs/06_odcp_client_package/](../docs/specs/06_odcp_client_package/) for
|
|
232
|
+
implementation details.
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# odcp-client
|
|
2
|
+
|
|
3
|
+
Official Python SDK for the Owndivision Control Plane. Provides offline-capable
|
|
4
|
+
license JWT verification with JWKS caching, whoami lookup with revision-aware
|
|
5
|
+
cache invalidation, and branding retrieval — all with both sync and async clients.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install odcp-client
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Requires Python 3.12+. Runtime dependencies: `odcp-contracts>=2.2,<3`, `httpx`,
|
|
14
|
+
`pyjwt`, `cryptography`.
|
|
15
|
+
|
|
16
|
+
## Quickstart (sync)
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
from odcp_client import OdcpClient, LicenseInvalid, WhoamiNotFound
|
|
20
|
+
|
|
21
|
+
client = OdcpClient(
|
|
22
|
+
base_url="https://cp.your-domain.com",
|
|
23
|
+
api_token="your-cp-api-token",
|
|
24
|
+
license_token="eyJ...", # the signed license JWT
|
|
25
|
+
# Required when CP is configured with non-default iss/aud — must match
|
|
26
|
+
# CP's CP_SIGNING_ISS / CP_SIGNING_AUD. Defaults are
|
|
27
|
+
# "owndivision-control-plane" / "owndivision-dp".
|
|
28
|
+
signing_issuer="owndivision-control-plane",
|
|
29
|
+
signing_audience="owndivision-dp",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# Verify the license (offline after first JWKS fetch)
|
|
33
|
+
try:
|
|
34
|
+
claims = client.verify_license()
|
|
35
|
+
print(f"Seat cap: {claims.license.seat_cap}")
|
|
36
|
+
except LicenseInvalid as exc:
|
|
37
|
+
print(f"License invalid: {exc.reason}")
|
|
38
|
+
|
|
39
|
+
# Whoami lookup with revision-aware caching
|
|
40
|
+
try:
|
|
41
|
+
whoami = client.whoami(
|
|
42
|
+
provider="auth0",
|
|
43
|
+
subject="auth0|user_id",
|
|
44
|
+
workspace_id="ws-uuid",
|
|
45
|
+
)
|
|
46
|
+
print(whoami.user.email)
|
|
47
|
+
except WhoamiNotFound:
|
|
48
|
+
print("Unknown identity")
|
|
49
|
+
|
|
50
|
+
# Permission check (never raises)
|
|
51
|
+
can_read = client.has_permission(
|
|
52
|
+
provider="auth0",
|
|
53
|
+
subject="auth0|user_id",
|
|
54
|
+
workspace_id="ws-uuid",
|
|
55
|
+
permission="workspace.read",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
client.close() # or use as a context manager: `with OdcpClient(...) as client:`
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Quickstart (async)
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
import asyncio
|
|
65
|
+
from odcp_client import AsyncOdcpClient
|
|
66
|
+
|
|
67
|
+
async def main() -> None:
|
|
68
|
+
async with AsyncOdcpClient(
|
|
69
|
+
base_url="https://cp.your-domain.com",
|
|
70
|
+
api_token="your-cp-api-token",
|
|
71
|
+
license_token="eyJ...",
|
|
72
|
+
) as client:
|
|
73
|
+
claims = await client.verify_license()
|
|
74
|
+
whoami = await client.whoami(
|
|
75
|
+
provider="auth0", subject="auth0|user_id", workspace_id="ws-uuid",
|
|
76
|
+
)
|
|
77
|
+
can_write = await client.has_permission(
|
|
78
|
+
provider="auth0",
|
|
79
|
+
subject="auth0|user_id",
|
|
80
|
+
workspace_id="ws-uuid",
|
|
81
|
+
permission="workspace.write",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
asyncio.run(main())
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## FastAPI dependency recipe
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
from fastapi import Depends, HTTPException, Request
|
|
91
|
+
from odcp_client import AsyncOdcpClient, WhoamiNotFound
|
|
92
|
+
from odcp_contracts.whoami import WhoAmIResponse
|
|
93
|
+
|
|
94
|
+
# Construct once at app startup (shared across requests).
|
|
95
|
+
odcp = AsyncOdcpClient(
|
|
96
|
+
base_url=settings.CP_BASE_URL,
|
|
97
|
+
api_token=settings.CP_API_TOKEN,
|
|
98
|
+
license_token=settings.LICENSE_TOKEN,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
async def get_current_user(request: Request) -> WhoAmIResponse:
|
|
103
|
+
sub = extract_auth0_sub(request) # your Auth0 subject extractor
|
|
104
|
+
ws_id = extract_workspace_from_license(request) # your workspace resolver
|
|
105
|
+
try:
|
|
106
|
+
return await odcp.whoami(
|
|
107
|
+
provider="auth0", subject=sub, workspace_id=ws_id
|
|
108
|
+
)
|
|
109
|
+
except WhoamiNotFound:
|
|
110
|
+
raise HTTPException(status_code=401, detail="unknown_identity")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@app.get("/protected")
|
|
114
|
+
async def protected(user: WhoAmIResponse = Depends(get_current_user)) -> dict:
|
|
115
|
+
return {"email": user.user.email}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Cache backends
|
|
119
|
+
|
|
120
|
+
The default backend is an in-process LRU+TTL cache (`InMemoryTTLCache`). For
|
|
121
|
+
multi-process deployments, swap in a Redis backend:
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
import pickle
|
|
125
|
+
import redis
|
|
126
|
+
|
|
127
|
+
class RedisCacheBackend:
|
|
128
|
+
def __init__(self, redis_client: redis.Redis) -> None:
|
|
129
|
+
self._r = redis_client
|
|
130
|
+
|
|
131
|
+
def get(self, key: str):
|
|
132
|
+
value = self._r.get(key)
|
|
133
|
+
return None if value is None else pickle.loads(value)
|
|
134
|
+
|
|
135
|
+
def set(self, key: str, value, ttl_seconds: int) -> None:
|
|
136
|
+
self._r.setex(key, ttl_seconds, pickle.dumps(value))
|
|
137
|
+
|
|
138
|
+
def delete(self, key: str) -> None:
|
|
139
|
+
self._r.delete(key)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
client = OdcpClient(
|
|
143
|
+
base_url="https://cp.your-domain.com",
|
|
144
|
+
cache_backend=RedisCacheBackend(redis.Redis()),
|
|
145
|
+
)
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
> **Important:** Custom backends MUST be thread-safe and SHOULD be
|
|
149
|
+
> picklable-friendly. Async clients call backend methods via
|
|
150
|
+
> `asyncio.to_thread` automatically when the backend is not an
|
|
151
|
+
> `InMemoryTTLCache` instance.
|
|
152
|
+
|
|
153
|
+
## License verification semantics
|
|
154
|
+
|
|
155
|
+
`verify_license()` works **offline after the first JWKS fetch**. The SDK fetches
|
|
156
|
+
`/api/v1/.well-known/jwks.json` once (default TTL: 3600 s), caches RSA public
|
|
157
|
+
keys by `kid`, and verifies subsequent tokens locally.
|
|
158
|
+
|
|
159
|
+
Key rotation is handled transparently: if a token references an unknown `kid`, the
|
|
160
|
+
SDK forces a JWKS refresh before raising. `LicenseInvalid.reason` codes:
|
|
161
|
+
|
|
162
|
+
| Reason | Meaning |
|
|
163
|
+
|--------|---------|
|
|
164
|
+
| `expired` | Token has passed its `exp` claim |
|
|
165
|
+
| `bad_signature` | Signature verification failed |
|
|
166
|
+
| `wrong_issuer` | `iss` claim does not match |
|
|
167
|
+
| `wrong_audience` | `aud` claim does not match |
|
|
168
|
+
| `unknown_kid` | No signing key found for the token's `kid` |
|
|
169
|
+
| `malformed` | Token is structurally invalid or claims fail validation |
|
|
170
|
+
| `jwks_unavailable` | CP JWKS endpoint could not be reached |
|
|
171
|
+
|
|
172
|
+
## Revision-aware whoami
|
|
173
|
+
|
|
174
|
+
`whoami()` caches responses keyed by `(provider, subject, workspace_id, rbac_revision)`.
|
|
175
|
+
When CP increments `rbac_revision` for a workspace (on role/permission change), the
|
|
176
|
+
pointer entry expires or mismatches, causing the next call to re-fetch automatically.
|
|
177
|
+
|
|
178
|
+
No explicit cache invalidation is needed on the DP side.
|
|
179
|
+
|
|
180
|
+
> **Note:** During the Plan 02 rollout window, CP may omit `rbac_revision`. The
|
|
181
|
+
> SDK degrades gracefully to TTL-only caching in that case.
|
|
182
|
+
|
|
183
|
+
## Versioning & compatibility
|
|
184
|
+
|
|
185
|
+
`odcp-client 1.x` requires `odcp-contracts>=2.2,<3`. The public API is frozen at
|
|
186
|
+
v1.0:
|
|
187
|
+
|
|
188
|
+
```python
|
|
189
|
+
from odcp_client import OdcpClient, AsyncOdcpClient, OdcpError, LicenseInvalid, WhoamiNotFound
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Everything else (modules with a leading underscore, internal classes) may change
|
|
193
|
+
without a major version bump.
|
|
194
|
+
|
|
195
|
+
## Development
|
|
196
|
+
|
|
197
|
+
```bash
|
|
198
|
+
git clone https://github.com/owndivision/owndivision-control-plane
|
|
199
|
+
cd owndivision-control-plane
|
|
200
|
+
pip install -e odcp_client[dev] -e .
|
|
201
|
+
pytest tests/odcp_client --cov=odcp_client --cov-fail-under=90
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
See [docs/plans/06_odcp_client_package.md](../docs/plans/06_odcp_client_package.md)
|
|
205
|
+
and [docs/specs/06_odcp_client_package/](../docs/specs/06_odcp_client_package/) for
|
|
206
|
+
implementation details.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "odcp-client"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Official Python SDK for the Owndivision Control Plane (license verify, JWKS, whoami caching)."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
license = { text = "Proprietary" }
|
|
12
|
+
authors = [{ name = "Owndivision" }]
|
|
13
|
+
keywords = ["owndivision", "control-plane", "sdk"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3.12",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
]
|
|
20
|
+
dependencies = [
|
|
21
|
+
"odcp-contracts>=2.2,<3",
|
|
22
|
+
"httpx>=0.27,<0.29",
|
|
23
|
+
"pyjwt>=2.10.1,<3",
|
|
24
|
+
"cryptography>=42",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
dev = [
|
|
29
|
+
"pytest>=8",
|
|
30
|
+
"pytest-asyncio>=0.23",
|
|
31
|
+
"pytest-cov>=5",
|
|
32
|
+
"respx>=0.21",
|
|
33
|
+
"freezegun>=1.5",
|
|
34
|
+
"ruff>=0.6",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[project.urls]
|
|
38
|
+
Homepage = "https://github.com/owndivision/owndivision-control-plane"
|
|
39
|
+
|
|
40
|
+
# src/ layout. The previous flat layout mapped the package onto the project
|
|
41
|
+
# directory itself (package-dir = {"odcp_client" = "."}), which produced an
|
|
42
|
+
# editable install with no real package directory on sys.path. Under pytest the
|
|
43
|
+
# sibling test directory tests/odcp_client/ - which has no __init__.py - was
|
|
44
|
+
# then picked up as a namespace package of the same name and shadowed the real
|
|
45
|
+
# one, so `from odcp_client import AsyncOdcpClient` failed with
|
|
46
|
+
# "cannot import name ... (unknown location)". A src/ layout keeps the
|
|
47
|
+
# importable package in exactly one place.
|
|
48
|
+
[tool.setuptools]
|
|
49
|
+
package-dir = {"" = "src"}
|
|
50
|
+
|
|
51
|
+
[tool.setuptools.packages.find]
|
|
52
|
+
where = ["src"]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""odcp-client — official Python SDK for the Owndivision Control Plane.
|
|
2
|
+
|
|
3
|
+
Public API is frozen at v1.0. Only the symbols re-exported below are
|
|
4
|
+
considered stable. Anything else (modules with leading underscore, internal
|
|
5
|
+
classes) may change without a major version bump.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from odcp_client.client import AsyncOdcpClient, OdcpClient
|
|
9
|
+
from odcp_client.exceptions import (
|
|
10
|
+
LicenseInvalid,
|
|
11
|
+
OdcpError,
|
|
12
|
+
WhoamiNotFound,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"OdcpClient",
|
|
17
|
+
"AsyncOdcpClient",
|
|
18
|
+
"OdcpError",
|
|
19
|
+
"LicenseInvalid",
|
|
20
|
+
"WhoamiNotFound",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
__version__ = "1.0.0"
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Internal HTTP helpers — sync + async with bounded retries.
|
|
2
|
+
|
|
3
|
+
Retry policy:
|
|
4
|
+
- On ``httpx.ConnectError``, ``httpx.ReadTimeout``, or 5xx response
|
|
5
|
+
- Up to ``retry_max`` additional attempts (so total = retry_max + 1)
|
|
6
|
+
- Backoff: ``min(0.1 * 2**attempt, 2.0)`` seconds
|
|
7
|
+
- Never retry on 4xx
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import time
|
|
14
|
+
from typing import Any, Mapping
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
_RETRYABLE_STATUS = (500, 502, 503, 504)
|
|
19
|
+
_BACKOFF_BASE_SECONDS = 0.1
|
|
20
|
+
_BACKOFF_MAX_SECONDS = 2.0
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _backoff_seconds(attempt: int) -> float:
|
|
24
|
+
return min(_BACKOFF_BASE_SECONDS * (2**attempt), _BACKOFF_MAX_SECONDS)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _auth_headers(api_token: str | None) -> dict[str, str]:
|
|
28
|
+
if not api_token:
|
|
29
|
+
return {}
|
|
30
|
+
return {"Authorization": f"Bearer {api_token}"}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SyncHttpClient:
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
*,
|
|
37
|
+
base_url: str,
|
|
38
|
+
api_token: str | None,
|
|
39
|
+
timeout: float,
|
|
40
|
+
retry_max: int,
|
|
41
|
+
) -> None:
|
|
42
|
+
self._client = httpx.Client(base_url=base_url, timeout=timeout)
|
|
43
|
+
self._api_token = api_token
|
|
44
|
+
self._retry_max = retry_max
|
|
45
|
+
|
|
46
|
+
def close(self) -> None:
|
|
47
|
+
self._client.close()
|
|
48
|
+
|
|
49
|
+
def request(
|
|
50
|
+
self,
|
|
51
|
+
method: str,
|
|
52
|
+
url: str,
|
|
53
|
+
*,
|
|
54
|
+
headers: Mapping[str, str] | None = None,
|
|
55
|
+
params: Mapping[str, Any] | None = None,
|
|
56
|
+
json: Any | None = None,
|
|
57
|
+
authenticated: bool = True,
|
|
58
|
+
) -> httpx.Response:
|
|
59
|
+
merged_headers: dict[str, str] = {}
|
|
60
|
+
if authenticated:
|
|
61
|
+
merged_headers.update(_auth_headers(self._api_token))
|
|
62
|
+
if headers:
|
|
63
|
+
merged_headers.update(headers)
|
|
64
|
+
|
|
65
|
+
last_exc: Exception | None = None
|
|
66
|
+
for attempt in range(self._retry_max + 1):
|
|
67
|
+
try:
|
|
68
|
+
response = self._client.request(
|
|
69
|
+
method,
|
|
70
|
+
url,
|
|
71
|
+
headers=merged_headers,
|
|
72
|
+
params=params,
|
|
73
|
+
json=json,
|
|
74
|
+
)
|
|
75
|
+
except (httpx.ConnectError, httpx.ReadTimeout) as exc:
|
|
76
|
+
last_exc = exc
|
|
77
|
+
if attempt >= self._retry_max:
|
|
78
|
+
raise
|
|
79
|
+
time.sleep(_backoff_seconds(attempt))
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
if response.status_code in _RETRYABLE_STATUS and attempt < self._retry_max:
|
|
83
|
+
time.sleep(_backoff_seconds(attempt))
|
|
84
|
+
continue
|
|
85
|
+
return response
|
|
86
|
+
|
|
87
|
+
# Unreachable but keeps mypy happy.
|
|
88
|
+
assert last_exc is not None
|
|
89
|
+
raise last_exc
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class AsyncHttpClient:
|
|
93
|
+
def __init__(
|
|
94
|
+
self,
|
|
95
|
+
*,
|
|
96
|
+
base_url: str,
|
|
97
|
+
api_token: str | None,
|
|
98
|
+
timeout: float,
|
|
99
|
+
retry_max: int,
|
|
100
|
+
) -> None:
|
|
101
|
+
self._client = httpx.AsyncClient(base_url=base_url, timeout=timeout)
|
|
102
|
+
self._api_token = api_token
|
|
103
|
+
self._retry_max = retry_max
|
|
104
|
+
|
|
105
|
+
async def aclose(self) -> None:
|
|
106
|
+
await self._client.aclose()
|
|
107
|
+
|
|
108
|
+
async def request(
|
|
109
|
+
self,
|
|
110
|
+
method: str,
|
|
111
|
+
url: str,
|
|
112
|
+
*,
|
|
113
|
+
headers: Mapping[str, str] | None = None,
|
|
114
|
+
params: Mapping[str, Any] | None = None,
|
|
115
|
+
json: Any | None = None,
|
|
116
|
+
authenticated: bool = True,
|
|
117
|
+
) -> httpx.Response:
|
|
118
|
+
merged_headers: dict[str, str] = {}
|
|
119
|
+
if authenticated:
|
|
120
|
+
merged_headers.update(_auth_headers(self._api_token))
|
|
121
|
+
if headers:
|
|
122
|
+
merged_headers.update(headers)
|
|
123
|
+
|
|
124
|
+
last_exc: Exception | None = None
|
|
125
|
+
for attempt in range(self._retry_max + 1):
|
|
126
|
+
try:
|
|
127
|
+
response = await self._client.request(
|
|
128
|
+
method,
|
|
129
|
+
url,
|
|
130
|
+
headers=merged_headers,
|
|
131
|
+
params=params,
|
|
132
|
+
json=json,
|
|
133
|
+
)
|
|
134
|
+
except (httpx.ConnectError, httpx.ReadTimeout) as exc:
|
|
135
|
+
last_exc = exc
|
|
136
|
+
if attempt >= self._retry_max:
|
|
137
|
+
raise
|
|
138
|
+
await asyncio.sleep(_backoff_seconds(attempt))
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
if response.status_code in _RETRYABLE_STATUS and attempt < self._retry_max:
|
|
142
|
+
await asyncio.sleep(_backoff_seconds(attempt))
|
|
143
|
+
continue
|
|
144
|
+
return response
|
|
145
|
+
|
|
146
|
+
assert last_exc is not None
|
|
147
|
+
raise last_exc
|