4343import logging
4444import os
4545import re
46+ from collections .abc import Iterator , Mapping
4647from pathlib import Path
47- from typing import Any , Iterator , Optional
48+ from typing import Any
4849
4950# Matches {{any.dot.path}} — first segment is the section name (or a globals key),
5051# subsequent segments are nested keys within that section.
5960_GLOBALS_KEY = "_globals"
6061
6162
62- def _find_config (start : Path ) -> Optional [ Path ] :
63+ def _find_config (start : Path ) -> Path | None :
6364 """Walk from *start* upward until a ``config/<file>`` is found."""
6465 current = start .resolve ()
6566 while True :
@@ -107,7 +108,10 @@ def _load_file(path: Path) -> dict:
107108
108109
109110def _load_env (env_path : Path ) -> None :
110- """Parse a .env file and set variables into os.environ (skip if missing)."""
111+ """Parse a .env file and set variables into os.environ (skip if missing).
112+
113+ Supports ``export KEY=value`` shell syntax and skips blank lines / comments.
114+ """
111115 if not env_path .is_file ():
112116 return
113117 for line in env_path .read_text (encoding = "utf-8" ).splitlines ():
@@ -116,6 +120,8 @@ def _load_env(env_path: Path) -> None:
116120 continue
117121 key , _ , value = line .partition ("=" )
118122 key = key .strip ()
123+ if key .startswith ("export " ):
124+ key = key [len ("export " ):].strip ()
119125 value = value .strip ().strip ('"' ).strip ("'" )
120126 if key :
121127 os .environ .setdefault (key , value )
@@ -162,65 +168,95 @@ def replace(match: re.Match) -> str:
162168 return _ENV_PATTERN .sub (replace , value )
163169
164170
171+ def _lookup_ref (full_path : str , raw : dict , _context : str ) -> Any :
172+ """Look up *full_path* in *raw* and return the raw (unresolved) value.
173+
174+ Cross-section lookups use the globals-merged view of the target section so
175+ that ``{{B.key}}`` sees the same keys that ``ConfigManager(section="B")``
176+ would expose (globals injected as defaults, section values win).
177+ """
178+ parts = full_path .split ("." , 1 )
179+
180+ if len (parts ) == 1 :
181+ # Bare key → look in _globals
182+ key = parts [0 ]
183+ globals_data = raw .get (_GLOBALS_KEY , {})
184+ if key not in globals_data :
185+ raise KeyError (
186+ f"Reference {{{{{{ { full_path } }}}}}}: "
187+ f"no section prefix given and { key !r} not found in "
188+ f"'_globals'. Use {{{{section.{ key } }}}} or add it to "
189+ f"'_globals'."
190+ )
191+ return globals_data [key ]
192+
193+ section , remainder = parts
194+ if section not in raw :
195+ raise KeyError (
196+ f"Reference {{{{{{ { full_path } }}}}}}: "
197+ f"section { section !r} not found. "
198+ f"Available sections: { [k for k in raw if not k .startswith ('_' )]} "
199+ )
200+ # Merge globals into target section so cross-section refs are consistent
201+ # with what ConfigManager(section=section) would expose.
202+ merged_section = {** raw .get (_GLOBALS_KEY , {}), ** raw [section ]}
203+ try :
204+ return _deep_get (merged_section , remainder )
205+ except KeyError as exc :
206+ raise KeyError (
207+ f"Reference {{{{{{ { full_path } }}}}}}: { exc } "
208+ + (f" (in { _context } )" if _context else "" )
209+ ) from exc
210+
211+
165212def _resolve_refs (
166213 value : Any ,
167214 raw : dict ,
168215 * ,
169216 _context : str = "" ,
170217 _resolving : frozenset [str ] = frozenset (),
171218) -> Any :
172- """Recursively resolve ``{{path}}`` references in *value* .
219+ """Recursively resolve ``{{path}}`` references and ``${VAR}`` placeholders .
173220
174221 *raw* is the full, unresolved config dict (all sections).
175- ``{{path}}`` — first segment is the section name; subsequent segments are
176- nested keys. For single-segment paths (e.g. ``{{version}}``) the lookup
177- falls back to ``_globals`` if present.
178-
179- *_resolving* tracks the set of reference paths currently being resolved to
180- detect cycles and raise a clear error instead of hitting Python's recursion
181- limit.
222+ *_resolving* tracks the reference chain for cycle detection.
223+
224+ Type preservation
225+ -----------------
226+ When the **entire** string value is a single ``{{ref}}`` (e.g. ``"{{a.lr}}"``),
227+ the resolved value is returned with its original type (float, int, bool, …).
228+ When the reference is embedded inside a larger string (e.g. ``"{{root}}/data"``),
229+ string coercion applies as normal.
182230 """
183231 if isinstance (value , str ):
232+ # ── Fast path: sole reference → preserve resolved type ──────────────
233+ sole = _REF_PATTERN .fullmatch (value )
234+ if sole :
235+ full_path = sole .group (1 )
236+ if full_path in _resolving :
237+ cycle = " -> " .join (sorted (_resolving )) + f" -> { full_path } "
238+ raise KeyError (
239+ f"Circular reference detected: {{{{{ full_path } }}}} "
240+ f"is already being resolved. Cycle: { cycle } "
241+ )
242+ resolved = _lookup_ref (full_path , raw , _context )
243+ result = _resolve_refs (
244+ resolved , raw ,
245+ _context = full_path ,
246+ _resolving = _resolving | {full_path },
247+ )
248+ return _resolve_env (result ) if isinstance (result , str ) else result
249+
250+ # ── General path: ref embedded in string → str coercion ────────────
184251 def replace (match : re .Match ) -> str :
185252 full_path = match .group (1 )
186-
187253 if full_path in _resolving :
188254 cycle = " -> " .join (sorted (_resolving )) + f" -> { full_path } "
189255 raise KeyError (
190256 f"Circular reference detected: {{{{{ full_path } }}}} "
191257 f"is already being resolved. Cycle: { cycle } "
192258 )
193-
194- parts = full_path .split ("." , 1 )
195-
196- if len (parts ) == 1 :
197- # bare key → look in _globals first
198- key = parts [0 ]
199- globals_data = raw .get (_GLOBALS_KEY , {})
200- if key not in globals_data :
201- raise KeyError (
202- f"Reference {{{{{{ { full_path } }}}}}}: "
203- f"no section prefix given and { key !r} not found in "
204- f"'_globals'. Use {{{{section.{ key } }}}} or add it to "
205- f"'_globals'."
206- )
207- resolved = globals_data [key ]
208- else :
209- section , remainder = parts
210- if section not in raw :
211- raise KeyError (
212- f"Reference {{{{{{ { full_path } }}}}}}: "
213- f"section { section !r} not found. "
214- f"Available sections: { [k for k in raw if not k .startswith ('_' )]} "
215- )
216- try :
217- resolved = _deep_get (raw [section ], remainder )
218- except KeyError as exc :
219- raise KeyError (
220- f"Reference {{{{{{ { full_path } }}}}}}: { exc } "
221- + (f" (in { _context } )" if _context else "" )
222- ) from exc
223-
259+ resolved = _lookup_ref (full_path , raw , _context )
224260 return str (_resolve_refs (
225261 resolved , raw ,
226262 _context = full_path ,
@@ -240,7 +276,7 @@ def replace(match: re.Match) -> str:
240276 return value
241277
242278
243- def _caller_stem () -> Optional [ str ] :
279+ def _caller_stem () -> str | None :
244280 """Return the stem of the first non-library frame's filename."""
245281 this_file = Path (__file__ ).resolve ()
246282 for frame_info in inspect .stack ():
@@ -250,7 +286,7 @@ def _caller_stem() -> Optional[str]:
250286 if any (part in path .parts for part in ("pytest" , "_pytest" , "pluggy" )):
251287 continue
252288 stem = path .stem
253- if stem not in ("<string>" , "<stdin>" ):
289+ if stem not in ("<string>" , "<stdin>" , "__main__" ):
254290 return stem
255291 return None
256292
@@ -264,7 +300,7 @@ def _wrap(value: Any) -> Any:
264300 return value
265301
266302
267- def _attr_name (key : str ) -> Optional [ str ] :
303+ def _attr_name (key : str ) -> str | None :
268304 """Return the attribute name for *key*, or ``None`` if no mapping exists.
269305
270306 Keys that are already valid identifiers map to themselves.
@@ -292,14 +328,16 @@ def _unwrap(value: Any) -> Any:
292328 return value
293329
294330
295- class _Namespace :
331+ class _Namespace ( Mapping ) :
296332 """Read-only attribute-access wrapper for a plain dict.
297333
298- Returned by :class:`ConfigManager` when a value is itself a dict, enabling
299- ``cfg.paths.raw`` instead of ``cfg["paths"]["raw"]``.
334+ Implements :class:`collections.abc.Mapping` for full duck-typing
335+ compatibility. Returned by :class:`ConfigManager` when a value is itself
336+ a dict, enabling ``cfg.paths.raw`` instead of ``cfg["paths"]["raw"]``.
300337 """
301338
302339 __slots__ = ("_data" ,)
340+ __hash__ = None # type: ignore[assignment]
303341
304342 def __init__ (self , data : dict ) -> None :
305343 object .__setattr__ (self , "_data" , data )
@@ -343,18 +381,9 @@ def __iter__(self) -> Iterator[str]:
343381 def __len__ (self ) -> int :
344382 return len (self ._data )
345383
346- def get (self , key : str , default : Any = None ) -> Any :
384+ def get (self , key : str , default : Any = None ) -> Any : # type: ignore[override]
347385 return _wrap (self ._data [key ]) if key in self ._data else default
348386
349- def keys (self ):
350- return self ._data .keys ()
351-
352- def values (self ):
353- return (_wrap (v ) for v in self ._data .values ())
354-
355- def items (self ):
356- return ((k , _wrap (v )) for k , v in self ._data .items ())
357-
358387 def to_dict (self ) -> dict :
359388 """Return a plain dict, recursively converting any nested _Namespace objects."""
360389 return {k : _unwrap (v ) for k , v in self ._data .items ()}
@@ -370,21 +399,29 @@ def __repr__(self) -> str:
370399 return repr (self ._data )
371400
372401
373- class ConfigManager :
402+ class ConfigManager ( Mapping ) :
374403 """Hierarchical config loader with cross-section reference resolution.
375404
405+ Implements :class:`collections.abc.Mapping` for full duck-typing
406+ compatibility (``isinstance(cfg, Mapping)`` is ``True``).
407+
376408 On construction:
377409 1. Walks parent directories from the calling script's location
378410 (or *start_dir*) until ``config.json`` / ``config.yaml`` /
379411 ``config.toml`` is found.
380412 2. Loads ``.env`` from the same directory into ``os.environ``
381- (existing env vars are never overwritten).
413+ (existing env vars are never overwritten). Supports
414+ ``export KEY=value`` shell syntax.
382415 3. Merges the special ``_globals`` section (if present) into the
383416 active section — globals act as default values, section keys win.
384417 4. Determines the active *section* — defaults to the calling
385418 script's filename stem (e.g. ``script02`` for ``script02.py``).
386419 5. Resolves ``{{section.key.subkey}}`` cross-references in the
387- active section's values (arbitrary depth).
420+ active section's values (arbitrary depth, with cycle detection).
421+ Cross-section lookups see the target section's globals-merged view,
422+ consistent with ``ConfigManager(section=target)``.
423+ 6. When the **entire** value is a sole ``{{ref}}``, the resolved type
424+ (float, int, bool, …) is preserved — no silent str coercion.
388425
389426 Args:
390427 section: Config section to expose. Defaults to the calling
@@ -396,7 +433,9 @@ class ConfigManager:
396433 Raises:
397434 FileNotFoundError: If no config file is found in any parent dir.
398435 KeyError: If *section* is not present in the config file, or a
399- ``{{reference}}`` cannot be resolved.
436+ ``{{reference}}`` cannot be resolved (including circular refs).
437+ ValueError: If the section cannot be auto-detected (e.g. run via
438+ ``python -m``) and no *section* argument is provided.
400439
401440 Examples::
402441
@@ -418,18 +457,20 @@ class ConfigManager:
418457 cfg = ConfigManager(logger=log) # section="train" auto-detected
419458
420459 cfg["input"] # → "data/clean.csv"
421- cfg["lr"] # → 0.01
460+ cfg["lr"] # → 0.01 (float preserved, not "0.01")
422461 cfg.lr # → 0.01 (attribute-style shorthand)
423462 cfg["version"] # → "v2" (from _globals)
424463 cfg.get("missing", "default") # → "default"
425464 """
426465
466+ __hash__ = None # type: ignore[assignment]
467+
427468 def __init__ (
428469 self ,
429- section : Optional [ str ] = None ,
470+ section : str | None = None ,
430471 * ,
431- start_dir : Optional [ str | Path ] = None ,
432- logger : Optional [ logging .Logger ] = None ,
472+ start_dir : str | Path | None = None ,
473+ logger : logging .Logger | None = None ,
433474 ) -> None :
434475 self ._log = logger
435476
@@ -501,18 +542,9 @@ def __iter__(self) -> Iterator[str]:
501542 def __len__ (self ) -> int :
502543 return len (self ._data )
503544
504- def get (self , key : str , default : Any = None ) -> Any :
545+ def get (self , key : str , default : Any = None ) -> Any : # type: ignore[override]
505546 return _wrap (self ._data [key ]) if key in self ._data else default
506547
507- def keys (self ):
508- return self ._data .keys ()
509-
510- def values (self ):
511- return (_wrap (v ) for v in self ._data .values ())
512-
513- def items (self ):
514- return ((k , _wrap (v )) for k , v in self ._data .items ())
515-
516548 def to_dict (self ) -> dict :
517549 """Return a plain dict, recursively converting any nested _Namespace objects."""
518550 return {k : _unwrap (v ) for k , v in self ._data .items ()}
0 commit comments