python-config-client 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- config_client/__init__.py +49 -0
- config_client/_sse.py +101 -0
- config_client/client.py +520 -0
- config_client/crypto.py +75 -0
- config_client/errors.py +68 -0
- config_client/options.py +96 -0
- config_client/snapshot.py +50 -0
- config_client/transport.py +207 -0
- config_client/types.py +62 -0
- python_config_client-0.1.2.dist-info/METADATA +315 -0
- python_config_client-0.1.2.dist-info/RECORD +12 -0
- python_config_client-0.1.2.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: python-config-client
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Python client SDK for Config Service
|
|
5
|
+
Project-URL: Homepage, https://github.com/holdemlab/python-config-client
|
|
6
|
+
Project-URL: Repository, https://github.com/holdemlab/python-config-client
|
|
7
|
+
Project-URL: Issues, https://github.com/holdemlab/python-config-client/issues
|
|
8
|
+
Author-email: Maks <maksymchuk.mm@gmail.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
Keywords: aes-gcm,client,config,sdk,sse
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Requires-Dist: cryptography>=42
|
|
22
|
+
Requires-Dist: httpx-sse>=0.4
|
|
23
|
+
Requires-Dist: httpx>=0.27
|
|
24
|
+
Provides-Extra: pydantic
|
|
25
|
+
Requires-Dist: pydantic>=2.0; extra == 'pydantic'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# python-config-client
|
|
29
|
+
|
|
30
|
+
Python SDK for Config Service — fetch, decrypt, and hot-reload service configurations with AES-256-GCM encryption.
|
|
31
|
+
|
|
32
|
+
[](https://www.python.org/)
|
|
33
|
+
[](LICENSE)
|
|
34
|
+
[](https://mypy.readthedocs.io/)
|
|
35
|
+
|
|
36
|
+
## Features
|
|
37
|
+
|
|
38
|
+
- **AES-256-GCM decryption** — compatible with Go SDK's wire format
|
|
39
|
+
- **Sync HTTP client** via `httpx` with configurable retry + exponential backoff
|
|
40
|
+
- **SSE watch mode** with auto-reconnect and backoff
|
|
41
|
+
- **Thread-safe `Snapshot[T]`** for zero-lock hot-reload reads
|
|
42
|
+
- **Flexible deserialization** — `dataclass`, Pydantic v2 `BaseModel`, or `dict`
|
|
43
|
+
- **Full `mypy --strict` typing** — no `Any` leaks in the public API
|
|
44
|
+
|
|
45
|
+
## Requirements
|
|
46
|
+
|
|
47
|
+
- Python ≥ 3.10
|
|
48
|
+
- A running Config Service with a valid service token and encryption key
|
|
49
|
+
|
|
50
|
+
## Installation
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pip install python-config-client
|
|
54
|
+
|
|
55
|
+
# With Pydantic v2 support
|
|
56
|
+
pip install "python-config-client[pydantic]"
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Quick Start
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
import os
|
|
63
|
+
from dataclasses import dataclass
|
|
64
|
+
from config_client import ConfigClient, Options
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class AppConfig:
|
|
68
|
+
log_level: str
|
|
69
|
+
debug: bool
|
|
70
|
+
|
|
71
|
+
with ConfigClient(Options(
|
|
72
|
+
host=os.environ["CONFIG_SERVICE_HOST"],
|
|
73
|
+
service_token=os.environ["CONFIG_SERVICE_TOKEN"],
|
|
74
|
+
encryption_key=os.environ["CONFIG_SERVICE_KEY"],
|
|
75
|
+
)) as client:
|
|
76
|
+
cfg = client.get("my-service", AppConfig)
|
|
77
|
+
print(cfg.log_level)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Configuration
|
|
81
|
+
|
|
82
|
+
### `Options`
|
|
83
|
+
|
|
84
|
+
| Field | Type | Default | Description |
|
|
85
|
+
|-------|------|---------|-------------|
|
|
86
|
+
| `host` | `str` | required | Base URL of Config Service, e.g. `https://config.example.com` |
|
|
87
|
+
| `service_token` | `str` | required | Plain-text service token (sent as `X-Service-Token`) |
|
|
88
|
+
| `encryption_key` | `str` | required | 64-character hex string representing a 32-byte AES-256 key |
|
|
89
|
+
| `request_timeout` | `float` | `10.0` | Per-request timeout in seconds |
|
|
90
|
+
| `retry_count` | `int` | `3` | Number of retry attempts for 5xx / network errors |
|
|
91
|
+
| `retry_delay` | `float` | `1.0` | Base delay for exponential backoff (seconds) |
|
|
92
|
+
| `on_error` | `Callable[[Exception], None] \| None` | `None` | Invoked on watch errors; does not stop the loop |
|
|
93
|
+
| `on_change` | `Callable[[str], None] \| None` | `None` | Invoked with config name on each received change event |
|
|
94
|
+
| `http_client` | `httpx.Client \| None` | `None` | BYO client (caller owns lifecycle) |
|
|
95
|
+
|
|
96
|
+
### Environment Variables
|
|
97
|
+
|
|
98
|
+
Load all three required options from the environment with `from_env()`:
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
from config_client import ConfigClient
|
|
102
|
+
|
|
103
|
+
client = ConfigClient.from_env()
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
| Variable | Description |
|
|
107
|
+
|----------|-------------|
|
|
108
|
+
| `CONFIG_SERVICE_HOST` | Base URL of Config Service |
|
|
109
|
+
| `CONFIG_SERVICE_TOKEN` | Plain-text service token |
|
|
110
|
+
| `CONFIG_SERVICE_KEY` | AES-256 encryption key (64 hex chars) |
|
|
111
|
+
|
|
112
|
+
### `GetOptions`
|
|
113
|
+
|
|
114
|
+
Per-request overrides for `get()`, `get_raw()`, `get_bytes()`:
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
from config_client import GetOptions
|
|
118
|
+
|
|
119
|
+
cfg = client.get("my-service", AppConfig, GetOptions(environment="production", version=5))
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
| Field | Type | Default | Description |
|
|
123
|
+
|-------|------|---------|-------------|
|
|
124
|
+
| `environment` | `str` | `""` | Environment override (e.g. `"production"`) |
|
|
125
|
+
| `version` | `int` | `0` | Specific version to fetch (`0` = latest) |
|
|
126
|
+
|
|
127
|
+
## API Reference
|
|
128
|
+
|
|
129
|
+
### `ConfigClient`
|
|
130
|
+
|
|
131
|
+
#### `get(config_name, target_type, opts=None) → T`
|
|
132
|
+
|
|
133
|
+
Fetch, decrypt (AES-256-GCM), and deserialize a configuration. Dispatches to:
|
|
134
|
+
- `pydantic.BaseModel.model_validate(data)` — for Pydantic v2 models
|
|
135
|
+
- Recursive dataclass construction — for `@dataclass` types
|
|
136
|
+
- Identity passthrough — for `dict`
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
cfg = client.get("my-service", AppConfig)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
#### `get_raw(config_name, opts=None) → dict[str, object]`
|
|
143
|
+
|
|
144
|
+
Decrypt and return the raw JSON as a `dict`.
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
data = client.get_raw("my-service")
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
#### `get_bytes(config_name, opts=None) → bytes`
|
|
151
|
+
|
|
152
|
+
Decrypt and return raw JSON bytes (no parsing).
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
raw = client.get_bytes("my-service")
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
#### `get_formatted(config_name, fmt) → bytes`
|
|
159
|
+
|
|
160
|
+
Fetch a config in plaintext via the `/formatted` endpoint. No decryption.
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
from config_client import Format
|
|
164
|
+
|
|
165
|
+
yaml_bytes = client.get_formatted("my-service", Format.YAML)
|
|
166
|
+
env_bytes = client.get_formatted("my-service", Format.ENV)
|
|
167
|
+
json_bytes = client.get_formatted("my-service", Format.JSON)
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
#### `list() → list[ConfigInfo]`
|
|
171
|
+
|
|
172
|
+
Return metadata for all configurations accessible to this token.
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
for info in client.list():
|
|
176
|
+
print(info.name, info.is_valid, info.updated_at)
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
`ConfigInfo` fields: `name: str`, `is_valid: bool`, `valid_from: datetime`, `updated_at: datetime`.
|
|
180
|
+
|
|
181
|
+
#### `watch(config_name, callback)`
|
|
182
|
+
|
|
183
|
+
Subscribe to SSE change events. Blocks the calling thread. Auto-reconnects.
|
|
184
|
+
|
|
185
|
+
#### `watch_and_decode(config_name, target_type, snapshot, opts=None)`
|
|
186
|
+
|
|
187
|
+
Watch for changes and update a `Snapshot[T]` on every change. Blocks.
|
|
188
|
+
|
|
189
|
+
#### `from_env() → ConfigClient`
|
|
190
|
+
|
|
191
|
+
Class method. Creates a client from `CONFIG_SERVICE_HOST`, `CONFIG_SERVICE_TOKEN`, `CONFIG_SERVICE_KEY`.
|
|
192
|
+
|
|
193
|
+
#### `close()`
|
|
194
|
+
|
|
195
|
+
Stop all watch loops and close the HTTP transport. Also called by `__exit__`.
|
|
196
|
+
|
|
197
|
+
## Watch & Hot-Reload
|
|
198
|
+
|
|
199
|
+
Use `watch_and_decode` inside a daemon thread to keep a `Snapshot` up to date without blocking the main thread:
|
|
200
|
+
|
|
201
|
+
```python
|
|
202
|
+
import threading
|
|
203
|
+
from dataclasses import dataclass
|
|
204
|
+
from config_client import ConfigClient, Options, Snapshot
|
|
205
|
+
|
|
206
|
+
@dataclass
|
|
207
|
+
class FeatureFlags:
|
|
208
|
+
dark_mode: bool
|
|
209
|
+
max_connections: int
|
|
210
|
+
|
|
211
|
+
snapshot: Snapshot[FeatureFlags] = Snapshot()
|
|
212
|
+
client = ConfigClient.from_env()
|
|
213
|
+
|
|
214
|
+
def _watcher() -> None:
|
|
215
|
+
client.watch_and_decode("feature-flags", FeatureFlags, snapshot)
|
|
216
|
+
|
|
217
|
+
t = threading.Thread(target=_watcher, daemon=True)
|
|
218
|
+
t.start()
|
|
219
|
+
|
|
220
|
+
# At any point — zero-lock read:
|
|
221
|
+
flags = snapshot.load()
|
|
222
|
+
if flags is not None and flags.dark_mode:
|
|
223
|
+
print("dark mode enabled")
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Call `client.close()` to stop the watcher thread gracefully.
|
|
227
|
+
|
|
228
|
+
## Error Handling
|
|
229
|
+
|
|
230
|
+
| Exception | HTTP status / cause |
|
|
231
|
+
|-----------|---------------------|
|
|
232
|
+
| `UnauthorizedError` | HTTP 401 |
|
|
233
|
+
| `ForbiddenError` | HTTP 403 |
|
|
234
|
+
| `NotFoundError` | HTTP 404 |
|
|
235
|
+
| `InvalidResponseError` | Any other 4xx |
|
|
236
|
+
| `ConnectionError` | Network error or 5xx after all retries exhausted |
|
|
237
|
+
| `DecryptionError` | AES-GCM authentication failure or malformed ciphertext |
|
|
238
|
+
| `UnmarshalError` | JSON → target type deserialization failed |
|
|
239
|
+
| `ConfigClientError` | Base class for all SDK exceptions; also raised for invalid options |
|
|
240
|
+
|
|
241
|
+
```python
|
|
242
|
+
from config_client import ConfigClient, NotFoundError, DecryptionError
|
|
243
|
+
|
|
244
|
+
try:
|
|
245
|
+
cfg = client.get("my-service", AppConfig)
|
|
246
|
+
except NotFoundError:
|
|
247
|
+
print("config not found")
|
|
248
|
+
except DecryptionError:
|
|
249
|
+
print("wrong encryption key or corrupted payload")
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
## Development
|
|
253
|
+
|
|
254
|
+
### Requirements
|
|
255
|
+
|
|
256
|
+
- [uv](https://github.com/astral-sh/uv) ≥ 0.4
|
|
257
|
+
|
|
258
|
+
### Setup
|
|
259
|
+
|
|
260
|
+
```bash
|
|
261
|
+
git clone https://github.com/holdemlab/python-config-client
|
|
262
|
+
cd python-config-client
|
|
263
|
+
make install
|
|
264
|
+
cp .env.example .env # fill in real values for integration tests
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
### Makefile Commands
|
|
268
|
+
|
|
269
|
+
| Command | Description |
|
|
270
|
+
|---------|-------------|
|
|
271
|
+
| `make install` | Install all dependencies (including dev) |
|
|
272
|
+
| `make test` | Run unit + integration tests with coverage |
|
|
273
|
+
| `make unit` | Unit tests only |
|
|
274
|
+
| `make lint` | `ruff check` + `ruff format --check` |
|
|
275
|
+
| `make fmt` | Auto-format with `ruff format` |
|
|
276
|
+
| `make typecheck` | `mypy config_client --strict` |
|
|
277
|
+
| `make security` | `bandit -r config_client` + `uv audit` |
|
|
278
|
+
| `make build` | Build sdist + wheel (`uv build`) |
|
|
279
|
+
| `make docker-up` | Start postgres + app via docker-compose |
|
|
280
|
+
| `make docker-down` | Stop containers |
|
|
281
|
+
|
|
282
|
+
### Running Tests
|
|
283
|
+
|
|
284
|
+
```bash
|
|
285
|
+
# All unit tests (fast, no external services):
|
|
286
|
+
make unit
|
|
287
|
+
|
|
288
|
+
# Integration tests (requires CONFIG_SERVICE_* env vars):
|
|
289
|
+
make integration
|
|
290
|
+
|
|
291
|
+
# Full suite with coverage report:
|
|
292
|
+
make test
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
### Project Structure
|
|
296
|
+
|
|
297
|
+
```
|
|
298
|
+
config_client/
|
|
299
|
+
__init__.py # public API surface
|
|
300
|
+
client.py # ConfigClient main class
|
|
301
|
+
options.py # Options / GetOptions dataclasses
|
|
302
|
+
types.py # ConfigInfo, ConfigChangeEvent, Format
|
|
303
|
+
errors.py # exception hierarchy
|
|
304
|
+
crypto.py # AES-256-GCM decrypt + parse_encryption_key
|
|
305
|
+
transport.py # httpx transport with retry + backoff
|
|
306
|
+
_sse.py # internal SSE reader (private)
|
|
307
|
+
snapshot.py # thread-safe Snapshot[T]
|
|
308
|
+
|
|
309
|
+
tests/ # pytest suite (97% coverage)
|
|
310
|
+
examples/ # runnable usage examples
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
## License
|
|
314
|
+
|
|
315
|
+
MIT — see [LICENSE](LICENSE) for details.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
config_client/__init__.py,sha256=CHgJWBDHGW7DfmsM6jW90hhjGX0fziEwL7jjgz5hq58,1214
|
|
2
|
+
config_client/_sse.py,sha256=0VRo1TxiIDNviHHY44bB-_K-EXm1y8GzA7F-uhijSLM,3427
|
|
3
|
+
config_client/client.py,sha256=ecpg5eBumqb4p5wxB-_gWhYSNNrofXBd6w3cbmrLqD4,17901
|
|
4
|
+
config_client/crypto.py,sha256=kdOsvJ-ziCcERDhAoOWr4YDHQDZPhy4RQDuXTGNUg8k,2331
|
|
5
|
+
config_client/errors.py,sha256=Q8Y3umQhRRCmD6X4BppHo6dTbW_HBKukY5uXL1hnNkc,2001
|
|
6
|
+
config_client/options.py,sha256=kYDGwdChIauaA22J8ZmLMejGT-WfNwU0VRMDxmTxq_g,3328
|
|
7
|
+
config_client/snapshot.py,sha256=tO7R9JdfrbsSWT6RUX5ovCqTz4EFre9FjRuQqsGZZrU,1453
|
|
8
|
+
config_client/transport.py,sha256=vRsPO8d5mIwpXC6WWBhgtvWO0AmjRptTMTiYRcqGPtI,6960
|
|
9
|
+
config_client/types.py,sha256=5bAL1g1AqGbqpDcW6IFXYx21J3usarZVj4Ic8mwAk-8,1559
|
|
10
|
+
python_config_client-0.1.2.dist-info/METADATA,sha256=HlzMdTB6FMgLDo-UdMpE6Zal5weBFPp4Cwt1rNi_4oA,9681
|
|
11
|
+
python_config_client-0.1.2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
12
|
+
python_config_client-0.1.2.dist-info/RECORD,,
|