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.
Files changed (51) hide show
  1. infrakit/__init__.py +0 -0
  2. infrakit/cli/__init__.py +1 -0
  3. infrakit/cli/commands/__init__.py +1 -0
  4. infrakit/cli/commands/deps.py +530 -0
  5. infrakit/cli/commands/init.py +129 -0
  6. infrakit/cli/commands/llm.py +295 -0
  7. infrakit/cli/commands/logger.py +160 -0
  8. infrakit/cli/commands/module.py +342 -0
  9. infrakit/cli/commands/time.py +81 -0
  10. infrakit/cli/main.py +65 -0
  11. infrakit/core/__init__.py +0 -0
  12. infrakit/core/config/__init__.py +0 -0
  13. infrakit/core/config/converter.py +480 -0
  14. infrakit/core/config/exporter.py +304 -0
  15. infrakit/core/config/loader.py +713 -0
  16. infrakit/core/config/validator.py +389 -0
  17. infrakit/core/logger/__init__.py +21 -0
  18. infrakit/core/logger/formatters.py +143 -0
  19. infrakit/core/logger/handlers.py +322 -0
  20. infrakit/core/logger/retention.py +176 -0
  21. infrakit/core/logger/setup.py +314 -0
  22. infrakit/deps/__init__.py +239 -0
  23. infrakit/deps/clean.py +141 -0
  24. infrakit/deps/depfile.py +405 -0
  25. infrakit/deps/health.py +357 -0
  26. infrakit/deps/optimizer.py +642 -0
  27. infrakit/deps/scanner.py +550 -0
  28. infrakit/llm/__init__.py +35 -0
  29. infrakit/llm/batch.py +165 -0
  30. infrakit/llm/client.py +575 -0
  31. infrakit/llm/key_manager.py +728 -0
  32. infrakit/llm/llm_readme.md +306 -0
  33. infrakit/llm/models.py +148 -0
  34. infrakit/llm/providers/__init__.py +5 -0
  35. infrakit/llm/providers/base.py +112 -0
  36. infrakit/llm/providers/gemini.py +164 -0
  37. infrakit/llm/providers/openai.py +168 -0
  38. infrakit/llm/rate_limiter.py +54 -0
  39. infrakit/scaffolder/__init__.py +31 -0
  40. infrakit/scaffolder/ai.py +508 -0
  41. infrakit/scaffolder/backend.py +555 -0
  42. infrakit/scaffolder/cli_tool.py +386 -0
  43. infrakit/scaffolder/generator.py +338 -0
  44. infrakit/scaffolder/pipeline.py +562 -0
  45. infrakit/scaffolder/registry.py +121 -0
  46. infrakit/time/__init__.py +60 -0
  47. infrakit/time/profiler.py +511 -0
  48. python_infrakit_dev-0.1.0.dist-info/METADATA +124 -0
  49. python_infrakit_dev-0.1.0.dist-info/RECORD +51 -0
  50. python_infrakit_dev-0.1.0.dist-info/WHEEL +4 -0
  51. 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)