lib-layered-config 4.1.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.
Files changed (47) hide show
  1. lib_layered_config/__init__.py +58 -0
  2. lib_layered_config/__init__conf__.py +74 -0
  3. lib_layered_config/__main__.py +18 -0
  4. lib_layered_config/_layers.py +310 -0
  5. lib_layered_config/_platform.py +166 -0
  6. lib_layered_config/adapters/__init__.py +13 -0
  7. lib_layered_config/adapters/_nested_keys.py +126 -0
  8. lib_layered_config/adapters/dotenv/__init__.py +1 -0
  9. lib_layered_config/adapters/dotenv/default.py +143 -0
  10. lib_layered_config/adapters/env/__init__.py +5 -0
  11. lib_layered_config/adapters/env/default.py +288 -0
  12. lib_layered_config/adapters/file_loaders/__init__.py +1 -0
  13. lib_layered_config/adapters/file_loaders/structured.py +376 -0
  14. lib_layered_config/adapters/path_resolvers/__init__.py +28 -0
  15. lib_layered_config/adapters/path_resolvers/_base.py +166 -0
  16. lib_layered_config/adapters/path_resolvers/_dotenv.py +74 -0
  17. lib_layered_config/adapters/path_resolvers/_linux.py +89 -0
  18. lib_layered_config/adapters/path_resolvers/_macos.py +93 -0
  19. lib_layered_config/adapters/path_resolvers/_windows.py +126 -0
  20. lib_layered_config/adapters/path_resolvers/default.py +194 -0
  21. lib_layered_config/application/__init__.py +12 -0
  22. lib_layered_config/application/merge.py +379 -0
  23. lib_layered_config/application/ports.py +115 -0
  24. lib_layered_config/cli/__init__.py +92 -0
  25. lib_layered_config/cli/common.py +381 -0
  26. lib_layered_config/cli/constants.py +12 -0
  27. lib_layered_config/cli/deploy.py +71 -0
  28. lib_layered_config/cli/fail.py +19 -0
  29. lib_layered_config/cli/generate.py +57 -0
  30. lib_layered_config/cli/info.py +29 -0
  31. lib_layered_config/cli/read.py +120 -0
  32. lib_layered_config/core.py +301 -0
  33. lib_layered_config/domain/__init__.py +7 -0
  34. lib_layered_config/domain/config.py +372 -0
  35. lib_layered_config/domain/errors.py +59 -0
  36. lib_layered_config/domain/identifiers.py +366 -0
  37. lib_layered_config/examples/__init__.py +29 -0
  38. lib_layered_config/examples/deploy.py +333 -0
  39. lib_layered_config/examples/generate.py +406 -0
  40. lib_layered_config/observability.py +209 -0
  41. lib_layered_config/py.typed +0 -0
  42. lib_layered_config/testing.py +46 -0
  43. lib_layered_config-4.1.0.dist-info/METADATA +3263 -0
  44. lib_layered_config-4.1.0.dist-info/RECORD +47 -0
  45. lib_layered_config-4.1.0.dist-info/WHEEL +4 -0
  46. lib_layered_config-4.1.0.dist-info/entry_points.txt +3 -0
  47. lib_layered_config-4.1.0.dist-info/licenses/LICENSE +22 -0
@@ -0,0 +1,126 @@
1
+ """Shared helpers for assigning nested keys using ``__`` delimiters.
2
+
3
+ Both dotenv and environment adapters need identical logic for converting
4
+ ``SERVICE__TIMEOUT`` style keys into nested ``{"service": {"timeout": ...}}``
5
+ structures. This module centralises that logic to eliminate duplication.
6
+
7
+ Contents:
8
+ - ``assign_nested``: public function to assign a value at a nested key path.
9
+ - ``resolve_key``: case-insensitive key resolution.
10
+ - ``ensure_child_mapping``: ensure intermediate mappings exist.
11
+ - ``NESTED_KEY_DELIMITER``: constant for the ``__`` separator.
12
+
13
+ Used by ``adapters.dotenv.default`` and ``adapters.env.default`` to share
14
+ the nested key assignment algorithm.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import Final, cast
20
+
21
+ NESTED_KEY_DELIMITER: Final[str] = "__"
22
+ """Delimiter used to separate nested key segments in environment variables.
23
+
24
+ Environment variables like ``SERVICE__TIMEOUT`` are split on this delimiter
25
+ to create nested structures like ``{"service": {"timeout": ...}}``.
26
+ """
27
+
28
+
29
+ def assign_nested(
30
+ target: dict[str, object],
31
+ key: str,
32
+ value: object,
33
+ *,
34
+ error_cls: type[Exception],
35
+ ) -> None:
36
+ """Assign ``value`` in ``target`` using case-insensitive ``__`` delimited syntax.
37
+
38
+ Args:
39
+ target: Mapping being mutated.
40
+ key: Key using ``__`` separators (e.g., ``SERVICE__TIMEOUT``).
41
+ value: Value to assign at the nested location.
42
+ error_cls: Exception type raised on scalar collisions.
43
+
44
+ Side Effects:
45
+ Mutates ``target`` in place.
46
+
47
+ Examples:
48
+ >>> data: dict[str, object] = {}
49
+ >>> assign_nested(data, 'SERVICE__TOKEN', 'secret', error_cls=ValueError)
50
+ >>> data
51
+ {'service': {'token': 'secret'}}
52
+ """
53
+ parts = key.split(NESTED_KEY_DELIMITER)
54
+ cursor = target
55
+ for part in parts[:-1]:
56
+ cursor = ensure_child_mapping(cursor, part, error_cls=error_cls)
57
+ final_key = resolve_key(cursor, parts[-1])
58
+ cursor[final_key] = value
59
+
60
+
61
+ def resolve_key(mapping: dict[str, object], key: str) -> str:
62
+ """Return an existing key matching ``key`` case-insensitively, or a new lowercase key.
63
+
64
+ Preserve case stability while avoiding duplicates that differ only by case.
65
+
66
+ Args:
67
+ mapping: Mutable mapping being inspected.
68
+ key: Incoming key to resolve.
69
+
70
+ Returns:
71
+ Existing key name or newly normalised lowercase variant.
72
+
73
+ Examples:
74
+ >>> resolve_key({'timeout': 5}, 'TIMEOUT')
75
+ 'timeout'
76
+ >>> resolve_key({}, 'Endpoint')
77
+ 'endpoint'
78
+ """
79
+ lower = key.lower()
80
+ for existing in mapping:
81
+ if existing.lower() == lower:
82
+ return existing
83
+ return lower
84
+
85
+
86
+ def ensure_child_mapping(
87
+ mapping: dict[str, object],
88
+ key: str,
89
+ *,
90
+ error_cls: type[Exception],
91
+ ) -> dict[str, object]:
92
+ """Ensure ``mapping[key]`` is a ``dict``, creating or validating as necessary.
93
+
94
+ Prevent accidental overwrites of scalar values when nested keys are introduced.
95
+
96
+ Args:
97
+ mapping: Mutable mapping being mutated.
98
+ key: Candidate key to ensure.
99
+ error_cls: Exception type raised when a scalar collision occurs.
100
+
101
+ Returns:
102
+ Child mapping stored at the resolved key.
103
+
104
+ Side Effects:
105
+ Mutates ``mapping`` by inserting a new child mapping when missing.
106
+
107
+ Examples:
108
+ >>> target: dict[str, object] = {}
109
+ >>> child = ensure_child_mapping(target, 'SERVICE', error_cls=ValueError)
110
+ >>> child == {}
111
+ True
112
+ >>> target
113
+ {'service': {}}
114
+ """
115
+ resolved = resolve_key(mapping, key)
116
+ if resolved not in mapping:
117
+ mapping[resolved] = dict[str, object]()
118
+ child = mapping[resolved]
119
+ if not isinstance(child, dict):
120
+ raise error_cls(f"Cannot override scalar with mapping for key {key}")
121
+ typed_child = cast(dict[str, object], child)
122
+ mapping[resolved] = typed_child
123
+ return typed_child
124
+
125
+
126
+ __all__ = ["assign_nested", "resolve_key", "ensure_child_mapping"]
@@ -0,0 +1 @@
1
+ """Dotenv adapter implementations."""
@@ -0,0 +1,143 @@
1
+ """`.env` adapter.
2
+
3
+ Implement the :class:`lib_layered_config.application.ports.DotEnvLoader`
4
+ protocol by scanning for `.env` files using the search discipline captured in
5
+ ``docs/systemdesign/module_reference.md``.
6
+
7
+ Contents:
8
+ - ``DefaultDotEnvLoader``: public loader that composes the helpers.
9
+ - ``_iter_candidates`` / ``_build_search_list``: gather candidate paths.
10
+ - ``_parse_dotenv``: strict parser converting dotenv files into nested dicts.
11
+ - ``_log_dotenv_*``: logging helpers that narrate discovery and parsing outcomes.
12
+ - Constants for parsing quote characters and delimiters.
13
+
14
+ Feeds `.env` key/value pairs into the merge pipeline using the same nesting
15
+ semantics as the environment adapter.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from collections.abc import Iterable, Mapping
21
+ from pathlib import Path
22
+ from typing import Final
23
+
24
+ from ...domain.errors import InvalidFormatError
25
+ from ...observability import log_debug, log_error
26
+ from .._nested_keys import assign_nested
27
+
28
+ # Constants for dotenv parsing
29
+ _QUOTE_CHARS: Final[frozenset[str]] = frozenset({'"', "'"})
30
+ _COMMENT_CHAR: Final[str] = "#"
31
+ _INLINE_COMMENT_DELIMITER: Final[str] = " #"
32
+ _KEY_VALUE_DELIMITER: Final[str] = "="
33
+
34
+ DOTENV_LAYER = "dotenv"
35
+ """Layer name for structured logging calls."""
36
+
37
+
38
+ def _log_dotenv_loaded(path: Path, keys: Mapping[str, object]) -> None:
39
+ """Log which dotenv file was loaded and its key names."""
40
+ log_debug("dotenv_loaded", layer=DOTENV_LAYER, path=str(path), keys=sorted(keys.keys()))
41
+
42
+
43
+ def _log_dotenv_missing() -> None:
44
+ """Log that no dotenv file was found in the search path."""
45
+ log_debug("dotenv_not_found", layer=DOTENV_LAYER, path=None)
46
+
47
+
48
+ def _log_dotenv_error(path: Path, line_number: int) -> None:
49
+ """Log a malformed line error with file path and line number."""
50
+ log_error("dotenv_invalid_line", layer=DOTENV_LAYER, path=str(path), line=line_number)
51
+
52
+
53
+ class DefaultDotEnvLoader:
54
+ """Load a dotenv file into a nested configuration dictionary.
55
+
56
+ Searches candidate paths, parses the first existing file, and tracks
57
+ the loaded path for provenance.
58
+ """
59
+
60
+ def __init__(self, *, extras: Iterable[str] | None = None) -> None:
61
+ """Initialise with optional extra search paths from the path resolver."""
62
+ self._extras = [Path(p) for p in extras or []]
63
+ self.last_loaded_path: str | None = None
64
+
65
+ def load(self, start_dir: str | None = None) -> Mapping[str, object]:
66
+ """Return the first parsed dotenv file discovered in the search order.
67
+
68
+ Searches from *start_dir* upward plus any extras, parses the first
69
+ existing file into a nested mapping, and sets :attr:`last_loaded_path`.
70
+ """
71
+ candidates = _build_search_list(start_dir, self._extras)
72
+ self.last_loaded_path = None
73
+ for candidate in candidates:
74
+ if not candidate.is_file():
75
+ continue
76
+ self.last_loaded_path = str(candidate)
77
+ data = _parse_dotenv(candidate)
78
+ _log_dotenv_loaded(candidate, data)
79
+ return data
80
+ _log_dotenv_missing()
81
+ return {}
82
+
83
+
84
+ def _build_search_list(start_dir: str | None, extras: Iterable[Path]) -> list[Path]:
85
+ """Combine upward-search candidates with platform-specific extras."""
86
+ return [*list(_iter_candidates(start_dir)), *extras]
87
+
88
+
89
+ def _iter_candidates(start_dir: str | None) -> Iterable[Path]:
90
+ """Yield `.env` paths from start_dir upward to filesystem root."""
91
+ base = Path(start_dir) if start_dir else Path.cwd()
92
+ for directory in [base, *base.parents]:
93
+ yield directory / ".env"
94
+
95
+
96
+ def _parse_dotenv(path: Path) -> Mapping[str, object]:
97
+ """Parse dotenv file into nested dict. Raises InvalidFormatError on malformed lines."""
98
+ result: dict[str, object] = {}
99
+ with path.open("r", encoding="utf-8") as handle:
100
+ for line_number, raw_line in enumerate(handle, start=1):
101
+ _process_line(result, raw_line, line_number, path)
102
+ return result
103
+
104
+
105
+ def _is_ignorable(line: str) -> bool:
106
+ """Return True if line is empty or a comment."""
107
+ return not line or line.startswith(_COMMENT_CHAR)
108
+
109
+
110
+ def _process_line(
111
+ result: dict[str, object],
112
+ raw_line: str,
113
+ line_number: int,
114
+ path: Path,
115
+ ) -> None:
116
+ """Process a single dotenv line, updating result in place."""
117
+ line = raw_line.strip()
118
+ if _is_ignorable(line):
119
+ return
120
+ if _KEY_VALUE_DELIMITER not in line:
121
+ _log_dotenv_error(path, line_number)
122
+ raise InvalidFormatError(f"Malformed line {line_number} in {path}")
123
+ key, value = line.split(_KEY_VALUE_DELIMITER, 1)
124
+ assign_nested(result, key.strip(), _strip_quotes(value.strip()), error_cls=InvalidFormatError)
125
+
126
+
127
+ def _is_quoted(value: str) -> bool:
128
+ """Check if value is wrapped in matching quotes."""
129
+ return len(value) >= 2 and value[0] == value[-1] and value[0] in _QUOTE_CHARS
130
+
131
+
132
+ def _strip_inline_comment(value: str) -> str:
133
+ """Remove trailing inline comment from value."""
134
+ return value.split(_INLINE_COMMENT_DELIMITER, 1)[0].strip() if _INLINE_COMMENT_DELIMITER in value else value
135
+
136
+
137
+ def _strip_quotes(value: str) -> str:
138
+ """Remove surrounding quotes and trailing inline comments from value."""
139
+ if _is_quoted(value):
140
+ return value[1:-1]
141
+ if value.startswith(_COMMENT_CHAR):
142
+ return ""
143
+ return _strip_inline_comment(value)
@@ -0,0 +1,5 @@
1
+ """Environment variable adapters for ``lib_layered_config``."""
2
+
3
+ from . import default
4
+
5
+ __all__ = ["default"]
@@ -0,0 +1,288 @@
1
+ """Environment variable adapter.
2
+
3
+ Translate process environment variables into nested configuration dictionaries.
4
+ It implements the port described in ``docs/systemdesign/module_reference.md``
5
+ and forms the final precedence layer in ``lib_layered_config``.
6
+
7
+ Contents:
8
+ - ``default_env_prefix``: canonical prefix builder for a slug.
9
+ - ``DefaultEnvLoader``: orchestrates filtering, coercion, and nesting.
10
+ - ``_coerce`` plus tiny predicate helpers that translate strings into
11
+ Python primitives.
12
+ - ``_normalize_prefix`` / ``_iter_namespace_entries`` / ``_collect_keys``:
13
+ small verbs that keep the loader body declarative.
14
+ - Constants for boolean and null literal detection.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ from collections.abc import Iterable, Iterator
21
+ from typing import Final
22
+
23
+ from ...observability import log_debug
24
+ from .._nested_keys import assign_nested
25
+
26
+ # Constants for environment variable value coercion
27
+ _BOOL_TRUE: Final[str] = "true"
28
+ _BOOL_FALSE: Final[str] = "false"
29
+ _BOOL_LITERALS: Final[frozenset[str]] = frozenset({_BOOL_TRUE, _BOOL_FALSE})
30
+ _NULL_LITERALS: Final[frozenset[str]] = frozenset({"null", "none"})
31
+
32
+
33
+ def default_env_prefix(slug: str) -> str:
34
+ """Return the canonical environment prefix for *slug*.
35
+
36
+ Namespacing prevents unrelated environment variables from leaking into the
37
+ configuration payload. The triple underscore (``___``) separator clearly
38
+ distinguishes the application prefix from section/key separators which use
39
+ double underscores (``__``).
40
+
41
+ Args:
42
+ slug: Package/application slug (typically ``kebab-case``).
43
+
44
+ Returns:
45
+ Upper-case prefix with dashes converted to underscores, ending with
46
+ triple underscore separator.
47
+
48
+ Examples:
49
+ >>> default_env_prefix('lib-layered-config')
50
+ 'LIB_LAYERED_CONFIG___'
51
+ """
52
+ return slug.replace("-", "_").upper() + "___"
53
+
54
+
55
+ class DefaultEnvLoader:
56
+ """Load environment variables that belong to the configuration namespace.
57
+
58
+ Implements the :class:`lib_layered_config.application.ports.EnvLoader` port,
59
+ translating process environment variables into merge-ready payloads.
60
+
61
+ Filters environment entries by prefix, nests values using ``__`` separators,
62
+ performs primitive coercion, and emits observability events.
63
+ """
64
+
65
+ def __init__(self, *, environ: dict[str, str] | None = None) -> None:
66
+ """Initialise the loader with a specific ``environ`` mapping for testability.
67
+
68
+ Allow tests and callers to supply deterministic environments.
69
+
70
+ Args:
71
+ environ: Mapping to read from. Defaults to :data:`os.environ`.
72
+ """
73
+ self._environ = os.environ if environ is None else environ
74
+
75
+ def load(self, prefix: str) -> dict[str, object]:
76
+ """Return a nested mapping containing variables with the supplied *prefix*.
77
+
78
+ Environment variables should integrate with the merge pipeline using the
79
+ same nesting semantics as `.env` files.
80
+
81
+ Normalises the prefix, filters matching entries, coerces values, nests
82
+ keys via :func:`assign_nested`, and logs the summarised result.
83
+
84
+ Args:
85
+ prefix: Prefix filter (upper-case). The loader appends ``___`` if missing.
86
+
87
+ Returns:
88
+ Nested mapping suitable for the merge algorithm. Keys are stored in
89
+ lowercase to align with file-based layers.
90
+
91
+ Side Effects:
92
+ Emits ``env_variables_loaded`` debug events with summarised keys.
93
+
94
+ Examples:
95
+ >>> env = {
96
+ ... 'DEMO___SERVICE__ENABLED': 'true',
97
+ ... 'DEMO___SERVICE__RETRIES': '3',
98
+ ... }
99
+ >>> loader = DefaultEnvLoader(environ=env)
100
+ >>> payload = loader.load('DEMO')
101
+ >>> payload['service']['retries']
102
+ 3
103
+ >>> payload['service']['enabled']
104
+ True
105
+ """
106
+ normalized_prefix = _normalize_prefix(prefix)
107
+ collected: dict[str, object] = {}
108
+ for raw_key, value in _iter_namespace_entries(self._environ.items(), normalized_prefix):
109
+ assign_nested(collected, raw_key, _coerce(value), error_cls=ValueError)
110
+ log_debug("env_variables_loaded", layer="env", path=None, keys=_collect_keys(collected))
111
+ return collected
112
+
113
+
114
+ def _normalize_prefix(prefix: str) -> str:
115
+ """Ensure the prefix ends with triple underscore when non-empty.
116
+
117
+ Aligns environment variable filtering semantics regardless of user input.
118
+ The triple underscore (``___``) separator clearly distinguishes the
119
+ application prefix from section/key separators (``__``).
120
+
121
+ Args:
122
+ prefix: Raw prefix string (upper-case expected but not enforced).
123
+
124
+ Returns:
125
+ Prefix guaranteed to end with ``___`` when non-empty.
126
+
127
+ Examples:
128
+ >>> _normalize_prefix('DEMO')
129
+ 'DEMO___'
130
+ >>> _normalize_prefix('DEMO___')
131
+ 'DEMO___'
132
+ >>> _normalize_prefix('')
133
+ ''
134
+ """
135
+ if prefix and not prefix.endswith("___"):
136
+ return f"{prefix}___"
137
+ return prefix
138
+
139
+
140
+ def _iter_namespace_entries(
141
+ items: Iterable[tuple[str, str]],
142
+ prefix: str,
143
+ ) -> Iterator[tuple[str, str]]:
144
+ """Yield ``(stripped_key, value)`` pairs that match *prefix*.
145
+
146
+ Encapsulate prefix filtering so caller code stays declarative.
147
+
148
+ Args:
149
+ items: Iterable of environment items to examine.
150
+ prefix: Normalised prefix (including trailing triple underscore) to filter on.
151
+
152
+ Returns:
153
+ Pairs whose keys share the prefix with the prefix removed.
154
+
155
+ Examples:
156
+ >>> list(_iter_namespace_entries([('DEMO___FLAG', '1'), ('OTHER', '0')], 'DEMO___'))
157
+ [('FLAG', '1')]
158
+ >>> list(_iter_namespace_entries([('DEMO', '1')], 'DEMO___'))
159
+ []
160
+ """
161
+ for key, value in items:
162
+ if prefix and not key.startswith(prefix):
163
+ continue
164
+ stripped = key[len(prefix) :] if prefix else key
165
+ if not stripped:
166
+ continue
167
+ yield stripped, value
168
+
169
+
170
+ def _collect_keys(mapping: dict[str, object]) -> list[str]:
171
+ """Return sorted top-level keys for logging.
172
+
173
+ Provide compact telemetry context without dumping entire payloads.
174
+
175
+ Args:
176
+ mapping: Nested mapping produced by environment parsing.
177
+
178
+ Returns:
179
+ Sorted list of top-level keys.
180
+
181
+ Examples:
182
+ >>> _collect_keys({'service': {}, 'logging': {}})
183
+ ['logging', 'service']
184
+ """
185
+ return sorted(mapping.keys())
186
+
187
+
188
+ def _coerce(value: str) -> object:
189
+ """Coerce textual environment values to Python primitives where possible.
190
+
191
+ Convert human-friendly strings (``true``, ``5``, ``3.14``) into their Python
192
+ equivalents before merging.
193
+
194
+ Applies boolean, null, integer, and float heuristics in sequence, returning
195
+ the original string when none match.
196
+
197
+ Returns:
198
+ Parsed primitive or original string when coercion is not possible.
199
+
200
+ Examples:
201
+ >>> _coerce('true'), _coerce('10'), _coerce('3.5'), _coerce('hello'), _coerce('null')
202
+ (True, 10, 3.5, 'hello', None)
203
+ """
204
+ lowered = value.lower()
205
+ if _looks_like_bool(lowered):
206
+ return lowered == _BOOL_TRUE
207
+ if _looks_like_null(lowered):
208
+ return None
209
+ if _looks_like_int(value):
210
+ return int(value)
211
+ return _maybe_float(value)
212
+
213
+
214
+ def _looks_like_bool(value: str) -> bool:
215
+ """Return ``True`` when *value* spells a boolean literal.
216
+
217
+ Support `_coerce` in recognising booleans without repeated literal sets.
218
+
219
+ Args:
220
+ value: Lower-cased string to inspect.
221
+
222
+ Returns:
223
+ ``True`` when the value is ``"true"`` or ``"false"``.
224
+
225
+ Examples:
226
+ >>> _looks_like_bool('true'), _looks_like_bool('false'), _looks_like_bool('maybe')
227
+ (True, True, False)
228
+ """
229
+ return value in _BOOL_LITERALS
230
+
231
+
232
+ def _looks_like_null(value: str) -> bool:
233
+ """Return ``True`` when *value* represents a null literal.
234
+
235
+ Allow `_coerce` to map textual null representations to ``None``.
236
+
237
+ Args:
238
+ value: Lower-cased string to inspect.
239
+
240
+ Returns:
241
+ ``True`` when the value is ``"null"`` or ``"none"``.
242
+
243
+ Examples:
244
+ >>> _looks_like_null('null'), _looks_like_null('none'), _looks_like_null('nil')
245
+ (True, True, False)
246
+ """
247
+ return value in _NULL_LITERALS
248
+
249
+
250
+ def _looks_like_int(value: str) -> bool:
251
+ """Return ``True`` when *value* can be parsed as an integer.
252
+
253
+ Let `_coerce` distinguish integers before attempting float conversion.
254
+
255
+ Args:
256
+ value: String to inspect (not yet normalised).
257
+
258
+ Returns:
259
+ ``True`` when the value represents a base-10 integer literal.
260
+
261
+ Examples:
262
+ >>> _looks_like_int('42'), _looks_like_int('-7'), _looks_like_int('3.14')
263
+ (True, True, False)
264
+ """
265
+ if value.startswith("-"):
266
+ return value[1:].isdigit()
267
+ return value.isdigit()
268
+
269
+
270
+ def _maybe_float(value: str) -> object:
271
+ """Return a float when *value* looks numeric; otherwise return the original string.
272
+
273
+ Provide a final numeric coercion step after integer detection fails.
274
+
275
+ Args:
276
+ value: String candidate for float conversion.
277
+
278
+ Returns:
279
+ Float value or the original string when conversion fails.
280
+
281
+ Examples:
282
+ >>> _maybe_float('2.5'), _maybe_float('not-a-number')
283
+ (2.5, 'not-a-number')
284
+ """
285
+ try:
286
+ return float(value)
287
+ except ValueError:
288
+ return value
@@ -0,0 +1 @@
1
+ """Structured configuration file loaders."""