python-infrakit-dev 0.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.
- infrakit/__init__.py +0 -0
- infrakit/cli/__init__.py +1 -0
- infrakit/cli/commands/__init__.py +1 -0
- infrakit/cli/commands/deps.py +530 -0
- infrakit/cli/commands/init.py +129 -0
- infrakit/cli/commands/llm.py +295 -0
- infrakit/cli/commands/logger.py +160 -0
- infrakit/cli/commands/module.py +342 -0
- infrakit/cli/commands/time.py +81 -0
- infrakit/cli/main.py +65 -0
- infrakit/core/__init__.py +0 -0
- infrakit/core/config/__init__.py +0 -0
- infrakit/core/config/converter.py +480 -0
- infrakit/core/config/exporter.py +304 -0
- infrakit/core/config/loader.py +713 -0
- infrakit/core/config/validator.py +389 -0
- infrakit/core/logger/__init__.py +21 -0
- infrakit/core/logger/formatters.py +143 -0
- infrakit/core/logger/handlers.py +322 -0
- infrakit/core/logger/retention.py +176 -0
- infrakit/core/logger/setup.py +314 -0
- infrakit/deps/__init__.py +239 -0
- infrakit/deps/clean.py +141 -0
- infrakit/deps/depfile.py +405 -0
- infrakit/deps/health.py +357 -0
- infrakit/deps/optimizer.py +642 -0
- infrakit/deps/scanner.py +550 -0
- infrakit/llm/__init__.py +35 -0
- infrakit/llm/batch.py +165 -0
- infrakit/llm/client.py +575 -0
- infrakit/llm/key_manager.py +728 -0
- infrakit/llm/llm_readme.md +306 -0
- infrakit/llm/models.py +148 -0
- infrakit/llm/providers/__init__.py +5 -0
- infrakit/llm/providers/base.py +112 -0
- infrakit/llm/providers/gemini.py +164 -0
- infrakit/llm/providers/openai.py +168 -0
- infrakit/llm/rate_limiter.py +54 -0
- infrakit/scaffolder/__init__.py +31 -0
- infrakit/scaffolder/ai.py +508 -0
- infrakit/scaffolder/backend.py +555 -0
- infrakit/scaffolder/cli_tool.py +386 -0
- infrakit/scaffolder/generator.py +338 -0
- infrakit/scaffolder/pipeline.py +562 -0
- infrakit/scaffolder/registry.py +121 -0
- infrakit/time/__init__.py +60 -0
- infrakit/time/profiler.py +511 -0
- python_infrakit_dev-0.1.0.dist-info/METADATA +124 -0
- python_infrakit_dev-0.1.0.dist-info/RECORD +51 -0
- python_infrakit_dev-0.1.0.dist-info/WHEEL +4 -0
- python_infrakit_dev-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,713 @@
|
|
|
1
|
+
"""
|
|
2
|
+
infrakit.core.config.loader
|
|
3
|
+
~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
4
|
+
Load configuration from JSON, YAML, INI, and .env files into a plain dict,
|
|
5
|
+
with optional type casting, env layering, and variable interpolation.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
from infrakit.core.config.loader import load, load_env, cast_value
|
|
9
|
+
|
|
10
|
+
cfg = load("config.yaml")
|
|
11
|
+
env = load_env(".env") # strings only
|
|
12
|
+
env = load_env(".env", cast_values=True) # auto-cast types
|
|
13
|
+
|
|
14
|
+
# Layer .env on top — inject brand-new keys too
|
|
15
|
+
cfg = load("config.yaml", env_file=".env", inject_new=True)
|
|
16
|
+
|
|
17
|
+
# Expand ${VAR} references in config values using .env as the source
|
|
18
|
+
cfg = load("config.yaml", env_file=".env", interpolate=True)
|
|
19
|
+
|
|
20
|
+
# All three together
|
|
21
|
+
cfg = load("config.yaml", env_file=".env",
|
|
22
|
+
inject_new=True, interpolate=True, cast_values=True)
|
|
23
|
+
|
|
24
|
+
Type casting rules (applied to string values only):
|
|
25
|
+
"true" / "false" -> bool
|
|
26
|
+
"null" / "none" / "" -> None
|
|
27
|
+
"42" -> int
|
|
28
|
+
"3.14" -> float
|
|
29
|
+
"13,hello,2.5" -> [13, "hello", 2.5] (comma-separated list)
|
|
30
|
+
anything else -> str (unchanged)
|
|
31
|
+
|
|
32
|
+
Variable interpolation:
|
|
33
|
+
${KEY} in any string value is replaced with the value of KEY from
|
|
34
|
+
env_file (or os.environ if env_override=True). Unknown references are
|
|
35
|
+
left as-is. Interpolation runs before casting.
|
|
36
|
+
|
|
37
|
+
# config.yaml # .env
|
|
38
|
+
database: DATABASE_URL=postgres://user:pass@localhost/db
|
|
39
|
+
url: ${DATABASE_URL} LOG_DIR=/app/logs
|
|
40
|
+
log_dir: ${LOG_DIR}/app
|
|
41
|
+
|
|
42
|
+
Within-file .env interpolation (handled natively by python-dotenv):
|
|
43
|
+
BASE=/app
|
|
44
|
+
LOG_DIR=${BASE}/logs -> LOG_DIR=/app/logs
|
|
45
|
+
|
|
46
|
+
JSON and YAML already carry native types — casting is skipped for those.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
from __future__ import annotations
|
|
50
|
+
|
|
51
|
+
import json
|
|
52
|
+
import os
|
|
53
|
+
from configparser import ConfigParser
|
|
54
|
+
from pathlib import Path
|
|
55
|
+
from typing import Any
|
|
56
|
+
|
|
57
|
+
# PyYAML is an optional dependency — raise a clear error if missing
|
|
58
|
+
try:
|
|
59
|
+
import yaml
|
|
60
|
+
_YAML_AVAILABLE = True
|
|
61
|
+
except ImportError:
|
|
62
|
+
_YAML_AVAILABLE = False
|
|
63
|
+
|
|
64
|
+
# python-dotenv is an optional dependency
|
|
65
|
+
try:
|
|
66
|
+
from dotenv import dotenv_values, find_dotenv
|
|
67
|
+
_DOTENV_AVAILABLE = True
|
|
68
|
+
except ImportError:
|
|
69
|
+
_DOTENV_AVAILABLE = False
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
# Public types
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
ConfigDict = dict[str, Any]
|
|
77
|
+
|
|
78
|
+
# Native Python scalar — what a cast_value() call can return
|
|
79
|
+
ScalarValue = bool | int | float | str | list[Any] | None
|
|
80
|
+
|
|
81
|
+
SUPPORTED_EXTENSIONS = {".json", ".yaml", ".yml", ".ini", ".cfg", ".env"}
|
|
82
|
+
|
|
83
|
+
# Formats whose values are always plain strings and benefit from casting.
|
|
84
|
+
# JSON and YAML already carry native types, so we skip casting there.
|
|
85
|
+
_STRING_ONLY_FORMATS = {".ini", ".cfg", ".env"}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
# Exceptions
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
class ConfigLoadError(Exception):
|
|
93
|
+
"""Raised when a config file cannot be loaded or parsed."""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class UnsupportedFormatError(ConfigLoadError):
|
|
97
|
+
"""Raised when the file extension is not recognised."""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class MissingDependencyError(ConfigLoadError):
|
|
101
|
+
"""Raised when an optional dependency required for a format is not installed."""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
# Type casting
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
#: Sentinel used internally so cast_value() can distinguish "the value was
|
|
109
|
+
#: the empty string" from "nothing was passed".
|
|
110
|
+
_MISSING = object()
|
|
111
|
+
|
|
112
|
+
# Boolean literals — matched case-insensitively
|
|
113
|
+
_TRUE_VALUES = {"true", "yes", "on", "1"}
|
|
114
|
+
_FALSE_VALUES = {"false", "no", "off", "0"}
|
|
115
|
+
_NULL_VALUES = {"null", "none", "~"}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def cast_value(value: str) -> ScalarValue:
|
|
119
|
+
"""Cast a single string value to the most specific Python type.
|
|
120
|
+
|
|
121
|
+
Casting priority (first match wins):
|
|
122
|
+
|
|
123
|
+
1. Empty string -> ``None``
|
|
124
|
+
2. Boolean literals -> ``bool`` (true/false/yes/no/on/off/1/0)
|
|
125
|
+
3. Null literals -> ``None`` (null/none/~)
|
|
126
|
+
4. Integer -> ``int``
|
|
127
|
+
5. Float -> ``float``
|
|
128
|
+
6. Comma-separated list -> ``list`` (each item is recursively cast)
|
|
129
|
+
7. Fallback -> ``str`` (unchanged)
|
|
130
|
+
|
|
131
|
+
Parameters
|
|
132
|
+
----------
|
|
133
|
+
value:
|
|
134
|
+
A string, as produced by INI or .env parsers.
|
|
135
|
+
|
|
136
|
+
Returns
|
|
137
|
+
-------
|
|
138
|
+
ScalarValue
|
|
139
|
+
The cast Python value.
|
|
140
|
+
|
|
141
|
+
Examples
|
|
142
|
+
--------
|
|
143
|
+
>>> cast_value("true")
|
|
144
|
+
True
|
|
145
|
+
>>> cast_value("3.14")
|
|
146
|
+
3.14
|
|
147
|
+
>>> cast_value("42")
|
|
148
|
+
42
|
|
149
|
+
>>> cast_value("null")
|
|
150
|
+
None
|
|
151
|
+
>>> cast_value("")
|
|
152
|
+
None
|
|
153
|
+
>>> cast_value("13,hello,2.5")
|
|
154
|
+
[13, 'hello', 2.5]
|
|
155
|
+
>>> cast_value("hello")
|
|
156
|
+
'hello'
|
|
157
|
+
"""
|
|
158
|
+
if not isinstance(value, str):
|
|
159
|
+
# Already a native type (e.g. from YAML) — leave it alone
|
|
160
|
+
return value # type: ignore[return-value]
|
|
161
|
+
|
|
162
|
+
stripped = value.strip()
|
|
163
|
+
|
|
164
|
+
# 1. Empty string -> None
|
|
165
|
+
if stripped == "":
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
lower = stripped.lower()
|
|
169
|
+
|
|
170
|
+
# 2. Boolean
|
|
171
|
+
if lower in _TRUE_VALUES:
|
|
172
|
+
return True
|
|
173
|
+
if lower in _FALSE_VALUES:
|
|
174
|
+
return False
|
|
175
|
+
|
|
176
|
+
# 3. Null
|
|
177
|
+
if lower in _NULL_VALUES:
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
# 4. Integer (covers negatives; leading zeros like "007" stay as str)
|
|
181
|
+
if _is_plain_integer(stripped):
|
|
182
|
+
return int(stripped)
|
|
183
|
+
|
|
184
|
+
# 5. Float — but only if it doesn't look like a leading-zero string.
|
|
185
|
+
# Without this guard, float("007") == 7.0 would slip through here
|
|
186
|
+
# after _is_plain_integer correctly rejects it as an int.
|
|
187
|
+
candidate = stripped.lstrip("+-")
|
|
188
|
+
has_leading_zero = len(candidate) > 1 and candidate[0] == "0" and candidate[1].isdigit()
|
|
189
|
+
if not has_leading_zero:
|
|
190
|
+
try:
|
|
191
|
+
float_val = float(stripped)
|
|
192
|
+
if not _is_special_float(stripped):
|
|
193
|
+
return float_val
|
|
194
|
+
except ValueError:
|
|
195
|
+
pass
|
|
196
|
+
|
|
197
|
+
# 6. Comma-separated list (requires at least one comma)
|
|
198
|
+
if "," in stripped:
|
|
199
|
+
items = [item.strip() for item in stripped.split(",")]
|
|
200
|
+
# Filter out empty items caused by trailing commas ("a,b,")
|
|
201
|
+
return [cast_value(item) for item in items if item != ""]
|
|
202
|
+
|
|
203
|
+
# 7. Fallback — plain string
|
|
204
|
+
return stripped
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def cast_dict(data: ConfigDict) -> ConfigDict:
|
|
208
|
+
"""Recursively cast all string leaf values in *data*.
|
|
209
|
+
|
|
210
|
+
Nested dicts (e.g. INI sections) are traversed. Non-string values
|
|
211
|
+
(already native types from JSON/YAML) are left untouched.
|
|
212
|
+
|
|
213
|
+
Parameters
|
|
214
|
+
----------
|
|
215
|
+
data:
|
|
216
|
+
A config dict, potentially with nested dicts as values.
|
|
217
|
+
|
|
218
|
+
Returns
|
|
219
|
+
-------
|
|
220
|
+
ConfigDict
|
|
221
|
+
A new dict with cast values.
|
|
222
|
+
"""
|
|
223
|
+
result: ConfigDict = {}
|
|
224
|
+
for key, value in data.items():
|
|
225
|
+
if isinstance(value, dict):
|
|
226
|
+
result[key] = cast_dict(value)
|
|
227
|
+
elif isinstance(value, str):
|
|
228
|
+
result[key] = cast_value(value)
|
|
229
|
+
else:
|
|
230
|
+
result[key] = value
|
|
231
|
+
return result
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# ---------------------------------------------------------------------------
|
|
235
|
+
# Casting helpers
|
|
236
|
+
# ---------------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
def _is_plain_integer(s: str) -> bool:
|
|
239
|
+
"""Return True only for decimal integers without leading zeros (except "0")."""
|
|
240
|
+
if not s:
|
|
241
|
+
return False
|
|
242
|
+
candidate = s.lstrip("+-")
|
|
243
|
+
if not candidate.isdigit():
|
|
244
|
+
return False
|
|
245
|
+
# Reject leading zeros: "007", "00", etc. — keep as string
|
|
246
|
+
if len(candidate) > 1 and candidate[0] == "0":
|
|
247
|
+
return False
|
|
248
|
+
return True
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _is_special_float(s: str) -> bool:
|
|
252
|
+
"""Return True for "inf", "-inf", "nan" — we don't cast these."""
|
|
253
|
+
lower = s.lower().lstrip("+-")
|
|
254
|
+
return lower in {"inf", "infinity", "nan"}
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# ---------------------------------------------------------------------------
|
|
258
|
+
# Internal format loaders
|
|
259
|
+
# ---------------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
def _load_json(path: Path) -> ConfigDict:
|
|
262
|
+
try:
|
|
263
|
+
with path.open("r", encoding="utf-8") as f:
|
|
264
|
+
data = json.load(f)
|
|
265
|
+
except json.JSONDecodeError as exc:
|
|
266
|
+
raise ConfigLoadError(f"Invalid JSON in '{path}': {exc}") from exc
|
|
267
|
+
|
|
268
|
+
if not isinstance(data, dict):
|
|
269
|
+
raise ConfigLoadError(
|
|
270
|
+
f"Expected a JSON object at the top level in '{path}', "
|
|
271
|
+
f"got {type(data).__name__}."
|
|
272
|
+
)
|
|
273
|
+
return data
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _load_yaml(path: Path) -> ConfigDict:
|
|
277
|
+
if not _YAML_AVAILABLE:
|
|
278
|
+
raise MissingDependencyError(
|
|
279
|
+
"PyYAML is required to load YAML files. "
|
|
280
|
+
"Install it with: pip install pyyaml"
|
|
281
|
+
)
|
|
282
|
+
try:
|
|
283
|
+
with path.open("r", encoding="utf-8") as f:
|
|
284
|
+
data = yaml.safe_load(f)
|
|
285
|
+
except yaml.YAMLError as exc:
|
|
286
|
+
raise ConfigLoadError(f"Invalid YAML in '{path}': {exc}") from exc
|
|
287
|
+
|
|
288
|
+
if data is None:
|
|
289
|
+
return {}
|
|
290
|
+
if not isinstance(data, dict):
|
|
291
|
+
raise ConfigLoadError(
|
|
292
|
+
f"Expected a YAML mapping at the top level in '{path}', "
|
|
293
|
+
f"got {type(data).__name__}."
|
|
294
|
+
)
|
|
295
|
+
return data
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _load_ini(path: Path) -> ConfigDict:
|
|
299
|
+
"""
|
|
300
|
+
Load an INI file and return a nested dict.
|
|
301
|
+
|
|
302
|
+
Top-level keys without a section are stored under the special
|
|
303
|
+
key ``"DEFAULT"`` by ConfigParser convention. Each section becomes
|
|
304
|
+
a nested dict, e.g.:
|
|
305
|
+
|
|
306
|
+
[database]
|
|
307
|
+
host = localhost -> {"database": {"host": "localhost"}}
|
|
308
|
+
"""
|
|
309
|
+
parser = ConfigParser()
|
|
310
|
+
try:
|
|
311
|
+
read = parser.read(path, encoding="utf-8")
|
|
312
|
+
except Exception as exc:
|
|
313
|
+
raise ConfigLoadError(f"Could not read INI file '{path}': {exc}") from exc
|
|
314
|
+
|
|
315
|
+
if not read:
|
|
316
|
+
raise ConfigLoadError(f"INI file not found or unreadable: '{path}'")
|
|
317
|
+
|
|
318
|
+
result: ConfigDict = {}
|
|
319
|
+
|
|
320
|
+
# DEFAULT section (key=value pairs outside any section header)
|
|
321
|
+
if parser.defaults():
|
|
322
|
+
result["DEFAULT"] = dict(parser.defaults())
|
|
323
|
+
|
|
324
|
+
for section in parser.sections():
|
|
325
|
+
# parser[section] includes DEFAULT keys too — use .options() to
|
|
326
|
+
# grab only keys defined in this section
|
|
327
|
+
result[section] = {
|
|
328
|
+
key: parser.get(section, key)
|
|
329
|
+
for key in parser.options(section)
|
|
330
|
+
if key not in parser.defaults()
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return result
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _load_dotenv(path: Path) -> ConfigDict:
|
|
337
|
+
"""
|
|
338
|
+
Load a .env file and return a flat dict of string key→value pairs.
|
|
339
|
+
Comments and blank lines are ignored by python-dotenv.
|
|
340
|
+
"""
|
|
341
|
+
if not _DOTENV_AVAILABLE:
|
|
342
|
+
raise MissingDependencyError(
|
|
343
|
+
"python-dotenv is required to load .env files. "
|
|
344
|
+
"Install it with: pip install python-dotenv"
|
|
345
|
+
)
|
|
346
|
+
values = dotenv_values(path)
|
|
347
|
+
return dict(values) # dotenv_values returns OrderedDict
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
# ---------------------------------------------------------------------------
|
|
351
|
+
# Format dispatch
|
|
352
|
+
# ---------------------------------------------------------------------------
|
|
353
|
+
|
|
354
|
+
_LOADERS = {
|
|
355
|
+
".json": _load_json,
|
|
356
|
+
".yaml": _load_yaml,
|
|
357
|
+
".yml": _load_yaml,
|
|
358
|
+
".ini": _load_ini,
|
|
359
|
+
".cfg": _load_ini,
|
|
360
|
+
".env": _load_dotenv,
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
# ---------------------------------------------------------------------------
|
|
365
|
+
# Public API
|
|
366
|
+
# ---------------------------------------------------------------------------
|
|
367
|
+
|
|
368
|
+
def load(
|
|
369
|
+
path: str | Path,
|
|
370
|
+
*,
|
|
371
|
+
env_override: bool = False,
|
|
372
|
+
env_file: str | Path | None = ".env",
|
|
373
|
+
inject_new: bool = False,
|
|
374
|
+
interpolate: bool = True,
|
|
375
|
+
cast_values: bool = True,
|
|
376
|
+
) -> ConfigDict:
|
|
377
|
+
"""Load a config file and return its contents as a dict.
|
|
378
|
+
|
|
379
|
+
Supports JSON, YAML (.yaml / .yml), INI (.ini / .cfg), and .env files.
|
|
380
|
+
The format is inferred from the file extension.
|
|
381
|
+
|
|
382
|
+
Parameters
|
|
383
|
+
----------
|
|
384
|
+
path:
|
|
385
|
+
Path to the config file.
|
|
386
|
+
env_override:
|
|
387
|
+
If ``True``, environment variables present in ``os.environ`` will
|
|
388
|
+
override keys found in the config file (existing keys only,
|
|
389
|
+
case-insensitive on Windows).
|
|
390
|
+
env_file:
|
|
391
|
+
Optional path to a ``.env`` file whose values are applied after
|
|
392
|
+
the base config is loaded. Controlled by *inject_new*.
|
|
393
|
+
inject_new:
|
|
394
|
+
Only applies when *env_file* is set.
|
|
395
|
+
|
|
396
|
+
``False`` (default) — only keys already present in the base config
|
|
397
|
+
are updated from the .env file. Keys unique to .env are ignored.
|
|
398
|
+
This is safe for ``os.environ`` (avoids injecting PATH, HOME, etc).
|
|
399
|
+
|
|
400
|
+
``True`` — all keys from the .env file are merged in, including
|
|
401
|
+
brand-new ones not present in the base config. Useful when .env
|
|
402
|
+
is the primary source of runtime variables.
|
|
403
|
+
interpolate:
|
|
404
|
+
If ``True``, any ``${KEY}`` reference in a string value is expanded
|
|
405
|
+
using the .env file values (or ``os.environ`` if *env_override* is
|
|
406
|
+
True). The lookup order is: env_file first, then os.environ.
|
|
407
|
+
|
|
408
|
+
Unknown ``${KEY}`` references are left as-is rather than raising.
|
|
409
|
+
Interpolation runs before casting so cast_values sees expanded values.
|
|
410
|
+
|
|
411
|
+
Example — config.yaml::
|
|
412
|
+
|
|
413
|
+
database:
|
|
414
|
+
url: ${DATABASE_URL}
|
|
415
|
+
log_dir: ${LOG_DIR}/app
|
|
416
|
+
|
|
417
|
+
With DATABASE_URL=postgres://... in .env, ``url`` becomes the
|
|
418
|
+
full connection string after interpolation.
|
|
419
|
+
cast_values:
|
|
420
|
+
If ``True``, string values from string-only formats (INI, .env) are
|
|
421
|
+
automatically cast to their most specific Python type. JSON and YAML
|
|
422
|
+
already carry native types, so casting is skipped for those.
|
|
423
|
+
|
|
424
|
+
Casting rules:
|
|
425
|
+
|
|
426
|
+
- ``"true"`` / ``"false"`` → ``bool``
|
|
427
|
+
- ``"null"`` / ``"none"`` / ``""`` → ``None``
|
|
428
|
+
- ``"42"`` → ``int``
|
|
429
|
+
- ``"2.5"`` → ``float``
|
|
430
|
+
- ``"13,hello,2.5"`` → ``[13, "hello", 2.5]``
|
|
431
|
+
- anything else → ``str``
|
|
432
|
+
|
|
433
|
+
Returns
|
|
434
|
+
-------
|
|
435
|
+
ConfigDict
|
|
436
|
+
A plain ``dict[str, Any]``.
|
|
437
|
+
|
|
438
|
+
Raises
|
|
439
|
+
------
|
|
440
|
+
FileNotFoundError
|
|
441
|
+
If *path* does not exist.
|
|
442
|
+
UnsupportedFormatError
|
|
443
|
+
If the file extension is not one of the supported formats.
|
|
444
|
+
ConfigLoadError
|
|
445
|
+
If the file exists but cannot be parsed.
|
|
446
|
+
"""
|
|
447
|
+
path = Path(path)
|
|
448
|
+
|
|
449
|
+
if not path.exists():
|
|
450
|
+
raise FileNotFoundError(f"Config file not found: '{path}'")
|
|
451
|
+
|
|
452
|
+
ext = _get_extension(path)
|
|
453
|
+
loader_fn = _LOADERS.get(ext)
|
|
454
|
+
if loader_fn is None:
|
|
455
|
+
raise UnsupportedFormatError(
|
|
456
|
+
f"Unsupported config format '{ext!r}'. "
|
|
457
|
+
f"Supported extensions: {', '.join(sorted(SUPPORTED_EXTENSIONS))}"
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
config = loader_fn(path)
|
|
461
|
+
|
|
462
|
+
# Collect the env vars that will be used for both override and interpolation
|
|
463
|
+
env_vars: dict[str, str] = {}
|
|
464
|
+
|
|
465
|
+
# Apply .env file — inject_new controls whether new keys are added.
|
|
466
|
+
# case_insensitive=True matches "HOST" in .env to "host" in config.
|
|
467
|
+
# When interpolate=True, skip overriding values that contain ${...}
|
|
468
|
+
# templates — interpolation will expand them correctly instead.
|
|
469
|
+
if env_file is not None:
|
|
470
|
+
env_file_path = Path(env_file)
|
|
471
|
+
# Auto-detect location if only a filename is provided
|
|
472
|
+
if _DOTENV_AVAILABLE and env_file_path.parent == Path():
|
|
473
|
+
found_env = find_dotenv(env_file_path.name, usecwd=True)
|
|
474
|
+
if found_env:
|
|
475
|
+
env_file_path = Path(found_env)
|
|
476
|
+
|
|
477
|
+
if env_file_path.exists():
|
|
478
|
+
dotenv_vals = _load_dotenv(env_file_path)
|
|
479
|
+
env_vars.update(dotenv_vals)
|
|
480
|
+
config = _apply_flat_overrides(
|
|
481
|
+
config, dotenv_vals,
|
|
482
|
+
case_insensitive=True,
|
|
483
|
+
inject_new=inject_new,
|
|
484
|
+
skip_templates=interpolate,
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
# Apply os.environ overrides — existing keys only, case-insensitive.
|
|
488
|
+
# Same template-skip logic applies.
|
|
489
|
+
if env_override:
|
|
490
|
+
env_vars.update(os.environ)
|
|
491
|
+
config = _apply_flat_overrides(
|
|
492
|
+
config, dict(os.environ),
|
|
493
|
+
case_insensitive=True,
|
|
494
|
+
inject_new=False,
|
|
495
|
+
skip_templates=interpolate,
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
# Expand ${KEY} and ${KEY:-default} references.
|
|
499
|
+
# Run even when env_vars is empty — defaults (:-) work without any vars.
|
|
500
|
+
if interpolate:
|
|
501
|
+
config = _interpolate_dict(config, env_vars)
|
|
502
|
+
|
|
503
|
+
# Cast string values for string-only formats (INI / .env).
|
|
504
|
+
# JSON and YAML are skipped — they already have native types.
|
|
505
|
+
if cast_values and ext in _STRING_ONLY_FORMATS:
|
|
506
|
+
config = cast_dict(config)
|
|
507
|
+
|
|
508
|
+
return config
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def load_env(path: str | Path = ".env", *, cast_values: bool = False) -> ConfigDict:
|
|
512
|
+
"""Convenience wrapper — load a .env file directly.
|
|
513
|
+
|
|
514
|
+
Parameters
|
|
515
|
+
----------
|
|
516
|
+
path:
|
|
517
|
+
Path to the .env file. Defaults to ``.env`` in the current directory.
|
|
518
|
+
cast_values:
|
|
519
|
+
If ``True``, string values are automatically cast to their most
|
|
520
|
+
specific Python type. See :func:`load` for casting rules.
|
|
521
|
+
|
|
522
|
+
Returns
|
|
523
|
+
-------
|
|
524
|
+
ConfigDict
|
|
525
|
+
``dict[str, Any]`` — strings if *cast_values* is False, native
|
|
526
|
+
types otherwise.
|
|
527
|
+
"""
|
|
528
|
+
path = Path(path)
|
|
529
|
+
# Auto-detect location if only a filename is provided
|
|
530
|
+
if _DOTENV_AVAILABLE and path.parent == Path():
|
|
531
|
+
found_env = find_dotenv(path.name, usecwd=True)
|
|
532
|
+
if found_env:
|
|
533
|
+
path = Path(found_env)
|
|
534
|
+
|
|
535
|
+
if not path.exists():
|
|
536
|
+
raise FileNotFoundError(f".env file not found: '{path}'")
|
|
537
|
+
data = _load_dotenv(path)
|
|
538
|
+
return cast_dict(data) if cast_values else data
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def detect_format(path: str | Path) -> str:
|
|
542
|
+
"""Return the format name for a given file path.
|
|
543
|
+
|
|
544
|
+
Useful for UI feedback and logging.
|
|
545
|
+
|
|
546
|
+
Returns one of: ``"json"``, ``"yaml"``, ``"ini"``, ``"env"``.
|
|
547
|
+
|
|
548
|
+
Raises
|
|
549
|
+
------
|
|
550
|
+
UnsupportedFormatError
|
|
551
|
+
If the extension is not recognised.
|
|
552
|
+
"""
|
|
553
|
+
ext = _get_extension(Path(path))
|
|
554
|
+
_FORMAT_NAMES = {
|
|
555
|
+
".json": "json",
|
|
556
|
+
".yaml": "yaml",
|
|
557
|
+
".yml": "yaml",
|
|
558
|
+
".ini": "ini",
|
|
559
|
+
".cfg": "ini",
|
|
560
|
+
".env": "env",
|
|
561
|
+
}
|
|
562
|
+
name = _FORMAT_NAMES.get(ext)
|
|
563
|
+
if name is None:
|
|
564
|
+
raise UnsupportedFormatError(
|
|
565
|
+
f"Cannot detect format for extension '{ext}'."
|
|
566
|
+
)
|
|
567
|
+
return name
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
# ---------------------------------------------------------------------------
|
|
571
|
+
# Helpers
|
|
572
|
+
# ---------------------------------------------------------------------------
|
|
573
|
+
|
|
574
|
+
def _get_extension(path: Path) -> str:
|
|
575
|
+
"""Return the lowercase extension for *path*, handling dotfiles correctly.
|
|
576
|
+
|
|
577
|
+
``Path(".env").suffix`` returns ``""`` on all platforms because Python
|
|
578
|
+
treats ``.env`` as a stem with no extension. We detect this case by
|
|
579
|
+
checking if the name starts with a dot and has no further dot in the name.
|
|
580
|
+
|
|
581
|
+
Path(".env") -> ".env"
|
|
582
|
+
Path("config.yaml") -> ".yaml"
|
|
583
|
+
Path("config.ini") -> ".ini"
|
|
584
|
+
"""
|
|
585
|
+
suffix = path.suffix.lower()
|
|
586
|
+
if suffix:
|
|
587
|
+
return suffix
|
|
588
|
+
# Dotfile: name is entirely the "extension" (e.g. ".env", ".envrc")
|
|
589
|
+
name = path.name.lower()
|
|
590
|
+
if name.startswith(".") and "." not in name[1:]:
|
|
591
|
+
return name
|
|
592
|
+
return suffix
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _apply_flat_overrides(
|
|
596
|
+
config: ConfigDict,
|
|
597
|
+
overrides: dict[str, str],
|
|
598
|
+
*,
|
|
599
|
+
case_insensitive: bool = False,
|
|
600
|
+
inject_new: bool = False,
|
|
601
|
+
skip_templates: bool = False,
|
|
602
|
+
) -> ConfigDict:
|
|
603
|
+
"""Apply flat string overrides to the top level of *config*.
|
|
604
|
+
|
|
605
|
+
Parameters
|
|
606
|
+
----------
|
|
607
|
+
case_insensitive:
|
|
608
|
+
Match override keys to config keys without regard to case.
|
|
609
|
+
The config key casing is preserved in the result.
|
|
610
|
+
inject_new:
|
|
611
|
+
If ``True``, keys present in *overrides* but absent from *config*
|
|
612
|
+
are inserted as new entries. If ``False`` (default), only existing
|
|
613
|
+
keys are updated — unknown override keys are silently ignored.
|
|
614
|
+
skip_templates:
|
|
615
|
+
If ``True``, skip overriding any config value that contains a
|
|
616
|
+
``${...}`` template token — those values are left for interpolation
|
|
617
|
+
to expand instead. Without this guard, the override step would
|
|
618
|
+
clobber ``"${LOG_DIR}/app"`` with ``"/var/log"`` before interpolation
|
|
619
|
+
runs, losing the ``/app`` suffix permanently.
|
|
620
|
+
"""
|
|
621
|
+
import re
|
|
622
|
+
_TEMPLATE_RE = re.compile(r"\$\{[^}]+\}")
|
|
623
|
+
|
|
624
|
+
def _has_template(value: Any) -> bool:
|
|
625
|
+
return isinstance(value, str) and bool(_TEMPLATE_RE.search(value))
|
|
626
|
+
|
|
627
|
+
result = dict(config)
|
|
628
|
+
|
|
629
|
+
if case_insensitive:
|
|
630
|
+
lower_map = {k.lower(): k for k in result}
|
|
631
|
+
for override_key, value in overrides.items():
|
|
632
|
+
original_key = lower_map.get(override_key.lower())
|
|
633
|
+
if original_key is not None:
|
|
634
|
+
if skip_templates and _has_template(result[original_key]):
|
|
635
|
+
continue # leave template intact for interpolation
|
|
636
|
+
result[original_key] = value
|
|
637
|
+
elif inject_new:
|
|
638
|
+
result[override_key] = value
|
|
639
|
+
else:
|
|
640
|
+
for key, value in overrides.items():
|
|
641
|
+
if key in result:
|
|
642
|
+
if skip_templates and _has_template(result[key]):
|
|
643
|
+
continue # leave template intact for interpolation
|
|
644
|
+
result[key] = value
|
|
645
|
+
elif inject_new:
|
|
646
|
+
result[key] = value
|
|
647
|
+
|
|
648
|
+
return result
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def _interpolate_dict(config: ConfigDict, env_vars: dict[str, str]) -> ConfigDict:
|
|
652
|
+
"""Recursively expand ``${KEY}`` references in all string values.
|
|
653
|
+
|
|
654
|
+
Traverses nested dicts. Non-string values are left untouched.
|
|
655
|
+
Unknown ``${KEY}`` references are left as-is.
|
|
656
|
+
|
|
657
|
+
Parameters
|
|
658
|
+
----------
|
|
659
|
+
config:
|
|
660
|
+
The config dict to expand (not mutated — a new dict is returned).
|
|
661
|
+
env_vars:
|
|
662
|
+
Flat dict of available variable values. Typically a merge of
|
|
663
|
+
dotenv values and/or os.environ.
|
|
664
|
+
"""
|
|
665
|
+
result: ConfigDict = {}
|
|
666
|
+
for key, value in config.items():
|
|
667
|
+
if isinstance(value, dict):
|
|
668
|
+
result[key] = _interpolate_dict(value, env_vars)
|
|
669
|
+
elif isinstance(value, str):
|
|
670
|
+
result[key] = _expand_vars(value, env_vars)
|
|
671
|
+
else:
|
|
672
|
+
result[key] = value
|
|
673
|
+
return result
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def _expand_vars(value: str, env_vars: dict[str, str]) -> str:
|
|
677
|
+
"""Replace all ``${KEY}`` tokens in *value* with values from *env_vars*.
|
|
678
|
+
|
|
679
|
+
Handles:
|
|
680
|
+
${KEY} standard token
|
|
681
|
+
${KEY:-default} token with fallback if KEY is missing or empty
|
|
682
|
+
|
|
683
|
+
Unknown tokens with no default are left as-is so callers can detect them.
|
|
684
|
+
|
|
685
|
+
Examples
|
|
686
|
+
--------
|
|
687
|
+
>>> _expand_vars("postgres://${HOST}/db", {"HOST": "localhost"})
|
|
688
|
+
'postgres://localhost/db'
|
|
689
|
+
>>> _expand_vars("${MISSING}", {})
|
|
690
|
+
'${MISSING}'
|
|
691
|
+
>>> _expand_vars("${TIMEOUT:-30}", {})
|
|
692
|
+
'30'
|
|
693
|
+
>>> _expand_vars("${PORT:-8080}", {"PORT": "9090"})
|
|
694
|
+
'9090'
|
|
695
|
+
"""
|
|
696
|
+
import re
|
|
697
|
+
|
|
698
|
+
def _replace(match: re.Match) -> str:
|
|
699
|
+
key = match.group(1)
|
|
700
|
+
default = match.group(2) # None if no :- syntax
|
|
701
|
+
|
|
702
|
+
resolved = env_vars.get(key)
|
|
703
|
+
|
|
704
|
+
if resolved:
|
|
705
|
+
return resolved
|
|
706
|
+
if default is not None:
|
|
707
|
+
return default
|
|
708
|
+
# Unknown reference — leave the original token intact
|
|
709
|
+
return match.group(0)
|
|
710
|
+
|
|
711
|
+
# Match ${KEY} and ${KEY:-default}
|
|
712
|
+
pattern = re.compile(r"\$\{([^}:]+)(?::-(.*?))?\}")
|
|
713
|
+
return pattern.sub(_replace, value)
|