lib-layered-config 1.0.0__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.
Potentially problematic release.
This version of lib-layered-config might be problematic. Click here for more details.
- lib_layered_config/__init__.py +60 -0
- lib_layered_config/__main__.py +19 -0
- lib_layered_config/_layers.py +457 -0
- lib_layered_config/_platform.py +200 -0
- lib_layered_config/adapters/__init__.py +13 -0
- lib_layered_config/adapters/dotenv/__init__.py +1 -0
- lib_layered_config/adapters/dotenv/default.py +438 -0
- lib_layered_config/adapters/env/__init__.py +5 -0
- lib_layered_config/adapters/env/default.py +509 -0
- lib_layered_config/adapters/file_loaders/__init__.py +1 -0
- lib_layered_config/adapters/file_loaders/structured.py +410 -0
- lib_layered_config/adapters/path_resolvers/__init__.py +1 -0
- lib_layered_config/adapters/path_resolvers/default.py +727 -0
- lib_layered_config/application/__init__.py +12 -0
- lib_layered_config/application/merge.py +442 -0
- lib_layered_config/application/ports.py +109 -0
- lib_layered_config/cli/__init__.py +162 -0
- lib_layered_config/cli/common.py +232 -0
- lib_layered_config/cli/constants.py +12 -0
- lib_layered_config/cli/deploy.py +70 -0
- lib_layered_config/cli/fail.py +21 -0
- lib_layered_config/cli/generate.py +60 -0
- lib_layered_config/cli/info.py +31 -0
- lib_layered_config/cli/read.py +117 -0
- lib_layered_config/core.py +384 -0
- lib_layered_config/domain/__init__.py +7 -0
- lib_layered_config/domain/config.py +490 -0
- lib_layered_config/domain/errors.py +65 -0
- lib_layered_config/examples/__init__.py +29 -0
- lib_layered_config/examples/deploy.py +305 -0
- lib_layered_config/examples/generate.py +537 -0
- lib_layered_config/observability.py +306 -0
- lib_layered_config/py.typed +0 -0
- lib_layered_config/testing.py +55 -0
- lib_layered_config-1.0.0.dist-info/METADATA +366 -0
- lib_layered_config-1.0.0.dist-info/RECORD +39 -0
- lib_layered_config-1.0.0.dist-info/WHEEL +4 -0
- lib_layered_config-1.0.0.dist-info/entry_points.txt +3 -0
- lib_layered_config-1.0.0.dist-info/licenses/LICENSE +22 -0
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
"""Environment variable adapter.
|
|
2
|
+
|
|
3
|
+
Purpose
|
|
4
|
+
-------
|
|
5
|
+
Translate process environment variables into nested configuration dictionaries.
|
|
6
|
+
It implements the port described in ``docs/systemdesign/module_reference.md``
|
|
7
|
+
and forms the final precedence layer in ``lib_layered_config``.
|
|
8
|
+
|
|
9
|
+
Contents
|
|
10
|
+
- ``default_env_prefix``: canonical prefix builder for a slug.
|
|
11
|
+
- ``DefaultEnvLoader``: orchestrates filtering, coercion, and nesting.
|
|
12
|
+
- ``assign_nested`` / ``_ensure_child_mapping`` / ``_resolve_key``: shared
|
|
13
|
+
helpers re-used by dotenv parsing to keep shapes aligned.
|
|
14
|
+
- ``_coerce`` plus tiny predicate helpers that translate strings into
|
|
15
|
+
Python primitives.
|
|
16
|
+
- ``_normalize_prefix`` / ``_iter_namespace_entries`` / ``_collect_keys``:
|
|
17
|
+
small verbs that keep the loader body declarative.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import os
|
|
23
|
+
from collections.abc import Iterable, Iterator
|
|
24
|
+
from typing import cast
|
|
25
|
+
|
|
26
|
+
from ...observability import log_debug
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def default_env_prefix(slug: str) -> str:
|
|
30
|
+
"""Return the canonical environment prefix for *slug*.
|
|
31
|
+
|
|
32
|
+
Why
|
|
33
|
+
----
|
|
34
|
+
Namespacing prevents unrelated environment variables from leaking into the
|
|
35
|
+
configuration payload.
|
|
36
|
+
|
|
37
|
+
Parameters
|
|
38
|
+
----------
|
|
39
|
+
slug:
|
|
40
|
+
Package/application slug (typically ``kebab-case``).
|
|
41
|
+
|
|
42
|
+
Returns
|
|
43
|
+
-------
|
|
44
|
+
str
|
|
45
|
+
Upper-case prefix with dashes converted to underscores.
|
|
46
|
+
|
|
47
|
+
Examples
|
|
48
|
+
--------
|
|
49
|
+
>>> default_env_prefix('lib-layered-config')
|
|
50
|
+
'LIB_LAYERED_CONFIG'
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
return slug.replace("-", "_").upper()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class DefaultEnvLoader:
|
|
57
|
+
"""Load environment variables that belong to the configuration namespace.
|
|
58
|
+
|
|
59
|
+
Why
|
|
60
|
+
----
|
|
61
|
+
Implements the :class:`lib_layered_config.application.ports.EnvLoader` port,
|
|
62
|
+
translating process environment variables into merge-ready payloads.
|
|
63
|
+
|
|
64
|
+
What
|
|
65
|
+
----
|
|
66
|
+
Filters environment entries by prefix, nests values using ``__`` separators,
|
|
67
|
+
performs primitive coercion, and emits observability events.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(self, *, environ: dict[str, str] | None = None) -> None:
|
|
71
|
+
"""Initialise the loader with a specific ``environ`` mapping for testability.
|
|
72
|
+
|
|
73
|
+
Why
|
|
74
|
+
----
|
|
75
|
+
Allow tests and callers to supply deterministic environments.
|
|
76
|
+
|
|
77
|
+
Parameters
|
|
78
|
+
----------
|
|
79
|
+
environ:
|
|
80
|
+
Mapping to read from. Defaults to :data:`os.environ`.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
self._environ = os.environ if environ is None else environ
|
|
84
|
+
|
|
85
|
+
def load(self, prefix: str) -> dict[str, object]:
|
|
86
|
+
"""Return a nested mapping containing variables with the supplied *prefix*.
|
|
87
|
+
|
|
88
|
+
Why
|
|
89
|
+
----
|
|
90
|
+
Environment variables should integrate with the merge pipeline using the
|
|
91
|
+
same nesting semantics as `.env` files.
|
|
92
|
+
|
|
93
|
+
What
|
|
94
|
+
----
|
|
95
|
+
Normalises the prefix, filters matching entries, coerces values, nests
|
|
96
|
+
keys via :func:`assign_nested`, and logs the summarised result.
|
|
97
|
+
|
|
98
|
+
Parameters
|
|
99
|
+
----------
|
|
100
|
+
prefix:
|
|
101
|
+
Prefix filter (upper-case). The loader appends ``_`` if missing.
|
|
102
|
+
|
|
103
|
+
Returns
|
|
104
|
+
-------
|
|
105
|
+
dict[str, object]
|
|
106
|
+
Nested mapping suitable for the merge algorithm. Keys are stored in
|
|
107
|
+
lowercase to align with file-based layers.
|
|
108
|
+
|
|
109
|
+
Side Effects
|
|
110
|
+
------------
|
|
111
|
+
Emits ``env_variables_loaded`` debug events with summarised keys.
|
|
112
|
+
|
|
113
|
+
Examples
|
|
114
|
+
--------
|
|
115
|
+
>>> env = {
|
|
116
|
+
... 'DEMO_SERVICE__ENABLED': 'true',
|
|
117
|
+
... 'DEMO_SERVICE__RETRIES': '3',
|
|
118
|
+
... }
|
|
119
|
+
>>> loader = DefaultEnvLoader(environ=env)
|
|
120
|
+
>>> payload = loader.load('DEMO')
|
|
121
|
+
>>> payload['service']['retries']
|
|
122
|
+
3
|
|
123
|
+
>>> payload['service']['enabled']
|
|
124
|
+
True
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
normalized_prefix = _normalize_prefix(prefix)
|
|
128
|
+
collected: dict[str, object] = {}
|
|
129
|
+
for raw_key, value in _iter_namespace_entries(self._environ.items(), normalized_prefix):
|
|
130
|
+
assign_nested(collected, raw_key, _coerce(value))
|
|
131
|
+
log_debug("env_variables_loaded", layer="env", path=None, keys=_collect_keys(collected))
|
|
132
|
+
return collected
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _normalize_prefix(prefix: str) -> str:
|
|
136
|
+
"""Ensure the prefix ends with an underscore when non-empty.
|
|
137
|
+
|
|
138
|
+
Why
|
|
139
|
+
----
|
|
140
|
+
Aligns environment variable filtering semantics regardless of user input.
|
|
141
|
+
|
|
142
|
+
Parameters
|
|
143
|
+
----------
|
|
144
|
+
prefix:
|
|
145
|
+
Raw prefix string (upper-case expected but not enforced).
|
|
146
|
+
|
|
147
|
+
Returns
|
|
148
|
+
-------
|
|
149
|
+
str
|
|
150
|
+
Prefix guaranteed to end with ``_`` when non-empty.
|
|
151
|
+
|
|
152
|
+
Examples
|
|
153
|
+
--------
|
|
154
|
+
>>> _normalize_prefix('DEMO')
|
|
155
|
+
'DEMO_'
|
|
156
|
+
>>> _normalize_prefix('DEMO_')
|
|
157
|
+
'DEMO_'
|
|
158
|
+
>>> _normalize_prefix('')
|
|
159
|
+
''
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
if prefix and not prefix.endswith("_"):
|
|
163
|
+
return f"{prefix}_"
|
|
164
|
+
return prefix
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _iter_namespace_entries(
|
|
168
|
+
items: Iterable[tuple[str, str]],
|
|
169
|
+
prefix: str,
|
|
170
|
+
) -> Iterator[tuple[str, str]]:
|
|
171
|
+
"""Yield ``(stripped_key, value)`` pairs that match *prefix*.
|
|
172
|
+
|
|
173
|
+
Why
|
|
174
|
+
----
|
|
175
|
+
Encapsulate prefix filtering so caller code stays declarative.
|
|
176
|
+
|
|
177
|
+
Parameters
|
|
178
|
+
----------
|
|
179
|
+
items:
|
|
180
|
+
Iterable of environment items to examine.
|
|
181
|
+
prefix:
|
|
182
|
+
Normalised prefix (including trailing underscore) to filter on.
|
|
183
|
+
|
|
184
|
+
Returns
|
|
185
|
+
-------
|
|
186
|
+
Iterator[tuple[str, str]]
|
|
187
|
+
Pairs whose keys share the prefix with the prefix removed.
|
|
188
|
+
|
|
189
|
+
Examples
|
|
190
|
+
--------
|
|
191
|
+
>>> list(_iter_namespace_entries([('DEMO_FLAG', '1'), ('OTHER', '0')], 'DEMO_'))
|
|
192
|
+
[('FLAG', '1')]
|
|
193
|
+
>>> list(_iter_namespace_entries([('DEMO', '1')], 'DEMO_'))
|
|
194
|
+
[]
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
for key, value in items:
|
|
198
|
+
if prefix and not key.startswith(prefix):
|
|
199
|
+
continue
|
|
200
|
+
stripped = key[len(prefix) :] if prefix else key
|
|
201
|
+
if not stripped:
|
|
202
|
+
continue
|
|
203
|
+
yield stripped, value
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _collect_keys(mapping: dict[str, object]) -> list[str]:
|
|
207
|
+
"""Return sorted top-level keys for logging.
|
|
208
|
+
|
|
209
|
+
Why
|
|
210
|
+
----
|
|
211
|
+
Provide compact telemetry context without dumping entire payloads.
|
|
212
|
+
|
|
213
|
+
Parameters
|
|
214
|
+
----------
|
|
215
|
+
mapping:
|
|
216
|
+
Nested mapping produced by environment parsing.
|
|
217
|
+
|
|
218
|
+
Returns
|
|
219
|
+
-------
|
|
220
|
+
list[str]
|
|
221
|
+
Sorted list of top-level keys.
|
|
222
|
+
|
|
223
|
+
Examples
|
|
224
|
+
--------
|
|
225
|
+
>>> _collect_keys({'service': {}, 'logging': {}})
|
|
226
|
+
['logging', 'service']
|
|
227
|
+
"""
|
|
228
|
+
|
|
229
|
+
return sorted(mapping.keys())
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def assign_nested(target: dict[str, object], key: str, value: object) -> None:
|
|
233
|
+
"""Assign ``value`` inside ``target`` using ``__`` as a nesting delimiter.
|
|
234
|
+
|
|
235
|
+
Why
|
|
236
|
+
----
|
|
237
|
+
Reuse the same semantics as dotenv parsing so callers see consistent shapes.
|
|
238
|
+
|
|
239
|
+
What
|
|
240
|
+
----
|
|
241
|
+
Splits the key on ``__``, ensures intermediate mappings exist, resolves
|
|
242
|
+
case-insensitive keys, and assigns the final value.
|
|
243
|
+
|
|
244
|
+
Parameters
|
|
245
|
+
----------
|
|
246
|
+
target:
|
|
247
|
+
Mapping being mutated in place.
|
|
248
|
+
key:
|
|
249
|
+
Namespaced key using ``__`` separators.
|
|
250
|
+
value:
|
|
251
|
+
Value to store at the resolved path.
|
|
252
|
+
|
|
253
|
+
Returns
|
|
254
|
+
-------
|
|
255
|
+
None
|
|
256
|
+
|
|
257
|
+
Side Effects
|
|
258
|
+
------------
|
|
259
|
+
Mutates ``target``.
|
|
260
|
+
|
|
261
|
+
Examples
|
|
262
|
+
--------
|
|
263
|
+
>>> data: dict[str, object] = {}
|
|
264
|
+
>>> assign_nested(data, 'SERVICE__TIMEOUT', 5)
|
|
265
|
+
>>> data
|
|
266
|
+
{'service': {'timeout': 5}}
|
|
267
|
+
"""
|
|
268
|
+
|
|
269
|
+
parts = key.split("__")
|
|
270
|
+
cursor = target
|
|
271
|
+
for part in parts[:-1]:
|
|
272
|
+
cursor = _ensure_child_mapping(cursor, part, error_cls=ValueError)
|
|
273
|
+
final_key = _resolve_key(cursor, parts[-1])
|
|
274
|
+
cursor[final_key] = value
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _resolve_key(mapping: dict[str, object], key: str) -> str:
|
|
278
|
+
"""Return an existing key that matches ``key`` (case-insensitive) or a new lowercase key.
|
|
279
|
+
|
|
280
|
+
Why
|
|
281
|
+
----
|
|
282
|
+
Preserve case stability while avoiding duplicates that differ only by case.
|
|
283
|
+
|
|
284
|
+
Parameters
|
|
285
|
+
----------
|
|
286
|
+
mapping:
|
|
287
|
+
Mutable mapping being inspected.
|
|
288
|
+
key:
|
|
289
|
+
Incoming key to resolve.
|
|
290
|
+
|
|
291
|
+
Returns
|
|
292
|
+
-------
|
|
293
|
+
str
|
|
294
|
+
Existing key name or newly normalised lowercase variant.
|
|
295
|
+
|
|
296
|
+
Examples
|
|
297
|
+
--------
|
|
298
|
+
>>> target = {'timeout': 5}
|
|
299
|
+
>>> _resolve_key(target, 'TIMEOUT')
|
|
300
|
+
'timeout'
|
|
301
|
+
>>> _resolve_key({}, 'Endpoint')
|
|
302
|
+
'endpoint'
|
|
303
|
+
"""
|
|
304
|
+
|
|
305
|
+
lower = key.lower()
|
|
306
|
+
for existing in mapping.keys():
|
|
307
|
+
if existing.lower() == lower:
|
|
308
|
+
return existing
|
|
309
|
+
return lower
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _ensure_child_mapping(mapping: dict[str, object], key: str, *, error_cls: type[Exception]) -> dict[str, object]:
|
|
313
|
+
"""Ensure ``mapping[key]`` is a ``dict`` (creating or validating as necessary).
|
|
314
|
+
|
|
315
|
+
Why
|
|
316
|
+
----
|
|
317
|
+
Prevent accidental overwrites of scalar values when nested keys are
|
|
318
|
+
introduced.
|
|
319
|
+
|
|
320
|
+
What
|
|
321
|
+
----
|
|
322
|
+
Resolves the key case-insensitively, creates an empty mapping when missing,
|
|
323
|
+
or raises ``error_cls`` if a scalar already occupies the slot.
|
|
324
|
+
|
|
325
|
+
Parameters
|
|
326
|
+
----------
|
|
327
|
+
mapping:
|
|
328
|
+
Mutable mapping being mutated.
|
|
329
|
+
key:
|
|
330
|
+
Candidate key to ensure.
|
|
331
|
+
error_cls:
|
|
332
|
+
Exception type raised when a scalar collision occurs.
|
|
333
|
+
|
|
334
|
+
Returns
|
|
335
|
+
-------
|
|
336
|
+
dict[str, object]
|
|
337
|
+
Child mapping stored at the resolved key.
|
|
338
|
+
|
|
339
|
+
Side Effects
|
|
340
|
+
------------
|
|
341
|
+
Mutates ``mapping`` by inserting a new child mapping when missing.
|
|
342
|
+
|
|
343
|
+
Examples
|
|
344
|
+
--------
|
|
345
|
+
>>> target = {}
|
|
346
|
+
>>> child = _ensure_child_mapping(target, 'SERVICE', error_cls=ValueError)
|
|
347
|
+
>>> child == {}
|
|
348
|
+
True
|
|
349
|
+
>>> target
|
|
350
|
+
{'service': {}}
|
|
351
|
+
>>> target['service'] = 1
|
|
352
|
+
>>> _ensure_child_mapping(target, 'SERVICE', error_cls=ValueError)
|
|
353
|
+
Traceback (most recent call last):
|
|
354
|
+
...
|
|
355
|
+
ValueError: Cannot override scalar with mapping for key SERVICE
|
|
356
|
+
"""
|
|
357
|
+
|
|
358
|
+
resolved = _resolve_key(mapping, key)
|
|
359
|
+
if resolved not in mapping:
|
|
360
|
+
mapping[resolved] = dict[str, object]()
|
|
361
|
+
child = mapping[resolved]
|
|
362
|
+
if not isinstance(child, dict):
|
|
363
|
+
raise error_cls(f"Cannot override scalar with mapping for key {key}")
|
|
364
|
+
typed_child = cast(dict[str, object], child)
|
|
365
|
+
mapping[resolved] = typed_child
|
|
366
|
+
return typed_child
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _coerce(value: str) -> object:
|
|
370
|
+
"""Coerce textual environment values to Python primitives where possible.
|
|
371
|
+
|
|
372
|
+
Why
|
|
373
|
+
----
|
|
374
|
+
Convert human-friendly strings (``true``, ``5``, ``3.14``) into their Python
|
|
375
|
+
equivalents before merging.
|
|
376
|
+
|
|
377
|
+
What
|
|
378
|
+
----
|
|
379
|
+
Applies boolean, null, integer, and float heuristics in sequence, returning
|
|
380
|
+
the original string when none match.
|
|
381
|
+
|
|
382
|
+
Returns
|
|
383
|
+
-------
|
|
384
|
+
object
|
|
385
|
+
Parsed primitive or original string when coercion is not possible.
|
|
386
|
+
|
|
387
|
+
Examples
|
|
388
|
+
--------
|
|
389
|
+
>>> _coerce('true'), _coerce('10'), _coerce('3.5'), _coerce('hello'), _coerce('null')
|
|
390
|
+
(True, 10, 3.5, 'hello', None)
|
|
391
|
+
"""
|
|
392
|
+
|
|
393
|
+
lowered = value.lower()
|
|
394
|
+
if _looks_like_bool(lowered):
|
|
395
|
+
return lowered == "true"
|
|
396
|
+
if _looks_like_null(lowered):
|
|
397
|
+
return None
|
|
398
|
+
if _looks_like_int(value):
|
|
399
|
+
return int(value)
|
|
400
|
+
return _maybe_float(value)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _looks_like_bool(value: str) -> bool:
|
|
404
|
+
"""Return ``True`` when *value* spells a boolean literal.
|
|
405
|
+
|
|
406
|
+
Why
|
|
407
|
+
----
|
|
408
|
+
Support `_coerce` in recognising booleans without repeated literal sets.
|
|
409
|
+
|
|
410
|
+
Parameters
|
|
411
|
+
----------
|
|
412
|
+
value:
|
|
413
|
+
Lower-cased string to inspect.
|
|
414
|
+
|
|
415
|
+
Returns
|
|
416
|
+
-------
|
|
417
|
+
bool
|
|
418
|
+
``True`` when the value is ``"true"`` or ``"false"``.
|
|
419
|
+
|
|
420
|
+
Examples
|
|
421
|
+
--------
|
|
422
|
+
>>> _looks_like_bool('true'), _looks_like_bool('false'), _looks_like_bool('maybe')
|
|
423
|
+
(True, True, False)
|
|
424
|
+
"""
|
|
425
|
+
|
|
426
|
+
return value in {"true", "false"}
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _looks_like_null(value: str) -> bool:
|
|
430
|
+
"""Return ``True`` when *value* represents a null literal.
|
|
431
|
+
|
|
432
|
+
Why
|
|
433
|
+
----
|
|
434
|
+
Allow `_coerce` to map textual null representations to ``None``.
|
|
435
|
+
|
|
436
|
+
Parameters
|
|
437
|
+
----------
|
|
438
|
+
value:
|
|
439
|
+
Lower-cased string to inspect.
|
|
440
|
+
|
|
441
|
+
Returns
|
|
442
|
+
-------
|
|
443
|
+
bool
|
|
444
|
+
``True`` when the value is ``"null"`` or ``"none"``.
|
|
445
|
+
|
|
446
|
+
Examples
|
|
447
|
+
--------
|
|
448
|
+
>>> _looks_like_null('null'), _looks_like_null('none'), _looks_like_null('nil')
|
|
449
|
+
(True, True, False)
|
|
450
|
+
"""
|
|
451
|
+
|
|
452
|
+
return value in {"null", "none"}
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _looks_like_int(value: str) -> bool:
|
|
456
|
+
"""Return ``True`` when *value* can be parsed as an integer.
|
|
457
|
+
|
|
458
|
+
Why
|
|
459
|
+
----
|
|
460
|
+
Let `_coerce` distinguish integers before attempting float conversion.
|
|
461
|
+
|
|
462
|
+
Parameters
|
|
463
|
+
----------
|
|
464
|
+
value:
|
|
465
|
+
String to inspect (not yet normalised).
|
|
466
|
+
|
|
467
|
+
Returns
|
|
468
|
+
-------
|
|
469
|
+
bool
|
|
470
|
+
``True`` when the value represents a base-10 integer literal.
|
|
471
|
+
|
|
472
|
+
Examples
|
|
473
|
+
--------
|
|
474
|
+
>>> _looks_like_int('42'), _looks_like_int('-7'), _looks_like_int('3.14')
|
|
475
|
+
(True, True, False)
|
|
476
|
+
"""
|
|
477
|
+
|
|
478
|
+
if value.startswith("-"):
|
|
479
|
+
return value[1:].isdigit()
|
|
480
|
+
return value.isdigit()
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _maybe_float(value: str) -> object:
|
|
484
|
+
"""Return a float when *value* looks numeric; otherwise return the original string.
|
|
485
|
+
|
|
486
|
+
Why
|
|
487
|
+
----
|
|
488
|
+
Provide a final numeric coercion step after integer detection fails.
|
|
489
|
+
|
|
490
|
+
Parameters
|
|
491
|
+
----------
|
|
492
|
+
value:
|
|
493
|
+
String candidate for float conversion.
|
|
494
|
+
|
|
495
|
+
Returns
|
|
496
|
+
-------
|
|
497
|
+
object
|
|
498
|
+
Float value or the original string when conversion fails.
|
|
499
|
+
|
|
500
|
+
Examples
|
|
501
|
+
--------
|
|
502
|
+
>>> _maybe_float('2.5'), _maybe_float('not-a-number')
|
|
503
|
+
(2.5, 'not-a-number')
|
|
504
|
+
"""
|
|
505
|
+
|
|
506
|
+
try:
|
|
507
|
+
return float(value)
|
|
508
|
+
except ValueError:
|
|
509
|
+
return value
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Structured configuration file loaders."""
|