quonfig 0.0.1__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.
- quonfig-0.0.1/PKG-INFO +21 -0
- quonfig-0.0.1/pyproject.toml +40 -0
- quonfig-0.0.1/quonfig/__init__.py +23 -0
- quonfig-0.0.1/quonfig/bound_client.py +57 -0
- quonfig-0.0.1/quonfig/client.py +458 -0
- quonfig-0.0.1/quonfig/context.py +74 -0
- quonfig-0.0.1/quonfig/crypto.py +103 -0
- quonfig-0.0.1/quonfig/datadir.py +67 -0
- quonfig-0.0.1/quonfig/evaluator.py +90 -0
- quonfig-0.0.1/quonfig/exceptions.py +22 -0
- quonfig-0.0.1/quonfig/operators.py +380 -0
- quonfig-0.0.1/quonfig/resolver.py +234 -0
- quonfig-0.0.1/quonfig/sse.py +61 -0
- quonfig-0.0.1/quonfig/store.py +31 -0
- quonfig-0.0.1/quonfig/telemetry/__init__.py +3 -0
- quonfig-0.0.1/quonfig/telemetry/collectors.py +95 -0
- quonfig-0.0.1/quonfig/telemetry/models.py +69 -0
- quonfig-0.0.1/quonfig/telemetry/reporter.py +118 -0
- quonfig-0.0.1/quonfig/transport.py +98 -0
- quonfig-0.0.1/quonfig/types.py +167 -0
quonfig-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: quonfig
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Python SDK for Quonfig — feature flags and configuration management
|
|
5
|
+
License: MIT
|
|
6
|
+
Author: Quonfig
|
|
7
|
+
Author-email: hello@quonfig.com
|
|
8
|
+
Requires-Python: >=3.9,<4.0
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Requires-Dist: cryptography (>=42.0.0)
|
|
16
|
+
Requires-Dist: isodate (>=0.6.1,<0.7.0)
|
|
17
|
+
Requires-Dist: mmh3 (>=3.0.0,<5.0.0)
|
|
18
|
+
Requires-Dist: packaging (>=21.0)
|
|
19
|
+
Requires-Dist: requests (>=2.30.0)
|
|
20
|
+
Requires-Dist: sseclient-py (>=1.7.2,<2.0.0)
|
|
21
|
+
Requires-Dist: tenacity (>=8.0.0)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "quonfig"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
description = "Python SDK for Quonfig — feature flags and configuration management"
|
|
5
|
+
license = "MIT"
|
|
6
|
+
authors = ["Quonfig <hello@quonfig.com>"]
|
|
7
|
+
packages = [{include = "quonfig"}]
|
|
8
|
+
|
|
9
|
+
[tool.poetry.dependencies]
|
|
10
|
+
python = "^3.9"
|
|
11
|
+
requests = ">=2.30.0"
|
|
12
|
+
sseclient-py = "^1.7.2"
|
|
13
|
+
mmh3 = ">=3.0.0,<5.0.0"
|
|
14
|
+
cryptography = ">=42.0.0"
|
|
15
|
+
tenacity = ">=8.0.0"
|
|
16
|
+
isodate = "^0.6.1"
|
|
17
|
+
packaging = ">=21.0"
|
|
18
|
+
|
|
19
|
+
[tool.poetry.group.dev.dependencies]
|
|
20
|
+
pytest = "^8.0"
|
|
21
|
+
pytest-mock = "^3.0"
|
|
22
|
+
pytest-cov = "^5.0"
|
|
23
|
+
mypy = "^1.0"
|
|
24
|
+
ruff = "^0.4"
|
|
25
|
+
responses = "^0.25"
|
|
26
|
+
|
|
27
|
+
[build-system]
|
|
28
|
+
requires = ["poetry-core"]
|
|
29
|
+
build-backend = "poetry.core.masonry.api"
|
|
30
|
+
|
|
31
|
+
[tool.ruff]
|
|
32
|
+
target-version = "py39"
|
|
33
|
+
line-length = 100
|
|
34
|
+
|
|
35
|
+
[tool.ruff.lint]
|
|
36
|
+
select = ["E", "F", "I"]
|
|
37
|
+
|
|
38
|
+
[tool.ruff.lint.per-file-ignores]
|
|
39
|
+
"tests/**" = ["E501"]
|
|
40
|
+
"quonfig/operators.py" = ["E501"]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from .bound_client import BoundQuonfig
|
|
2
|
+
from .client import Quonfig
|
|
3
|
+
from .exceptions import (
|
|
4
|
+
QuonfigDecryptionError,
|
|
5
|
+
QuonfigEnvVarNotSetError,
|
|
6
|
+
QuonfigError,
|
|
7
|
+
QuonfigInitTimeoutError,
|
|
8
|
+
QuonfigKeyNotFoundError,
|
|
9
|
+
QuonfigNotInitializedError,
|
|
10
|
+
)
|
|
11
|
+
from .types import Contexts
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"Quonfig",
|
|
15
|
+
"BoundQuonfig",
|
|
16
|
+
"QuonfigError",
|
|
17
|
+
"QuonfigKeyNotFoundError",
|
|
18
|
+
"QuonfigInitTimeoutError",
|
|
19
|
+
"QuonfigNotInitializedError",
|
|
20
|
+
"QuonfigEnvVarNotSetError",
|
|
21
|
+
"QuonfigDecryptionError",
|
|
22
|
+
"Contexts",
|
|
23
|
+
]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, List, Optional
|
|
4
|
+
|
|
5
|
+
from .client import _NO_DEFAULT, Quonfig
|
|
6
|
+
from .context import merge_contexts
|
|
7
|
+
from .types import Contexts
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BoundQuonfig:
|
|
11
|
+
"""
|
|
12
|
+
A Quonfig client bound to a specific context.
|
|
13
|
+
|
|
14
|
+
All getters automatically merge the bound contexts with any additional
|
|
15
|
+
per-call contexts.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, client: Quonfig, contexts: Contexts) -> None:
|
|
19
|
+
self._client = client
|
|
20
|
+
self._contexts = contexts
|
|
21
|
+
|
|
22
|
+
def with_context(self, contexts: Contexts) -> "BoundQuonfig":
|
|
23
|
+
"""Return a new BoundQuonfig with additional contexts merged in."""
|
|
24
|
+
return BoundQuonfig(self._client, merge_contexts(self._contexts, contexts))
|
|
25
|
+
|
|
26
|
+
def get(self, key: str, default: Any = _NO_DEFAULT) -> Any:
|
|
27
|
+
return self._client.get(key, default=default, contexts=self._contexts)
|
|
28
|
+
|
|
29
|
+
def get_string(self, key: str, default: Any = _NO_DEFAULT) -> Optional[str]:
|
|
30
|
+
return self._client.get_string(key, default=default, contexts=self._contexts)
|
|
31
|
+
|
|
32
|
+
def get_int(self, key: str, default: Any = _NO_DEFAULT) -> Optional[int]:
|
|
33
|
+
return self._client.get_int(key, default=default, contexts=self._contexts)
|
|
34
|
+
|
|
35
|
+
def get_float(self, key: str, default: Any = _NO_DEFAULT) -> Optional[float]:
|
|
36
|
+
return self._client.get_float(key, default=default, contexts=self._contexts)
|
|
37
|
+
|
|
38
|
+
def get_bool(self, key: str, default: Any = _NO_DEFAULT) -> Optional[bool]:
|
|
39
|
+
return self._client.get_bool(key, default=default, contexts=self._contexts)
|
|
40
|
+
|
|
41
|
+
def get_string_list(self, key: str, default: Any = _NO_DEFAULT) -> Optional[List[str]]:
|
|
42
|
+
return self._client.get_string_list(key, default=default, contexts=self._contexts)
|
|
43
|
+
|
|
44
|
+
def get_json(self, key: str, default: Any = _NO_DEFAULT) -> Any:
|
|
45
|
+
return self._client.get_json(key, default=default, contexts=self._contexts)
|
|
46
|
+
|
|
47
|
+
def get_duration(self, key: str, default: Any = _NO_DEFAULT) -> Optional[float]:
|
|
48
|
+
return self._client.get_duration(key, default=default, contexts=self._contexts)
|
|
49
|
+
|
|
50
|
+
def is_feature_enabled(self, key: str, default: bool = False) -> bool:
|
|
51
|
+
return self._client.is_feature_enabled(key, default=default, contexts=self._contexts)
|
|
52
|
+
|
|
53
|
+
def should_log(self, logger_name: str, desired_level: str) -> bool:
|
|
54
|
+
return self._client.should_log(logger_name, desired_level, contexts=self._contexts)
|
|
55
|
+
|
|
56
|
+
def keys(self) -> List[str]:
|
|
57
|
+
return self._client.keys()
|
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import threading
|
|
7
|
+
from typing import TYPE_CHECKING, Any, List, Optional
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from .bound_client import BoundQuonfig
|
|
11
|
+
|
|
12
|
+
from .context import (
|
|
13
|
+
clear_thread_context,
|
|
14
|
+
get_thread_context,
|
|
15
|
+
merge_contexts,
|
|
16
|
+
set_thread_context,
|
|
17
|
+
)
|
|
18
|
+
from .evaluator import Evaluator
|
|
19
|
+
from .exceptions import (
|
|
20
|
+
QuonfigDecryptionError,
|
|
21
|
+
QuonfigEnvVarNotSetError,
|
|
22
|
+
QuonfigInitTimeoutError,
|
|
23
|
+
QuonfigKeyNotFoundError,
|
|
24
|
+
)
|
|
25
|
+
from .resolver import LOG_LEVEL_ORDER, Resolver
|
|
26
|
+
from .store import ConfigStore
|
|
27
|
+
from .transport import Transport
|
|
28
|
+
from .types import Contexts
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
_NO_DEFAULT = object()
|
|
33
|
+
|
|
34
|
+
# Default API URL
|
|
35
|
+
_DEFAULT_API_URL = "https://api.quonfig.com"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Quonfig:
|
|
39
|
+
"""
|
|
40
|
+
Main Quonfig SDK client.
|
|
41
|
+
|
|
42
|
+
Usage:
|
|
43
|
+
client = Quonfig(sdk_key="sdk-...")
|
|
44
|
+
client.init()
|
|
45
|
+
value = client.get_string("my.key", default="fallback")
|
|
46
|
+
enabled = client.is_feature_enabled("my.flag")
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
sdk_key: Optional[str] = None,
|
|
52
|
+
*,
|
|
53
|
+
api_urls: Optional[List[str]] = None,
|
|
54
|
+
init_timeout: float = 10.0,
|
|
55
|
+
on_init_failure: str = "raise", # "raise" | "return_zero_value"
|
|
56
|
+
global_context: Optional[Contexts] = None,
|
|
57
|
+
environment: Optional[str] = None,
|
|
58
|
+
telemetry_url: Optional[str] = None,
|
|
59
|
+
collect_evaluation_summaries: bool = True,
|
|
60
|
+
context_upload_mode: str = "shapes_only", # "none" | "shapes_only" | "periodic_example"
|
|
61
|
+
on_no_default: str = "error", # "error" | "warn" | "ignore"
|
|
62
|
+
datadir: Optional[str] = None,
|
|
63
|
+
) -> None:
|
|
64
|
+
# Resolve configuration from params or env vars
|
|
65
|
+
self._sdk_key = sdk_key or os.environ.get("QUONFIG_SDK_KEY", "")
|
|
66
|
+
self._environment = environment or os.environ.get("QUONFIG_ENVIRONMENT", "")
|
|
67
|
+
self._datadir = datadir or os.environ.get("QUONFIG_DIR")
|
|
68
|
+
|
|
69
|
+
if api_urls:
|
|
70
|
+
self._api_urls = api_urls
|
|
71
|
+
else:
|
|
72
|
+
env_url = os.environ.get("QUONFIG_API_URL", "")
|
|
73
|
+
if env_url:
|
|
74
|
+
self._api_urls = [u.strip() for u in env_url.split(",") if u.strip()]
|
|
75
|
+
else:
|
|
76
|
+
self._api_urls = [_DEFAULT_API_URL]
|
|
77
|
+
|
|
78
|
+
self._telemetry_url = telemetry_url or os.environ.get(
|
|
79
|
+
"QUONFIG_TELEMETRY_URL", _DEFAULT_API_URL
|
|
80
|
+
)
|
|
81
|
+
self._init_timeout = init_timeout
|
|
82
|
+
self._on_init_failure = on_init_failure
|
|
83
|
+
self._on_no_default = on_no_default
|
|
84
|
+
self._global_context: Contexts = global_context or {}
|
|
85
|
+
|
|
86
|
+
self._store = ConfigStore()
|
|
87
|
+
self._shutdown = threading.Event()
|
|
88
|
+
self._initialized = threading.Event()
|
|
89
|
+
self._init_error: Optional[Exception] = None
|
|
90
|
+
|
|
91
|
+
# Will be set after init
|
|
92
|
+
self._evaluator: Optional[Evaluator] = None
|
|
93
|
+
self._resolver = Resolver(self._store)
|
|
94
|
+
|
|
95
|
+
# Telemetry (optional)
|
|
96
|
+
self._telemetry = None
|
|
97
|
+
if collect_evaluation_summaries or context_upload_mode != "none":
|
|
98
|
+
try:
|
|
99
|
+
from .telemetry import TelemetryReporter
|
|
100
|
+
|
|
101
|
+
self._telemetry = TelemetryReporter(
|
|
102
|
+
telemetry_url=self._telemetry_url,
|
|
103
|
+
sdk_key=self._sdk_key,
|
|
104
|
+
collect_evaluation_summaries=collect_evaluation_summaries,
|
|
105
|
+
collect_context_shapes=(context_upload_mode != "none"),
|
|
106
|
+
)
|
|
107
|
+
except Exception:
|
|
108
|
+
pass # Telemetry is optional
|
|
109
|
+
|
|
110
|
+
# Transport (only if not datadir mode)
|
|
111
|
+
self._transport: Optional[Transport] = None
|
|
112
|
+
if not self._datadir and self._sdk_key:
|
|
113
|
+
self._transport = Transport(
|
|
114
|
+
api_urls=self._api_urls,
|
|
115
|
+
sdk_key=self._sdk_key,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# ------------------------------------------------------------------
|
|
119
|
+
# Initialization
|
|
120
|
+
# ------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
def init(self) -> "Quonfig":
|
|
123
|
+
"""
|
|
124
|
+
Block until first config load completes (or timeout).
|
|
125
|
+
|
|
126
|
+
Raises QuonfigInitTimeoutError if init_timeout exceeded and
|
|
127
|
+
on_init_failure="raise".
|
|
128
|
+
"""
|
|
129
|
+
if self._datadir:
|
|
130
|
+
self._load_from_datadir()
|
|
131
|
+
elif self._transport:
|
|
132
|
+
self._load_from_api()
|
|
133
|
+
else:
|
|
134
|
+
# No data source configured — mark initialized with empty store
|
|
135
|
+
self._finish_init()
|
|
136
|
+
|
|
137
|
+
return self
|
|
138
|
+
|
|
139
|
+
def _load_from_datadir(self) -> None:
|
|
140
|
+
from .datadir import load_datadir
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
envelope = load_datadir(self._datadir or "", self._environment)
|
|
144
|
+
self._store.update(envelope)
|
|
145
|
+
except Exception as e:
|
|
146
|
+
self._init_error = e
|
|
147
|
+
logger.error("Failed to load datadir: %s", e)
|
|
148
|
+
self._finish_init()
|
|
149
|
+
raise
|
|
150
|
+
else:
|
|
151
|
+
self._finish_init()
|
|
152
|
+
|
|
153
|
+
def _load_from_api(self) -> None:
|
|
154
|
+
"""Start background SSE thread; initial load done via polling thread."""
|
|
155
|
+
assert self._transport is not None
|
|
156
|
+
|
|
157
|
+
# Do an initial blocking fetch to populate the store
|
|
158
|
+
try:
|
|
159
|
+
envelope = self._transport.fetch()
|
|
160
|
+
if envelope is not None:
|
|
161
|
+
self._store.update(envelope)
|
|
162
|
+
self._finish_init()
|
|
163
|
+
except Exception as e:
|
|
164
|
+
logger.warning("Initial fetch failed: %s — starting SSE anyway", e)
|
|
165
|
+
self._finish_init()
|
|
166
|
+
|
|
167
|
+
# Start SSE for live updates
|
|
168
|
+
from .sse import SSEClient
|
|
169
|
+
|
|
170
|
+
sse = SSEClient(self._transport, self._store, self._shutdown)
|
|
171
|
+
sse.start()
|
|
172
|
+
|
|
173
|
+
# Start polling as fallback
|
|
174
|
+
self._transport.start_polling(self._store, self._shutdown)
|
|
175
|
+
|
|
176
|
+
# Start telemetry
|
|
177
|
+
if self._telemetry is not None:
|
|
178
|
+
self._telemetry.start()
|
|
179
|
+
|
|
180
|
+
def _finish_init(self) -> None:
|
|
181
|
+
self._evaluator = Evaluator(self._store, self._environment)
|
|
182
|
+
self._initialized.set()
|
|
183
|
+
|
|
184
|
+
def _wait_initialized(self) -> None:
|
|
185
|
+
if not self._initialized.is_set():
|
|
186
|
+
ok = self._initialized.wait(timeout=self._init_timeout)
|
|
187
|
+
if not ok:
|
|
188
|
+
if self._on_init_failure == "raise":
|
|
189
|
+
raise QuonfigInitTimeoutError(
|
|
190
|
+
f"Quonfig did not initialize within {self._init_timeout}s"
|
|
191
|
+
)
|
|
192
|
+
# return_zero_value: best effort with partial data
|
|
193
|
+
self._finish_init()
|
|
194
|
+
|
|
195
|
+
# ------------------------------------------------------------------
|
|
196
|
+
# Context helpers
|
|
197
|
+
# ------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
def _effective_contexts(self, contexts: Optional[Contexts]) -> Contexts:
|
|
200
|
+
"""Merge global, thread-local, and per-call contexts."""
|
|
201
|
+
parts = [self._global_context]
|
|
202
|
+
thread_ctx = get_thread_context()
|
|
203
|
+
if thread_ctx:
|
|
204
|
+
parts.append(thread_ctx)
|
|
205
|
+
if contexts:
|
|
206
|
+
parts.append(contexts)
|
|
207
|
+
return merge_contexts(*[p for p in parts if p])
|
|
208
|
+
|
|
209
|
+
# ------------------------------------------------------------------
|
|
210
|
+
# Core evaluate + resolve
|
|
211
|
+
# ------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
def _get(self, key: str, contexts: Optional[Contexts] = None) -> Any:
|
|
214
|
+
self._wait_initialized()
|
|
215
|
+
assert self._evaluator is not None
|
|
216
|
+
merged = self._effective_contexts(contexts)
|
|
217
|
+
result = self._evaluator.evaluate(key, merged)
|
|
218
|
+
|
|
219
|
+
# Record telemetry
|
|
220
|
+
if self._telemetry is not None:
|
|
221
|
+
self._telemetry.record_evaluation(result)
|
|
222
|
+
if merged:
|
|
223
|
+
self._telemetry.record_context(merged)
|
|
224
|
+
|
|
225
|
+
if result.reason == "MISSING" or result.value is None:
|
|
226
|
+
return _NO_DEFAULT
|
|
227
|
+
|
|
228
|
+
try:
|
|
229
|
+
return self._resolver.resolve(result.value, merged, config_key=key)
|
|
230
|
+
except (QuonfigEnvVarNotSetError, QuonfigDecryptionError):
|
|
231
|
+
# These are semantic errors that callers need to handle — re-raise
|
|
232
|
+
raise
|
|
233
|
+
except Exception as e:
|
|
234
|
+
logger.warning("Error resolving value for key '%s': %s", key, e)
|
|
235
|
+
return _NO_DEFAULT
|
|
236
|
+
|
|
237
|
+
def _handle_missing(self, key: str, default: Any) -> Any:
|
|
238
|
+
if default is not _NO_DEFAULT:
|
|
239
|
+
return default
|
|
240
|
+
if self._on_no_default == "error":
|
|
241
|
+
raise QuonfigKeyNotFoundError(
|
|
242
|
+
f"No value found for key '{key}' and no default was provided"
|
|
243
|
+
)
|
|
244
|
+
elif self._on_no_default == "warn":
|
|
245
|
+
logger.warning("No value found for key '%s'", key)
|
|
246
|
+
return None
|
|
247
|
+
|
|
248
|
+
# ------------------------------------------------------------------
|
|
249
|
+
# Typed getters
|
|
250
|
+
# ------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
def get(
|
|
253
|
+
self,
|
|
254
|
+
key: str,
|
|
255
|
+
default: Any = _NO_DEFAULT,
|
|
256
|
+
contexts: Optional[Contexts] = None,
|
|
257
|
+
) -> Any:
|
|
258
|
+
"""Get any config value by key, returning raw Python type."""
|
|
259
|
+
result = self._get(key, contexts)
|
|
260
|
+
if result is _NO_DEFAULT:
|
|
261
|
+
return self._handle_missing(key, default)
|
|
262
|
+
return result
|
|
263
|
+
|
|
264
|
+
def get_string(
|
|
265
|
+
self,
|
|
266
|
+
key: str,
|
|
267
|
+
default: Any = _NO_DEFAULT,
|
|
268
|
+
contexts: Optional[Contexts] = None,
|
|
269
|
+
) -> Optional[str]:
|
|
270
|
+
result = self._get(key, contexts)
|
|
271
|
+
if result is _NO_DEFAULT:
|
|
272
|
+
val = self._handle_missing(key, default)
|
|
273
|
+
return str(val) if val is not None else None
|
|
274
|
+
return str(result) if result is not None else None
|
|
275
|
+
|
|
276
|
+
def get_int(
|
|
277
|
+
self,
|
|
278
|
+
key: str,
|
|
279
|
+
default: Any = _NO_DEFAULT,
|
|
280
|
+
contexts: Optional[Contexts] = None,
|
|
281
|
+
) -> Optional[int]:
|
|
282
|
+
result = self._get(key, contexts)
|
|
283
|
+
if result is _NO_DEFAULT:
|
|
284
|
+
val = self._handle_missing(key, default)
|
|
285
|
+
return int(val) if val is not None else None
|
|
286
|
+
try:
|
|
287
|
+
return int(result)
|
|
288
|
+
except (TypeError, ValueError):
|
|
289
|
+
# Coercion failed (e.g. env-var-provided value is not a valid int)
|
|
290
|
+
return self._handle_missing(key, default)
|
|
291
|
+
|
|
292
|
+
def get_float(
|
|
293
|
+
self,
|
|
294
|
+
key: str,
|
|
295
|
+
default: Any = _NO_DEFAULT,
|
|
296
|
+
contexts: Optional[Contexts] = None,
|
|
297
|
+
) -> Optional[float]:
|
|
298
|
+
result = self._get(key, contexts)
|
|
299
|
+
if result is _NO_DEFAULT:
|
|
300
|
+
val = self._handle_missing(key, default)
|
|
301
|
+
return float(val) if val is not None else None
|
|
302
|
+
try:
|
|
303
|
+
return float(result)
|
|
304
|
+
except (TypeError, ValueError):
|
|
305
|
+
return None
|
|
306
|
+
|
|
307
|
+
def get_bool(
|
|
308
|
+
self,
|
|
309
|
+
key: str,
|
|
310
|
+
default: Any = _NO_DEFAULT,
|
|
311
|
+
contexts: Optional[Contexts] = None,
|
|
312
|
+
) -> Optional[bool]:
|
|
313
|
+
result = self._get(key, contexts)
|
|
314
|
+
if result is _NO_DEFAULT:
|
|
315
|
+
val = self._handle_missing(key, default)
|
|
316
|
+
return bool(val) if val is not None else None
|
|
317
|
+
return bool(result)
|
|
318
|
+
|
|
319
|
+
def get_string_list(
|
|
320
|
+
self,
|
|
321
|
+
key: str,
|
|
322
|
+
default: Any = _NO_DEFAULT,
|
|
323
|
+
contexts: Optional[Contexts] = None,
|
|
324
|
+
) -> Optional[List[str]]:
|
|
325
|
+
result = self._get(key, contexts)
|
|
326
|
+
if result is _NO_DEFAULT:
|
|
327
|
+
val = self._handle_missing(key, default)
|
|
328
|
+
if val is None:
|
|
329
|
+
return None
|
|
330
|
+
if isinstance(val, list):
|
|
331
|
+
return [str(x) for x in val]
|
|
332
|
+
return [str(val)]
|
|
333
|
+
if isinstance(result, list):
|
|
334
|
+
return [str(x) for x in result]
|
|
335
|
+
return [str(result)] if result is not None else None
|
|
336
|
+
|
|
337
|
+
def get_json(
|
|
338
|
+
self,
|
|
339
|
+
key: str,
|
|
340
|
+
default: Any = _NO_DEFAULT,
|
|
341
|
+
contexts: Optional[Contexts] = None,
|
|
342
|
+
) -> Any:
|
|
343
|
+
result = self._get(key, contexts)
|
|
344
|
+
if result is _NO_DEFAULT:
|
|
345
|
+
return self._handle_missing(key, default)
|
|
346
|
+
return result
|
|
347
|
+
|
|
348
|
+
def get_duration(
|
|
349
|
+
self,
|
|
350
|
+
key: str,
|
|
351
|
+
default: Any = _NO_DEFAULT,
|
|
352
|
+
contexts: Optional[Contexts] = None,
|
|
353
|
+
) -> Optional[float]:
|
|
354
|
+
"""Get a duration value in seconds."""
|
|
355
|
+
result = self._get(key, contexts)
|
|
356
|
+
if result is _NO_DEFAULT:
|
|
357
|
+
val = self._handle_missing(key, default)
|
|
358
|
+
return float(val) if val is not None else None
|
|
359
|
+
try:
|
|
360
|
+
return float(result)
|
|
361
|
+
except (TypeError, ValueError):
|
|
362
|
+
return None
|
|
363
|
+
|
|
364
|
+
def is_feature_enabled(
|
|
365
|
+
self,
|
|
366
|
+
key: str,
|
|
367
|
+
default: bool = False,
|
|
368
|
+
contexts: Optional[Contexts] = None,
|
|
369
|
+
) -> bool:
|
|
370
|
+
"""Returns True only if the config is a boolean True value.
|
|
371
|
+
Returns False for missing keys, non-boolean types, or boolean False."""
|
|
372
|
+
result = self._get(key, contexts)
|
|
373
|
+
if result is _NO_DEFAULT:
|
|
374
|
+
return default
|
|
375
|
+
if isinstance(result, bool):
|
|
376
|
+
return result
|
|
377
|
+
if isinstance(result, str):
|
|
378
|
+
if result.lower() == "true":
|
|
379
|
+
return True
|
|
380
|
+
if result.lower() == "false":
|
|
381
|
+
return False
|
|
382
|
+
# Non-boolean types (int, float, list, dict, etc.) return False
|
|
383
|
+
return False
|
|
384
|
+
|
|
385
|
+
def should_log(
|
|
386
|
+
self,
|
|
387
|
+
logger_name: str,
|
|
388
|
+
desired_level: str,
|
|
389
|
+
contexts: Optional[Contexts] = None,
|
|
390
|
+
) -> bool:
|
|
391
|
+
"""
|
|
392
|
+
Return True if the given logger_name should log at desired_level.
|
|
393
|
+
|
|
394
|
+
Walks the hierarchy from specific to general:
|
|
395
|
+
log-levels.app.auth -> log-levels.app -> log-levels
|
|
396
|
+
"""
|
|
397
|
+
desired_order = LOG_LEVEL_ORDER.get(desired_level.upper())
|
|
398
|
+
if desired_order is None:
|
|
399
|
+
return True # Unknown level — log it
|
|
400
|
+
|
|
401
|
+
# Build hierarchy of keys to check
|
|
402
|
+
parts = logger_name.split(".")
|
|
403
|
+
keys_to_check = []
|
|
404
|
+
for i in range(len(parts), 0, -1):
|
|
405
|
+
keys_to_check.append("log-levels." + ".".join(parts[:i]))
|
|
406
|
+
keys_to_check.append("log-levels")
|
|
407
|
+
|
|
408
|
+
for key in keys_to_check:
|
|
409
|
+
result = self._get(key, contexts)
|
|
410
|
+
if result is not _NO_DEFAULT and result is not None:
|
|
411
|
+
configured_order = LOG_LEVEL_ORDER.get(str(result).upper())
|
|
412
|
+
if configured_order is not None:
|
|
413
|
+
return desired_order >= configured_order
|
|
414
|
+
# No config found — default to logging everything
|
|
415
|
+
return True
|
|
416
|
+
|
|
417
|
+
# ------------------------------------------------------------------
|
|
418
|
+
# Context scoping
|
|
419
|
+
# ------------------------------------------------------------------
|
|
420
|
+
|
|
421
|
+
def with_context(self, contexts: Contexts) -> "BoundQuonfig":
|
|
422
|
+
from .bound_client import BoundQuonfig
|
|
423
|
+
|
|
424
|
+
return BoundQuonfig(self, contexts)
|
|
425
|
+
|
|
426
|
+
@contextlib.contextmanager
|
|
427
|
+
def scoped_context(self, contexts: Contexts):
|
|
428
|
+
"""Context manager that sets thread-local context for the duration."""
|
|
429
|
+
old = get_thread_context()
|
|
430
|
+
try:
|
|
431
|
+
set_thread_context(contexts)
|
|
432
|
+
yield self
|
|
433
|
+
finally:
|
|
434
|
+
if old is None:
|
|
435
|
+
clear_thread_context()
|
|
436
|
+
else:
|
|
437
|
+
set_thread_context(old)
|
|
438
|
+
|
|
439
|
+
# ------------------------------------------------------------------
|
|
440
|
+
# Misc
|
|
441
|
+
# ------------------------------------------------------------------
|
|
442
|
+
|
|
443
|
+
def keys(self) -> List[str]:
|
|
444
|
+
return self._store.keys()
|
|
445
|
+
|
|
446
|
+
def close(self) -> None:
|
|
447
|
+
self._shutdown.set()
|
|
448
|
+
if self._telemetry is not None:
|
|
449
|
+
try:
|
|
450
|
+
self._telemetry.stop()
|
|
451
|
+
except Exception:
|
|
452
|
+
pass
|
|
453
|
+
|
|
454
|
+
def __enter__(self) -> "Quonfig":
|
|
455
|
+
return self
|
|
456
|
+
|
|
457
|
+
def __exit__(self, *args: Any) -> None:
|
|
458
|
+
self.close()
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
from typing import Any, Optional, Tuple
|
|
6
|
+
|
|
7
|
+
from .types import Contexts
|
|
8
|
+
|
|
9
|
+
_thread_local = threading.local()
|
|
10
|
+
|
|
11
|
+
# Magic property names that resolve to current time in milliseconds
|
|
12
|
+
_MAGIC_TIME_PROPS = frozenset(
|
|
13
|
+
["prefab.current-time", "quonfig.current-time", "reforge.current-time"]
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def merge_contexts(*contexts_list: Contexts) -> Contexts:
|
|
18
|
+
"""Shallow merge per namespace; later wins."""
|
|
19
|
+
result: Contexts = {}
|
|
20
|
+
for ctx in contexts_list:
|
|
21
|
+
if not ctx:
|
|
22
|
+
continue
|
|
23
|
+
for namespace, values in ctx.items():
|
|
24
|
+
result[namespace] = dict(values)
|
|
25
|
+
return result
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_context_value(contexts: Contexts, property_name: str) -> Tuple[Any, bool]:
|
|
29
|
+
"""
|
|
30
|
+
Dotted-path lookup: "user.email" -> contexts["user"]["email"].
|
|
31
|
+
|
|
32
|
+
Magic properties are resolved before normal lookup:
|
|
33
|
+
- "prefab.current-time", "quonfig.current-time", "reforge.current-time"
|
|
34
|
+
-> current Unix time in ms
|
|
35
|
+
|
|
36
|
+
Returns (value, found: bool).
|
|
37
|
+
"""
|
|
38
|
+
if property_name in _MAGIC_TIME_PROPS:
|
|
39
|
+
return int(time.time() * 1000), True
|
|
40
|
+
|
|
41
|
+
if not property_name:
|
|
42
|
+
return None, False
|
|
43
|
+
|
|
44
|
+
parts = property_name.split(".", maxsplit=1)
|
|
45
|
+
if len(parts) == 1:
|
|
46
|
+
# No namespace — look in "" namespace
|
|
47
|
+
namespace = ""
|
|
48
|
+
key = property_name
|
|
49
|
+
else:
|
|
50
|
+
namespace, key = parts
|
|
51
|
+
|
|
52
|
+
ns_data = contexts.get(namespace)
|
|
53
|
+
if ns_data is None:
|
|
54
|
+
return None, False
|
|
55
|
+
|
|
56
|
+
if key in ns_data:
|
|
57
|
+
return ns_data[key], True
|
|
58
|
+
return None, False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def set_thread_context(contexts: Contexts) -> None:
|
|
62
|
+
"""Store contexts in thread-local storage."""
|
|
63
|
+
_thread_local.quonfig_context = contexts
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def get_thread_context() -> Optional[Contexts]:
|
|
67
|
+
"""Retrieve contexts from thread-local storage."""
|
|
68
|
+
return getattr(_thread_local, "quonfig_context", None)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def clear_thread_context() -> None:
|
|
72
|
+
"""Remove contexts from thread-local storage."""
|
|
73
|
+
if hasattr(_thread_local, "quonfig_context"):
|
|
74
|
+
del _thread_local.quonfig_context
|