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,49 @@
|
|
|
1
|
+
"""python-config-client — Python SDK for Config Service.
|
|
2
|
+
|
|
3
|
+
This package provides a synchronous client for fetching, decrypting,
|
|
4
|
+
and hot-reloading configurations from Config Service.
|
|
5
|
+
|
|
6
|
+
Example usage::
|
|
7
|
+
|
|
8
|
+
from config_client import ConfigClient, Options
|
|
9
|
+
|
|
10
|
+
client = ConfigClient(Options(
|
|
11
|
+
host="https://config.example.com",
|
|
12
|
+
service_token="your-token",
|
|
13
|
+
encryption_key="your-hex-key",
|
|
14
|
+
))
|
|
15
|
+
cfg = client.get("my-service", dict)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from config_client.client import ConfigClient
|
|
19
|
+
from config_client.errors import (
|
|
20
|
+
ConfigClientError,
|
|
21
|
+
ConnectionError,
|
|
22
|
+
DecryptionError,
|
|
23
|
+
ForbiddenError,
|
|
24
|
+
InvalidResponseError,
|
|
25
|
+
NotFoundError,
|
|
26
|
+
UnauthorizedError,
|
|
27
|
+
UnmarshalError,
|
|
28
|
+
)
|
|
29
|
+
from config_client.options import GetOptions, Options
|
|
30
|
+
from config_client.snapshot import Snapshot
|
|
31
|
+
from config_client.types import ConfigChangeEvent, ConfigInfo, Format
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"ConfigClient",
|
|
35
|
+
"Options",
|
|
36
|
+
"GetOptions",
|
|
37
|
+
"ConfigInfo",
|
|
38
|
+
"ConfigChangeEvent",
|
|
39
|
+
"Format",
|
|
40
|
+
"Snapshot",
|
|
41
|
+
"ConfigClientError",
|
|
42
|
+
"UnauthorizedError",
|
|
43
|
+
"ForbiddenError",
|
|
44
|
+
"NotFoundError",
|
|
45
|
+
"DecryptionError",
|
|
46
|
+
"InvalidResponseError",
|
|
47
|
+
"ConnectionError",
|
|
48
|
+
"UnmarshalError",
|
|
49
|
+
]
|
config_client/_sse.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Internal SSE reader — not part of the public API.
|
|
2
|
+
|
|
3
|
+
This module is intentionally private (prefixed with ``_``).
|
|
4
|
+
Do **not** import or re-export its contents from ``config_client/__init__.py``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from collections.abc import Generator
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
import httpx_sse
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger("config_client")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class SSEEvent:
|
|
21
|
+
"""A parsed Server-Sent Event received from the ``/watch`` endpoint.
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
event: The ``event:`` field value (e.g. ``"config_changed"``).
|
|
25
|
+
Defaults to ``"message"`` when absent in the stream.
|
|
26
|
+
data: The ``data:`` field value (raw string payload).
|
|
27
|
+
id: The ``id:`` field value. Empty string when absent.
|
|
28
|
+
retry: The ``retry:`` reconnection hint in milliseconds, or ``None``.
|
|
29
|
+
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
event: str = "message"
|
|
33
|
+
data: str = ""
|
|
34
|
+
id: str = ""
|
|
35
|
+
retry: int | None = field(default=None)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def iter_sse_events(
|
|
39
|
+
client: httpx.Client,
|
|
40
|
+
url: str,
|
|
41
|
+
headers: dict[str, str],
|
|
42
|
+
) -> Generator[SSEEvent, None, None]:
|
|
43
|
+
"""Open an SSE stream and yield parsed :class:`SSEEvent` objects.
|
|
44
|
+
|
|
45
|
+
The generator is designed to be used by the watch layer, which is
|
|
46
|
+
responsible for reconnection and error handling.
|
|
47
|
+
|
|
48
|
+
The underlying ``httpx`` connection is kept open for the lifetime of
|
|
49
|
+
the generator. Callers should either exhaust the generator or call
|
|
50
|
+
``close()`` on it (e.g. via a ``try/finally`` block or ``with``
|
|
51
|
+
statement) to release the connection.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
client: An already-configured ``httpx.Client`` instance. The caller
|
|
55
|
+
owns its lifecycle; this function never closes it.
|
|
56
|
+
url: Fully-qualified URL of the SSE endpoint.
|
|
57
|
+
headers: Additional HTTP headers to include in the request.
|
|
58
|
+
|
|
59
|
+
Yields:
|
|
60
|
+
:class:`SSEEvent` for every valid event received from the stream.
|
|
61
|
+
|
|
62
|
+
Raises:
|
|
63
|
+
httpx.TransportError: On network-level failures (connection reset,
|
|
64
|
+
timeouts, etc.). The caller (watch layer) is responsible for
|
|
65
|
+
catching this and deciding whether to reconnect.
|
|
66
|
+
httpx.HTTPStatusError: When the server returns a non-2xx status on
|
|
67
|
+
the initial connection.
|
|
68
|
+
|
|
69
|
+
"""
|
|
70
|
+
logger.debug("Opening SSE stream url=%s", url)
|
|
71
|
+
|
|
72
|
+
with httpx_sse.connect_sse(client, "GET", url, headers=headers) as event_source:
|
|
73
|
+
event_source.response.raise_for_status()
|
|
74
|
+
|
|
75
|
+
for raw in event_source.iter_sse():
|
|
76
|
+
try:
|
|
77
|
+
retry_ms: int | None = raw.retry if raw.retry is not None else None
|
|
78
|
+
event = SSEEvent(
|
|
79
|
+
event=raw.event or "message",
|
|
80
|
+
data=raw.data,
|
|
81
|
+
id=raw.id or "",
|
|
82
|
+
retry=retry_ms,
|
|
83
|
+
)
|
|
84
|
+
except Exception as exc: # noqa: BLE001 — unexpected malformed event
|
|
85
|
+
logger.warning("Ignoring malformed SSE event: %s", exc)
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
if not event.data:
|
|
89
|
+
# Keep-alive / comment lines — skip silently
|
|
90
|
+
logger.debug("SSE keep-alive received url=%s", url)
|
|
91
|
+
continue
|
|
92
|
+
|
|
93
|
+
logger.debug(
|
|
94
|
+
"SSE event received event=%s id=%s url=%s",
|
|
95
|
+
event.event,
|
|
96
|
+
event.id,
|
|
97
|
+
url,
|
|
98
|
+
)
|
|
99
|
+
yield event
|
|
100
|
+
|
|
101
|
+
logger.warning("SSE stream closed url=%s", url)
|
config_client/client.py
ADDED
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
"""ConfigClient — main entry point for the Config Service SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import threading
|
|
10
|
+
import typing
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from typing import TYPE_CHECKING, Any, TypeVar, cast
|
|
14
|
+
|
|
15
|
+
from config_client.crypto import decrypt, parse_encryption_key
|
|
16
|
+
from config_client.errors import ConfigClientError, UnmarshalError
|
|
17
|
+
from config_client.options import GetOptions, Options
|
|
18
|
+
from config_client.snapshot import Snapshot
|
|
19
|
+
from config_client.transport import Transport, backoff_delay
|
|
20
|
+
from config_client.types import ConfigChangeEvent, ConfigInfo, Format
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
pass # nothing extra needed at runtime from type-only imports
|
|
24
|
+
logger = logging.getLogger("config_client")
|
|
25
|
+
|
|
26
|
+
T = TypeVar("T")
|
|
27
|
+
|
|
28
|
+
_BASE_PATH = "/api/v1/service/configs"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _build_query(opts: GetOptions | None) -> dict[str, str | int]:
|
|
32
|
+
"""Return non-default query parameters from *opts*."""
|
|
33
|
+
params: dict[str, str | int] = {}
|
|
34
|
+
if opts is None:
|
|
35
|
+
return params
|
|
36
|
+
if opts.environment:
|
|
37
|
+
params["environment"] = opts.environment
|
|
38
|
+
if opts.version != 0:
|
|
39
|
+
params["version"] = opts.version
|
|
40
|
+
return params
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _deserialize(data: dict[str, Any], target_type: type[T]) -> T:
|
|
44
|
+
"""Deserialize *data* dict into an instance of *target_type*.
|
|
45
|
+
|
|
46
|
+
Dispatch order:
|
|
47
|
+
1. ``dict`` — return as-is.
|
|
48
|
+
2. Pydantic ``BaseModel`` subclass — ``model_validate(data)``.
|
|
49
|
+
3. ``dataclass`` — recursive construction via :func:`_build_dataclass`.
|
|
50
|
+
4. Anything else — raise :exc:`~config_client.errors.UnmarshalError`.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
data: Decoded JSON dict from the config payload.
|
|
54
|
+
target_type: The type to deserialize into.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
An instance of *target_type*.
|
|
58
|
+
|
|
59
|
+
Raises:
|
|
60
|
+
UnmarshalError: If deserialization fails for any reason.
|
|
61
|
+
|
|
62
|
+
"""
|
|
63
|
+
# 1. dict passthrough
|
|
64
|
+
if target_type is dict:
|
|
65
|
+
return cast(T, data)
|
|
66
|
+
|
|
67
|
+
# 2. Pydantic v2
|
|
68
|
+
try:
|
|
69
|
+
import pydantic # noqa: PLC0415
|
|
70
|
+
|
|
71
|
+
if isinstance(target_type, type) and issubclass(target_type, pydantic.BaseModel):
|
|
72
|
+
try:
|
|
73
|
+
return target_type.model_validate(data)
|
|
74
|
+
except pydantic.ValidationError as exc:
|
|
75
|
+
raise UnmarshalError(
|
|
76
|
+
f"Pydantic validation failed for {target_type.__name__}: {exc}"
|
|
77
|
+
) from exc
|
|
78
|
+
except ImportError:
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
# 3. dataclass
|
|
82
|
+
if dataclasses.is_dataclass(target_type) and isinstance(target_type, type):
|
|
83
|
+
try:
|
|
84
|
+
return cast(T, _build_dataclass(target_type, data))
|
|
85
|
+
except (TypeError, KeyError, ValueError) as exc:
|
|
86
|
+
raise UnmarshalError(
|
|
87
|
+
f"Dataclass construction failed for {target_type.__name__}: {exc}"
|
|
88
|
+
) from exc
|
|
89
|
+
|
|
90
|
+
raise UnmarshalError(
|
|
91
|
+
f"Unsupported target_type {target_type!r}. "
|
|
92
|
+
"Use dict, a dataclass, or a pydantic.BaseModel subclass."
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _build_dataclass(cls: type, data: dict[str, Any]) -> Any: # noqa: ANN401
|
|
97
|
+
"""Recursively construct a dataclass from a nested dict.
|
|
98
|
+
|
|
99
|
+
Uses :func:`typing.get_type_hints` to resolve string annotations
|
|
100
|
+
(produced by ``from __future__ import annotations``) without ``eval``.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
cls: A dataclass type.
|
|
104
|
+
data: Dict of field values (may contain nested dicts).
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
An instance of *cls*.
|
|
108
|
+
|
|
109
|
+
"""
|
|
110
|
+
try:
|
|
111
|
+
hints: dict[str, Any] = typing.get_type_hints(cls)
|
|
112
|
+
except Exception: # noqa: BLE001 — graceful fallback for unresolvable hints
|
|
113
|
+
hints = {}
|
|
114
|
+
|
|
115
|
+
kwargs: dict[str, Any] = {}
|
|
116
|
+
for f in dataclasses.fields(cls):
|
|
117
|
+
value = data[f.name]
|
|
118
|
+
actual_type = hints.get(f.name)
|
|
119
|
+
|
|
120
|
+
if (
|
|
121
|
+
actual_type is not None
|
|
122
|
+
and isinstance(actual_type, type)
|
|
123
|
+
and dataclasses.is_dataclass(actual_type)
|
|
124
|
+
and isinstance(value, dict)
|
|
125
|
+
):
|
|
126
|
+
kwargs[f.name] = _build_dataclass(actual_type, value)
|
|
127
|
+
else:
|
|
128
|
+
kwargs[f.name] = value
|
|
129
|
+
return cls(**kwargs)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _parse_config_info(raw: dict[str, Any]) -> ConfigInfo:
|
|
133
|
+
"""Parse a single ConfigInfo dict from the list endpoint response.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
raw: A dict with ``name``, ``is_valid``, ``valid_from``, ``updated_at`` keys.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
A :class:`~config_client.types.ConfigInfo` instance.
|
|
140
|
+
|
|
141
|
+
"""
|
|
142
|
+
return ConfigInfo(
|
|
143
|
+
name=str(raw["name"]),
|
|
144
|
+
is_valid=bool(raw["is_valid"]),
|
|
145
|
+
valid_from=datetime.fromisoformat(str(raw["valid_from"])).replace(tzinfo=timezone.utc)
|
|
146
|
+
if datetime.fromisoformat(str(raw["valid_from"])).tzinfo is None
|
|
147
|
+
else datetime.fromisoformat(str(raw["valid_from"])),
|
|
148
|
+
updated_at=datetime.fromisoformat(str(raw["updated_at"])).replace(tzinfo=timezone.utc)
|
|
149
|
+
if datetime.fromisoformat(str(raw["updated_at"])).tzinfo is None
|
|
150
|
+
else datetime.fromisoformat(str(raw["updated_at"])),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _parse_change_event(data: str) -> ConfigChangeEvent | None:
|
|
155
|
+
"""Parse a JSON SSE data payload into a :class:`ConfigChangeEvent`.
|
|
156
|
+
|
|
157
|
+
Returns ``None`` when the payload is invalid or missing required fields,
|
|
158
|
+
logging a warning so the caller can skip the event gracefully.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
data: Raw ``data:`` field from an SSE event (JSON string).
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
A :class:`ConfigChangeEvent` or ``None`` if parsing fails.
|
|
165
|
+
|
|
166
|
+
"""
|
|
167
|
+
try:
|
|
168
|
+
raw = json.loads(data)
|
|
169
|
+
return ConfigChangeEvent(
|
|
170
|
+
config_name=str(raw["config_name"]),
|
|
171
|
+
version=int(raw["version"]),
|
|
172
|
+
changed_by=int(raw["changed_by"]),
|
|
173
|
+
timestamp=datetime.fromisoformat(str(raw["timestamp"])).replace(tzinfo=timezone.utc)
|
|
174
|
+
if datetime.fromisoformat(str(raw["timestamp"])).tzinfo is None
|
|
175
|
+
else datetime.fromisoformat(str(raw["timestamp"])),
|
|
176
|
+
)
|
|
177
|
+
except (KeyError, ValueError, TypeError) as exc:
|
|
178
|
+
logger.warning("Could not parse SSE change event data=%r: %s", data, exc)
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class ConfigClient:
|
|
183
|
+
"""Synchronous client for Config Service.
|
|
184
|
+
|
|
185
|
+
Fetches, decrypts, and deserializes service configurations. Supports
|
|
186
|
+
plain-dict, dataclass, and Pydantic v2 deserialization targets.
|
|
187
|
+
|
|
188
|
+
Example::
|
|
189
|
+
|
|
190
|
+
with ConfigClient(Options(
|
|
191
|
+
host="https://config.example.com",
|
|
192
|
+
service_token="tok",
|
|
193
|
+
encryption_key="aa" * 32,
|
|
194
|
+
)) as client:
|
|
195
|
+
cfg = client.get("my-service", AppConfig)
|
|
196
|
+
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
def __init__(self, opts: Options) -> None:
|
|
200
|
+
"""Initialise the client with the given options.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
opts: :class:`~config_client.options.Options` instance. Validation
|
|
204
|
+
happens inside ``Options.__post_init__``; any invalid value
|
|
205
|
+
raises :exc:`~config_client.errors.ConfigClientError` before
|
|
206
|
+
this constructor returns.
|
|
207
|
+
|
|
208
|
+
"""
|
|
209
|
+
# Options validates itself; we just store what we need.
|
|
210
|
+
self._opts = opts
|
|
211
|
+
self._key: bytes = parse_encryption_key(opts.encryption_key)
|
|
212
|
+
|
|
213
|
+
self._transport = Transport(
|
|
214
|
+
host=opts.host,
|
|
215
|
+
service_token=opts.service_token,
|
|
216
|
+
request_timeout=opts.request_timeout,
|
|
217
|
+
retry_count=opts.retry_count,
|
|
218
|
+
retry_delay=opts.retry_delay,
|
|
219
|
+
http_client=opts.http_client,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
# Threading event used to signal watch loops to stop.
|
|
223
|
+
self._stop_event: threading.Event = threading.Event()
|
|
224
|
+
|
|
225
|
+
logger.debug("ConfigClient initialised host=%s", opts.host)
|
|
226
|
+
|
|
227
|
+
# ------------------------------------------------------------------
|
|
228
|
+
# Constructors
|
|
229
|
+
# ------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
@classmethod
|
|
232
|
+
def from_env(cls) -> ConfigClient:
|
|
233
|
+
"""Create a :class:`ConfigClient` from environment variables.
|
|
234
|
+
|
|
235
|
+
Reads ``CONFIG_SERVICE_HOST``, ``CONFIG_SERVICE_TOKEN``, and
|
|
236
|
+
``CONFIG_SERVICE_KEY`` from the process environment.
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
A configured :class:`ConfigClient`.
|
|
240
|
+
|
|
241
|
+
Raises:
|
|
242
|
+
ConfigClientError: If any required environment variable is missing.
|
|
243
|
+
|
|
244
|
+
"""
|
|
245
|
+
required = {
|
|
246
|
+
"host": "CONFIG_SERVICE_HOST",
|
|
247
|
+
"service_token": "CONFIG_SERVICE_TOKEN",
|
|
248
|
+
"encryption_key": "CONFIG_SERVICE_KEY",
|
|
249
|
+
}
|
|
250
|
+
values: dict[str, str] = {}
|
|
251
|
+
for field, var in required.items():
|
|
252
|
+
val = os.environ.get(var)
|
|
253
|
+
if not val:
|
|
254
|
+
raise ConfigClientError(f"Required environment variable {var!r} is not set")
|
|
255
|
+
values[field] = val
|
|
256
|
+
|
|
257
|
+
return cls(Options(**values)) # type: ignore[arg-type]
|
|
258
|
+
|
|
259
|
+
# ------------------------------------------------------------------
|
|
260
|
+
# Lifecycle
|
|
261
|
+
# ------------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
def close(self) -> None:
|
|
264
|
+
"""Stop all active watch loops and close the HTTP transport."""
|
|
265
|
+
self._stop_event.set()
|
|
266
|
+
self._transport.close()
|
|
267
|
+
logger.debug("ConfigClient closed")
|
|
268
|
+
|
|
269
|
+
def __enter__(self) -> ConfigClient:
|
|
270
|
+
"""Support use as a context manager."""
|
|
271
|
+
return self
|
|
272
|
+
|
|
273
|
+
def __exit__(self, *args: object) -> None:
|
|
274
|
+
"""Close on context manager exit."""
|
|
275
|
+
self.close()
|
|
276
|
+
|
|
277
|
+
# ------------------------------------------------------------------
|
|
278
|
+
# Config retrieval
|
|
279
|
+
# ------------------------------------------------------------------
|
|
280
|
+
|
|
281
|
+
def get(
|
|
282
|
+
self,
|
|
283
|
+
config_name: str,
|
|
284
|
+
target_type: type[T],
|
|
285
|
+
opts: GetOptions | None = None,
|
|
286
|
+
) -> T:
|
|
287
|
+
"""Fetch, decrypt, and deserialize a configuration.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
config_name: The configuration name (slug).
|
|
291
|
+
target_type: Deserialization target — ``dict``, a ``dataclass``
|
|
292
|
+
type, or a ``pydantic.BaseModel`` subclass.
|
|
293
|
+
opts: Optional per-request options (environment, version).
|
|
294
|
+
|
|
295
|
+
Returns:
|
|
296
|
+
An instance of *target_type*.
|
|
297
|
+
|
|
298
|
+
Raises:
|
|
299
|
+
UnauthorizedError: HTTP 401.
|
|
300
|
+
ForbiddenError: HTTP 403.
|
|
301
|
+
NotFoundError: HTTP 404.
|
|
302
|
+
ConnectionError: Network / 5xx after retries exhausted.
|
|
303
|
+
DecryptionError: AES-GCM decryption failed.
|
|
304
|
+
UnmarshalError: JSON→type deserialization failed.
|
|
305
|
+
|
|
306
|
+
"""
|
|
307
|
+
path = f"{_BASE_PATH}/{config_name}"
|
|
308
|
+
params = _build_query(opts)
|
|
309
|
+
response = self._transport.get(path, **params)
|
|
310
|
+
|
|
311
|
+
plaintext = decrypt(response.content, self._key)
|
|
312
|
+
return _deserialize(json.loads(plaintext), target_type)
|
|
313
|
+
|
|
314
|
+
def get_raw(
|
|
315
|
+
self,
|
|
316
|
+
config_name: str,
|
|
317
|
+
opts: GetOptions | None = None,
|
|
318
|
+
) -> dict[str, object]:
|
|
319
|
+
"""Fetch and decrypt a configuration, returning a raw ``dict``.
|
|
320
|
+
|
|
321
|
+
Args:
|
|
322
|
+
config_name: The configuration name (slug).
|
|
323
|
+
opts: Optional per-request options.
|
|
324
|
+
|
|
325
|
+
Returns:
|
|
326
|
+
Decrypted JSON payload as a ``dict``.
|
|
327
|
+
|
|
328
|
+
"""
|
|
329
|
+
return cast("dict[str, object]", self.get(config_name, dict, opts))
|
|
330
|
+
|
|
331
|
+
def get_bytes(
|
|
332
|
+
self,
|
|
333
|
+
config_name: str,
|
|
334
|
+
opts: GetOptions | None = None,
|
|
335
|
+
) -> bytes:
|
|
336
|
+
"""Fetch and decrypt a configuration, returning raw JSON bytes.
|
|
337
|
+
|
|
338
|
+
Args:
|
|
339
|
+
config_name: The configuration name (slug).
|
|
340
|
+
opts: Optional per-request options.
|
|
341
|
+
|
|
342
|
+
Returns:
|
|
343
|
+
Decrypted JSON as raw ``bytes`` (not parsed).
|
|
344
|
+
|
|
345
|
+
"""
|
|
346
|
+
path = f"{_BASE_PATH}/{config_name}"
|
|
347
|
+
params = _build_query(opts)
|
|
348
|
+
response = self._transport.get(path, **params)
|
|
349
|
+
return decrypt(response.content, self._key)
|
|
350
|
+
|
|
351
|
+
def get_formatted(self, config_name: str, fmt: Format) -> bytes:
|
|
352
|
+
"""Fetch a configuration in plaintext format (no decryption).
|
|
353
|
+
|
|
354
|
+
Uses the ``/formatted`` endpoint which returns plaintext JSON, YAML,
|
|
355
|
+
or env-file content directly.
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
config_name: The configuration name (slug).
|
|
359
|
+
fmt: Output format — :attr:`Format.JSON`, :attr:`Format.YAML`,
|
|
360
|
+
or :attr:`Format.ENV`.
|
|
361
|
+
|
|
362
|
+
Returns:
|
|
363
|
+
Raw bytes of the formatted configuration.
|
|
364
|
+
|
|
365
|
+
"""
|
|
366
|
+
path = f"{_BASE_PATH}/{config_name}/formatted"
|
|
367
|
+
response = self._transport.get(path, format=fmt.value)
|
|
368
|
+
return response.content
|
|
369
|
+
|
|
370
|
+
def list(self) -> list[ConfigInfo]:
|
|
371
|
+
"""Return metadata for all configurations accessible to this token.
|
|
372
|
+
|
|
373
|
+
Returns:
|
|
374
|
+
A list of :class:`~config_client.types.ConfigInfo` objects.
|
|
375
|
+
|
|
376
|
+
Raises:
|
|
377
|
+
UnauthorizedError: HTTP 401.
|
|
378
|
+
ForbiddenError: HTTP 403.
|
|
379
|
+
ConnectionError: Network / 5xx after retries exhausted.
|
|
380
|
+
InvalidResponseError: Unexpected response format.
|
|
381
|
+
|
|
382
|
+
"""
|
|
383
|
+
response = self._transport.get(_BASE_PATH)
|
|
384
|
+
payload: list[dict[str, Any]] = response.json()
|
|
385
|
+
return [_parse_config_info(item) for item in payload]
|
|
386
|
+
|
|
387
|
+
# ------------------------------------------------------------------
|
|
388
|
+
# Watch & Hot-Reload
|
|
389
|
+
# ------------------------------------------------------------------
|
|
390
|
+
|
|
391
|
+
def watch(
|
|
392
|
+
self,
|
|
393
|
+
config_name: str,
|
|
394
|
+
callback: Callable[[ConfigChangeEvent], None],
|
|
395
|
+
) -> None:
|
|
396
|
+
"""Subscribe to SSE change events for a configuration.
|
|
397
|
+
|
|
398
|
+
Blocks the calling thread until :meth:`close` is called or
|
|
399
|
+
:exc:`KeyboardInterrupt` is raised. Automatically reconnects
|
|
400
|
+
after connection drops using exponential backoff.
|
|
401
|
+
|
|
402
|
+
Args:
|
|
403
|
+
config_name: The configuration name to watch.
|
|
404
|
+
callback: Invoked with a :class:`~config_client.types.ConfigChangeEvent`
|
|
405
|
+
on every change event received from the SSE stream.
|
|
406
|
+
|
|
407
|
+
"""
|
|
408
|
+
from config_client._sse import iter_sse_events # noqa: PLC0415
|
|
409
|
+
|
|
410
|
+
url = f"{self._opts.host.rstrip('/')}{_BASE_PATH}/{config_name}/watch"
|
|
411
|
+
headers = {"X-Service-Token": self._opts.service_token}
|
|
412
|
+
attempt = 0
|
|
413
|
+
max_reconnects = max(self._opts.retry_count, 1)
|
|
414
|
+
|
|
415
|
+
while not self._stop_event.is_set():
|
|
416
|
+
try:
|
|
417
|
+
for event in iter_sse_events(
|
|
418
|
+
self._transport._client, # noqa: SLF001
|
|
419
|
+
url,
|
|
420
|
+
headers,
|
|
421
|
+
):
|
|
422
|
+
if self._stop_event.is_set():
|
|
423
|
+
return
|
|
424
|
+
|
|
425
|
+
change_event = _parse_change_event(event.data)
|
|
426
|
+
if change_event is None:
|
|
427
|
+
continue
|
|
428
|
+
|
|
429
|
+
try:
|
|
430
|
+
callback(change_event)
|
|
431
|
+
except Exception as cb_exc: # noqa: BLE001
|
|
432
|
+
logger.error(
|
|
433
|
+
"watch callback raised an exception config=%s: %s",
|
|
434
|
+
config_name,
|
|
435
|
+
cb_exc,
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
if self._opts.on_change is not None:
|
|
439
|
+
self._opts.on_change(config_name)
|
|
440
|
+
|
|
441
|
+
# Stream ended cleanly — reconnect
|
|
442
|
+
attempt = 0
|
|
443
|
+
logger.warning("SSE stream ended, reconnecting config=%s", config_name)
|
|
444
|
+
|
|
445
|
+
except KeyboardInterrupt:
|
|
446
|
+
logger.info("watch interrupted by user config=%s", config_name)
|
|
447
|
+
return
|
|
448
|
+
|
|
449
|
+
except Exception as exc: # noqa: BLE001
|
|
450
|
+
if self._stop_event.is_set():
|
|
451
|
+
return
|
|
452
|
+
|
|
453
|
+
logger.warning("SSE error config=%s attempt=%d: %s", config_name, attempt, exc)
|
|
454
|
+
|
|
455
|
+
if self._opts.on_error is not None:
|
|
456
|
+
self._opts.on_error(exc)
|
|
457
|
+
|
|
458
|
+
attempt += 1
|
|
459
|
+
if attempt > max_reconnects:
|
|
460
|
+
logger.error(
|
|
461
|
+
"watch giving up after %d attempts config=%s",
|
|
462
|
+
attempt,
|
|
463
|
+
config_name,
|
|
464
|
+
)
|
|
465
|
+
return
|
|
466
|
+
|
|
467
|
+
delay = backoff_delay(attempt, self._opts.retry_delay)
|
|
468
|
+
logger.warning(
|
|
469
|
+
"Reconnecting in %.2fs config=%s attempt=%d/%d",
|
|
470
|
+
delay,
|
|
471
|
+
config_name,
|
|
472
|
+
attempt,
|
|
473
|
+
max_reconnects,
|
|
474
|
+
)
|
|
475
|
+
self._stop_event.wait(timeout=delay)
|
|
476
|
+
|
|
477
|
+
def watch_and_decode(
|
|
478
|
+
self,
|
|
479
|
+
config_name: str,
|
|
480
|
+
target_type: type[T],
|
|
481
|
+
snapshot: Snapshot[T],
|
|
482
|
+
opts: GetOptions | None = None,
|
|
483
|
+
) -> None:
|
|
484
|
+
"""Watch for config changes and update *snapshot* on every change.
|
|
485
|
+
|
|
486
|
+
Internally calls :meth:`watch` with a callback that fetches the
|
|
487
|
+
latest configuration via :meth:`get` and stores it in *snapshot*.
|
|
488
|
+
Errors from :meth:`get` are forwarded to ``Options.on_error`` and
|
|
489
|
+
do **not** stop the watch loop.
|
|
490
|
+
|
|
491
|
+
Blocks the calling thread. Recommended usage is inside a daemon
|
|
492
|
+
``threading.Thread``.
|
|
493
|
+
|
|
494
|
+
Args:
|
|
495
|
+
config_name: The configuration name to watch.
|
|
496
|
+
target_type: Deserialization target type for :meth:`get`.
|
|
497
|
+
snapshot: Thread-safe container to update on each change.
|
|
498
|
+
opts: Optional per-request options passed to :meth:`get`.
|
|
499
|
+
|
|
500
|
+
"""
|
|
501
|
+
|
|
502
|
+
def _on_change(event: ConfigChangeEvent) -> None: # noqa: ARG001
|
|
503
|
+
try:
|
|
504
|
+
new_value = self.get(config_name, target_type, opts)
|
|
505
|
+
snapshot.store(new_value)
|
|
506
|
+
logger.debug(
|
|
507
|
+
"Snapshot updated config=%s version=%s",
|
|
508
|
+
config_name,
|
|
509
|
+
event.version,
|
|
510
|
+
)
|
|
511
|
+
except Exception as exc: # noqa: BLE001
|
|
512
|
+
logger.error(
|
|
513
|
+
"watch_and_decode get() failed config=%s: %s",
|
|
514
|
+
config_name,
|
|
515
|
+
exc,
|
|
516
|
+
)
|
|
517
|
+
if self._opts.on_error is not None:
|
|
518
|
+
self._opts.on_error(exc)
|
|
519
|
+
|
|
520
|
+
self.watch(config_name, _on_change)
|