Skip to content

Commit a1127c3

Browse files
committed
Fix type coercion, cross-section globals, .env export, Mapping ABC, CI improvements
Bug fixes: - Preserve native type (float, int, bool, list, dict) when an entire config value is a sole {{ref}} — previously always coerced to str via str() - Cross-section {{B.key}} lookups now use the globals-merged view of B, consistent with ConfigManager(section='B'); previously used raw B data which caused spurious KeyError for globals-only keys - _load_env: handle 'export KEY=value' shell syntax (strip the prefix) - _caller_stem: skip '__main__' stem so python -m runs fall through to the ValueError with a clear 'pass section= explicitly' message API improvements: - _Namespace and ConfigManager now inherit from collections.abc.Mapping, so isinstance(cfg, Mapping) is True and values()/items() return proper view objects that support len() instead of bare generators CI / packaging: - Add Python 3.13 to test matrix and classifiers - Remove redundant 'pytest' from pip install line (already in [dev] extras) - Add ruff lint, mypy type-check, and coverage steps to CI - Add [tool.ruff], [tool.mypy] config sections to pyproject.toml - Bump Development Status classifier to 5 - Production/Stable Tests: - Fix dead assignment 'handler = logging.handlers_list = []' in TestLogger - Add TestTypePreservation: float/int/bool/list/dict type preserved via sole ref - Add TestCrossSectionGlobals: cross-section refs see globals-merged target - Add TestEnvExportSyntax: export KEY=value .env syntax - Add TestMappingABC: isinstance checks, len(values()), len(items()), unhashability of ConfigManager and _Namespace
1 parent 4f10b04 commit a1127c3

4 files changed

Lines changed: 268 additions & 86 deletions

File tree

.github/workflows/ci.yml

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ jobs:
1111
runs-on: ubuntu-latest
1212
strategy:
1313
matrix:
14-
python-version: ["3.10", "3.11", "3.12"]
14+
python-version: ["3.10", "3.11", "3.12", "3.13"]
1515

1616
steps:
1717
- uses: actions/checkout@v6
@@ -23,8 +23,14 @@ jobs:
2323
with:
2424
python-version: ${{ matrix.python-version }}
2525

26-
- name: Install package and test dependencies
27-
run: pip install -e ".[dev]" pytest
26+
- name: Install package and dev dependencies
27+
run: pip install -e ".[dev]"
2828

29-
- name: Run tests
30-
run: pytest --tb=short
29+
- name: Lint (ruff)
30+
run: ruff check src tests
31+
32+
- name: Type-check (mypy)
33+
run: mypy src --ignore-missing-imports
34+
35+
- name: Run tests with coverage
36+
run: pytest --tb=short --cov=refconf_manager --cov-report=term-missing

pyproject.toml

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,20 @@ requires-python = ">=3.10"
1212
dependencies = []
1313
keywords = ["config", "pipeline", "dotenv", "data-processing"]
1414
classifiers = [
15-
"Development Status :: 3 - Alpha",
15+
"Development Status :: 5 - Production/Stable",
1616
"Intended Audience :: Science/Research",
1717
"License :: OSI Approved :: MIT License",
1818
"Programming Language :: Python :: 3",
1919
"Programming Language :: Python :: 3.10",
2020
"Programming Language :: Python :: 3.11",
2121
"Programming Language :: Python :: 3.12",
22+
"Programming Language :: Python :: 3.13",
2223
]
2324

2425
[project.optional-dependencies]
2526
yaml = ["pyyaml>=6"]
2627
toml = ["tomli>=2; python_version<'3.11'"]
27-
dev = ["pytest>=7", "pyyaml>=6"]
28+
dev = ["pytest>=7", "pytest-cov>=4", "pyyaml>=6", "ruff>=0.4", "mypy>=1.10"]
2829

2930
[project.urls]
3031
Homepage = "https://github.com/lukaszplk/config-manager"
@@ -38,3 +39,14 @@ packages = ["src/refconf_manager"]
3839

3940
[tool.pytest.ini_options]
4041
testpaths = ["tests"]
42+
43+
[tool.ruff]
44+
line-length = 100
45+
46+
[tool.ruff.lint]
47+
select = ["E", "F", "W", "I", "UP"]
48+
49+
[tool.mypy]
50+
python_version = "3.10"
51+
warn_return_any = true
52+
warn_unused_configs = true

src/refconf_manager/manager.py

Lines changed: 109 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,9 @@
4343
import logging
4444
import os
4545
import re
46+
from collections.abc import Iterator, Mapping
4647
from 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.
@@ -59,7 +60,7 @@
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

109110
def _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+
165212
def _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

Comments
 (0)