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.
@@ -0,0 +1,75 @@
1
+ """AES-256-GCM decryption helpers for Config Service responses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import binascii
6
+
7
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
8
+
9
+ from config_client.errors import ConfigClientError, DecryptionError
10
+
11
+ NONCE_SIZE: int = 12 # bytes
12
+ GCM_TAG_SIZE: int = 16 # bytes
13
+ _MIN_CIPHERTEXT_SIZE: int = NONCE_SIZE + GCM_TAG_SIZE
14
+
15
+
16
+ def decrypt(ciphertext: bytes, key: bytes) -> bytes:
17
+ """Decrypt an AES-256-GCM ciphertext produced by Config Service.
18
+
19
+ The expected wire format is::
20
+
21
+ nonce (12 bytes) || encrypted_data || GCM tag (16 bytes)
22
+
23
+ This format is compatible with the Go SDK's ``gcm.Seal(nonce, nonce,
24
+ plaintext, nil)`` call.
25
+
26
+ Args:
27
+ ciphertext: Raw bytes received from the Config Service endpoint.
28
+ key: 32-byte AES-256 key derived from :func:`parse_encryption_key`.
29
+
30
+ Returns:
31
+ Decrypted plaintext bytes (JSON-encoded configuration).
32
+
33
+ Raises:
34
+ DecryptionError: If *ciphertext* is too short or AESGCM authentication
35
+ fails (wrong key, corrupted data, etc.).
36
+
37
+ """
38
+ if len(ciphertext) < _MIN_CIPHERTEXT_SIZE:
39
+ raise DecryptionError(
40
+ f"ciphertext too short: expected at least {_MIN_CIPHERTEXT_SIZE} bytes, "
41
+ f"got {len(ciphertext)}"
42
+ )
43
+
44
+ nonce = ciphertext[:NONCE_SIZE]
45
+ encrypted = ciphertext[NONCE_SIZE:]
46
+
47
+ try:
48
+ aesgcm = AESGCM(key)
49
+ return aesgcm.decrypt(nonce, encrypted, None)
50
+ except Exception as exc:
51
+ raise DecryptionError("AES-256-GCM decryption failed") from exc
52
+
53
+
54
+ def parse_encryption_key(hex_key: str) -> bytes:
55
+ """Parse and validate a hex-encoded AES-256 encryption key.
56
+
57
+ Args:
58
+ hex_key: 64-character hexadecimal string representing a 32-byte key.
59
+
60
+ Returns:
61
+ Raw 32-byte key suitable for passing to :func:`decrypt`.
62
+
63
+ Raises:
64
+ ConfigClientError: If *hex_key* is not exactly 64 characters or contains
65
+ non-hexadecimal characters.
66
+
67
+ """
68
+ if len(hex_key) != 64:
69
+ raise ConfigClientError(
70
+ f"encryption_key must be exactly 64 hex characters (32 bytes), got {len(hex_key)}"
71
+ )
72
+ try:
73
+ return binascii.unhexlify(hex_key)
74
+ except binascii.Error as exc:
75
+ raise ConfigClientError("encryption_key is not valid hexadecimal") from exc
@@ -0,0 +1,68 @@
1
+ """Exception hierarchy for python-config-client."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class ConfigClientError(Exception):
7
+ """Base class for all ``config_client`` exceptions.
8
+
9
+ Catch this to handle any error raised by the SDK.
10
+ """
11
+
12
+
13
+ class UnauthorizedError(ConfigClientError):
14
+ """HTTP 401 — invalid or expired service token.
15
+
16
+ Raised when the Config Service rejects the ``X-Service-Token`` header.
17
+ This error is **not** retried automatically.
18
+ """
19
+
20
+
21
+ class ForbiddenError(ConfigClientError):
22
+ """HTTP 403 — insufficient permissions.
23
+
24
+ Raised when the service token does not have access to the requested
25
+ configuration. This error is **not** retried automatically.
26
+ """
27
+
28
+
29
+ class NotFoundError(ConfigClientError):
30
+ """HTTP 404 — configuration not found.
31
+
32
+ Raised when the requested configuration name does not exist or is not
33
+ accessible with the current service token.
34
+ """
35
+
36
+
37
+ class DecryptionError(ConfigClientError):
38
+ """AES-256-GCM decryption failed.
39
+
40
+ Raised when the ciphertext received from Config Service cannot be
41
+ decrypted with the provided ``encryption_key``. This typically means
42
+ the wrong key was supplied.
43
+ """
44
+
45
+
46
+ class InvalidResponseError(ConfigClientError):
47
+ """Unexpected server response.
48
+
49
+ Raised on non-retriable 4xx HTTP responses other than 401, 403, 404,
50
+ or when the response body cannot be parsed as expected.
51
+ """
52
+
53
+
54
+ class ConnectionError(ConfigClientError):
55
+ """Network error or 5xx response after all retries are exhausted.
56
+
57
+ Raised when the HTTP transport fails due to a network-level error
58
+ (``httpx.TransportError``) or the server returns a 5xx status code
59
+ and the configured ``retry_count`` has been exceeded.
60
+ """
61
+
62
+
63
+ class UnmarshalError(ConfigClientError):
64
+ """Failed to deserialize decrypted JSON into the target type.
65
+
66
+ Raised when the decrypted JSON payload cannot be mapped onto the
67
+ ``target_type`` passed to :meth:`~config_client.ConfigClient.get`.
68
+ """
@@ -0,0 +1,96 @@
1
+ """Options and GetOptions dataclasses for ConfigClient configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import binascii
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass, field
8
+ from typing import TYPE_CHECKING
9
+
10
+ if TYPE_CHECKING:
11
+ import httpx
12
+
13
+ from config_client.errors import ConfigClientError
14
+
15
+
16
+ def _validate_host(host: str) -> None:
17
+ """Raise ``ConfigClientError`` if *host* is empty."""
18
+ if not host:
19
+ raise ConfigClientError("Options.host must not be empty")
20
+
21
+
22
+ def _validate_service_token(token: str) -> None:
23
+ """Raise ``ConfigClientError`` if *token* is empty."""
24
+ if not token:
25
+ raise ConfigClientError("Options.service_token must not be empty")
26
+
27
+
28
+ def _validate_encryption_key(hex_key: str) -> None:
29
+ """Raise ``ConfigClientError`` if *hex_key* is not a valid 64-char hex string."""
30
+ if len(hex_key) != 64:
31
+ raise ConfigClientError(
32
+ f"Options.encryption_key must be exactly 64 hex characters (32 bytes), "
33
+ f"got {len(hex_key)}"
34
+ )
35
+ try:
36
+ binascii.unhexlify(hex_key)
37
+ except binascii.Error as exc:
38
+ raise ConfigClientError("Options.encryption_key is not valid hexadecimal") from exc
39
+
40
+
41
+ @dataclass
42
+ class Options:
43
+ """Configuration options for :class:`~config_client.ConfigClient`.
44
+
45
+ All fields are validated on construction via ``__post_init__``.
46
+
47
+ Attributes:
48
+ host: Base URL of the Config Service (e.g. ``https://config.example.com``).
49
+ service_token: Plain-text service token used in ``X-Service-Token`` header.
50
+ encryption_key: AES-256 key as a 64-character hex string (32 bytes).
51
+ http_client: Optional pre-configured ``httpx.Client``. When provided,
52
+ the caller is responsible for closing it.
53
+ request_timeout: HTTP request timeout in seconds. Defaults to ``10.0``.
54
+ retry_count: Number of retry attempts for retriable errors. Defaults to ``3``.
55
+ retry_delay: Base delay between retries in seconds (exponential backoff).
56
+ Defaults to ``1.0``.
57
+ on_error: Optional callback invoked with the exception when a watch error
58
+ occurs. Does not stop the watch loop.
59
+ on_change: Optional callback invoked with the config name when a change
60
+ event is received from the SSE stream.
61
+
62
+ """
63
+
64
+ # --- required ---
65
+ host: str
66
+ service_token: str
67
+ encryption_key: str
68
+
69
+ # --- optional ---
70
+ http_client: httpx.Client | None = field(default=None)
71
+ request_timeout: float = 10.0
72
+ retry_count: int = 3
73
+ retry_delay: float = 1.0
74
+ on_error: Callable[[Exception], None] | None = field(default=None)
75
+ on_change: Callable[[str], None] | None = field(default=None)
76
+
77
+ def __post_init__(self) -> None:
78
+ """Validate all required fields."""
79
+ _validate_host(self.host)
80
+ _validate_service_token(self.service_token)
81
+ _validate_encryption_key(self.encryption_key)
82
+
83
+
84
+ @dataclass
85
+ class GetOptions:
86
+ """Per-request options for config retrieval methods.
87
+
88
+ Attributes:
89
+ environment: Filter configurations by environment label (e.g.
90
+ ``"production"``, ``"staging"``). Empty string means no filter.
91
+ version: Specific version to retrieve. ``0`` means the latest version.
92
+
93
+ """
94
+
95
+ environment: str = ""
96
+ version: int = 0
@@ -0,0 +1,50 @@
1
+ """Thread-safe Snapshot container for hot-reload support."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ from typing import Generic, TypeVar
7
+
8
+ T = TypeVar("T")
9
+
10
+
11
+ class Snapshot(Generic[T]):
12
+ """Thread-safe container for a configuration value with hot-reload support.
13
+
14
+ Designed to be written by a background watch thread and read concurrently
15
+ by any number of application threads without explicit locking on the
16
+ caller side.
17
+
18
+ Example::
19
+
20
+ snapshot: Snapshot[AppConfig] = Snapshot()
21
+ snapshot.store(client.get("my-service", AppConfig))
22
+
23
+ # In application code (any thread):
24
+ cfg = snapshot.load()
25
+ if cfg is not None:
26
+ print(cfg.log_level)
27
+ """
28
+
29
+ def __init__(self) -> None:
30
+ """Initialise an empty snapshot."""
31
+ self._lock: threading.RLock = threading.RLock()
32
+ self._value: T | None = None
33
+
34
+ def load(self) -> T | None:
35
+ """Return the current configuration value.
36
+
37
+ Returns ``None`` if :meth:`store` has never been called.
38
+ Safe for concurrent reads from multiple threads.
39
+ """
40
+ with self._lock:
41
+ return self._value
42
+
43
+ def store(self, value: T) -> None:
44
+ """Replace the current configuration value with *value*.
45
+
46
+ Safe for concurrent writes; subsequent :meth:`load` calls will
47
+ immediately observe the new value.
48
+ """
49
+ with self._lock:
50
+ self._value = value
@@ -0,0 +1,207 @@
1
+ """HTTP transport layer with retry and exponential backoff."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import random
7
+ import time
8
+
9
+ import httpx
10
+
11
+ from config_client.errors import (
12
+ ConnectionError,
13
+ ForbiddenError,
14
+ InvalidResponseError,
15
+ NotFoundError,
16
+ UnauthorizedError,
17
+ )
18
+
19
+ logger = logging.getLogger("config_client")
20
+
21
+ _MAX_BACKOFF: float = 30.0
22
+
23
+
24
+ def _mask_token(token: str) -> str:
25
+ """Return a masked representation of *token* safe for logging."""
26
+ if len(token) <= 4:
27
+ return "****"
28
+ return "****" + token[-4:]
29
+
30
+
31
+ def backoff_delay(attempt: int, base_delay: float) -> float:
32
+ """Compute exponential backoff delay with full jitter.
33
+
34
+ The delay is sampled uniformly from ``[0, min(base * 2^attempt, 30.0)]``,
35
+ which is the "full jitter" strategy recommended to avoid thundering herd.
36
+
37
+ Args:
38
+ attempt: Zero-based attempt index (first retry = 1, second = 2, …).
39
+ base_delay: Base delay in seconds.
40
+
41
+ Returns:
42
+ A random float in ``[0.0, cap]`` where ``cap = min(base * 2^attempt, 30.0)``.
43
+
44
+ """
45
+ cap = min(base_delay * (2**attempt), _MAX_BACKOFF)
46
+ return random.uniform(0.0, cap) # noqa: S311 — jitter, not cryptographic
47
+
48
+
49
+ def _raise_for_status(response: httpx.Response) -> None:
50
+ """Map an HTTP error response to the appropriate SDK exception.
51
+
52
+ Args:
53
+ response: A completed ``httpx.Response`` with a non-2xx status code.
54
+
55
+ Raises:
56
+ UnauthorizedError: HTTP 401.
57
+ ForbiddenError: HTTP 403.
58
+ NotFoundError: HTTP 404.
59
+ InvalidResponseError: Any other 4xx.
60
+
61
+ """
62
+ status = response.status_code
63
+ if status == 401:
64
+ raise UnauthorizedError(f"HTTP 401 Unauthorized: {response.text}")
65
+ if status == 403:
66
+ raise ForbiddenError(f"HTTP 403 Forbidden: {response.text}")
67
+ if status == 404:
68
+ raise NotFoundError(f"HTTP 404 Not Found: {response.text}")
69
+ if 400 <= status < 500:
70
+ raise InvalidResponseError(f"HTTP {status} client error: {response.text}")
71
+
72
+
73
+ class Transport:
74
+ """HTTP transport with retry logic and structured logging.
75
+
76
+ Wraps ``httpx.Client`` with:
77
+ - Automatic ``X-Service-Token`` header injection.
78
+ - Exponential backoff with full jitter for 5xx / network errors.
79
+ - Deterministic mapping of HTTP status codes to SDK exceptions.
80
+ - Token masking in all log messages.
81
+
82
+ Args:
83
+ host: Base URL of the Config Service.
84
+ service_token: Plain-text service token.
85
+ request_timeout: Per-request timeout in seconds.
86
+ retry_count: Number of retry attempts for retriable errors.
87
+ retry_delay: Base delay for exponential backoff.
88
+ http_client: Optional pre-configured ``httpx.Client``. When
89
+ provided the caller owns its lifecycle; ``close()`` will not
90
+ close it.
91
+
92
+ """
93
+
94
+ def __init__(
95
+ self,
96
+ host: str,
97
+ service_token: str,
98
+ request_timeout: float,
99
+ retry_count: int,
100
+ retry_delay: float,
101
+ http_client: httpx.Client | None = None,
102
+ ) -> None:
103
+ """Initialise the transport."""
104
+ self._host = host.rstrip("/")
105
+ self._token = service_token
106
+ self._retry_count = retry_count
107
+ self._retry_delay = retry_delay
108
+ self._owns_client = http_client is None
109
+
110
+ self._client = http_client or httpx.Client(
111
+ timeout=httpx.Timeout(request_timeout),
112
+ verify=True, # SSL verification enabled by default
113
+ )
114
+
115
+ # ------------------------------------------------------------------
116
+ # Public interface
117
+ # ------------------------------------------------------------------
118
+
119
+ def get(self, path: str, **params: str | int) -> httpx.Response:
120
+ """Perform a GET request with retry logic.
121
+
122
+ Retries on 5xx responses and ``httpx.TransportError``. 4xx errors
123
+ are raised immediately without retrying.
124
+
125
+ Args:
126
+ path: URL path relative to the host (e.g. ``/api/v1/service/configs``).
127
+ **params: Query string parameters to include in the request.
128
+
129
+ Returns:
130
+ The successful ``httpx.Response``.
131
+
132
+ Raises:
133
+ UnauthorizedError: HTTP 401.
134
+ ForbiddenError: HTTP 403.
135
+ NotFoundError: HTTP 404.
136
+ InvalidResponseError: Any other non-retriable 4xx.
137
+ ConnectionError: Network error or 5xx after all retries exhausted.
138
+
139
+ """
140
+ url = f"{self._host}{path}"
141
+ masked = _mask_token(self._token)
142
+ # Filter out params with falsy default values (empty str / 0) to keep
143
+ # URLs clean, but still allow explicit non-default values.
144
+ query: dict[str, str] = {k: str(v) for k, v in params.items() if v not in ("", 0)}
145
+
146
+ last_exc: Exception | None = None
147
+
148
+ for attempt in range(self._retry_count + 1):
149
+ if attempt > 0:
150
+ delay = backoff_delay(attempt, self._retry_delay)
151
+ logger.warning(
152
+ "Retrying request attempt=%d/%d url=%s token=%s delay=%.2fs",
153
+ attempt,
154
+ self._retry_count,
155
+ url,
156
+ masked,
157
+ delay,
158
+ )
159
+ time.sleep(delay)
160
+
161
+ logger.debug("GET %s attempt=%d token=%s", url, attempt + 1, masked)
162
+
163
+ try:
164
+ response = self._client.get(
165
+ url,
166
+ params=query or None,
167
+ headers={"X-Service-Token": self._token},
168
+ )
169
+ except httpx.TransportError as exc:
170
+ logger.warning("Transport error url=%s: %s", url, exc)
171
+ last_exc = exc
172
+ continue # retriable
173
+
174
+ if 400 <= response.status_code < 500:
175
+ # 4xx — raise immediately, do not retry
176
+ _raise_for_status(response)
177
+ # unreachable, but satisfies mypy
178
+ raise InvalidResponseError(f"HTTP {response.status_code}")
179
+
180
+ if response.status_code >= 500:
181
+ logger.warning(
182
+ "Server error status=%d url=%s attempt=%d",
183
+ response.status_code,
184
+ url,
185
+ attempt + 1,
186
+ )
187
+ last_exc = ConnectionError(f"HTTP {response.status_code} server error")
188
+ continue # retriable
189
+
190
+ return response # 2xx — success
191
+
192
+ raise ConnectionError(
193
+ f"Request failed after {self._retry_count + 1} attempt(s): {url}"
194
+ ) from last_exc
195
+
196
+ def close(self) -> None:
197
+ """Close the underlying ``httpx.Client`` if owned by this instance."""
198
+ if self._owns_client:
199
+ self._client.close()
200
+
201
+ def __enter__(self) -> Transport:
202
+ """Support use as a context manager."""
203
+ return self
204
+
205
+ def __exit__(self, *args: object) -> None:
206
+ """Close on context manager exit."""
207
+ self.close()
config_client/types.py ADDED
@@ -0,0 +1,62 @@
1
+ """Public types: ConfigInfo, ConfigChangeEvent, Format."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import datetime
7
+ from enum import Enum
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class ConfigInfo:
12
+ """Metadata about a configuration available to the service token.
13
+
14
+ Returned by :meth:`~config_client.ConfigClient.list`.
15
+
16
+ Attributes:
17
+ name: The configuration name (slug).
18
+ is_valid: Whether the configuration is currently valid/active.
19
+ valid_from: UTC datetime from which the configuration is effective.
20
+ updated_at: UTC datetime of the last update.
21
+
22
+ """
23
+
24
+ name: str
25
+ is_valid: bool
26
+ valid_from: datetime
27
+ updated_at: datetime
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class ConfigChangeEvent:
32
+ """Payload of a Server-Sent Event from the ``/watch`` endpoint.
33
+
34
+ Passed to callbacks registered via :meth:`~config_client.ConfigClient.watch`.
35
+
36
+ Attributes:
37
+ config_name: Name of the changed configuration.
38
+ version: New version number after the change.
39
+ changed_by: ID of the user or process that made the change.
40
+ timestamp: UTC datetime when the change was applied.
41
+
42
+ """
43
+
44
+ config_name: str
45
+ version: int
46
+ changed_by: int
47
+ timestamp: datetime
48
+
49
+
50
+ class Format(str, Enum):
51
+ """Output format for :meth:`~config_client.ConfigClient.get_formatted`.
52
+
53
+ Attributes:
54
+ JSON: JSON format (``application/json``).
55
+ YAML: YAML format.
56
+ ENV: Key=value ``.env`` format.
57
+
58
+ """
59
+
60
+ JSON = "json"
61
+ YAML = "yaml"
62
+ ENV = "env"