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 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