Skip to content

Commit 454b25e

Browse files
fix: recognize .env filename, preserve null values and empty mappings
- cli.py: detect .env files by name (target_path.name == '.env') in addition to suffix, so the conventional dotfile without an extension is not rejected as unsupported format. - loader.py _flatten_nested: preserve None values (JSON/YAML null) instead of converting to empty string, so fix can write back null when the baseline specifies it. - loader.py _flatten_nested: preserve empty dict values ({}) so reconstruction does not silently drop unrelated empty mappings when other keys in the same file need fixing. - cli.py: add _json_null_handler for json.dumps to serialize preserved None values as JSON null. Addresses Codex review: cli.py:435 (P1), cli.py:349 (P1), cli.py:387 (P1)
1 parent 7e86bd6 commit 454b25e

2 files changed

Lines changed: 26 additions & 5 deletions

File tree

src/configdrift/cli.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,16 @@ def require_license(product: str) -> None: # type: ignore[misc]
2323

2424
from configdrift import __version__
2525
from configdrift._atomic import atomic_dump_toml, atomic_dump_yaml, atomic_write_text
26+
27+
def _json_null_handler(obj: Any) -> Any:
28+
"""JSON serializer for objects not serializable by default json code.
29+
30+
Handles None values that were preserved through the flatten cycle
31+
so they serialize to JSON null instead of raising TypeError.
32+
"""
33+
if obj is None:
34+
return None
35+
raise TypeError(f"Object of type {type(obj)} is not JSON serializable")
2636
from configdrift.diff import (
2737
Severity,
2838
diff_environments,
@@ -361,6 +371,8 @@ def fix(
361371
# dry-run mode so --dry-run accurately predicts whether the
362372
# real run would succeed.
363373
ext = target_path.suffix.lower()
374+
# .env files (literal name, no extension) need name-based detection
375+
is_dotenv = ext == ".env" or target_path.name == ".env"
364376
supported_exts = {".json", ".yaml", ".yml", ".toml", ".env"}
365377
if ext == ".toml":
366378
try:
@@ -369,13 +381,14 @@ def fix(
369381
console.print("[red]Error: tomli-w is required to write TOML files. Install with: pip install tomli-w[/red]")
370382
failed_targets.append(str(target_path))
371383
continue
372-
elif ext not in supported_exts:
384+
elif not is_dotenv and ext not in supported_exts:
373385
console.print(f"[red]Error: unsupported format '{ext}' for write-back of {target_path}.[/red]")
374386
failed_targets.append(str(target_path))
375387
continue
376388
console.print(f"[yellow]Dry run: {changes} key(s) would be updated in {target_path}[/yellow]")
377389
else:
378390
ext = target_path.suffix.lower()
391+
is_dotenv = ext == ".env" or target_path.name == ".env"
379392
if ext == ".json":
380393
import json as _json
381394

@@ -395,7 +408,7 @@ def fix(
395408
d[part] = {}
396409
d = d[part]
397410
d[parts[-1]] = v
398-
atomic_write_text(target_path, _json.dumps(nested_json, indent=2) + "\n")
411+
atomic_write_text(target_path, _json.dumps(nested_json, indent=2, default=_json_null_handler) + "\n")
399412
elif ext in (".yaml", ".yml"):
400413
# Reconstruct nested structure from flat keys for YAML output
401414
nested: dict[str, Any] = {}
@@ -432,7 +445,7 @@ def fix(
432445
console.print("[red]Error: tomli-w is required to write TOML files. Install with: pip install tomli-w[/red]")
433446
failed_targets.append(str(target_path))
434447
continue
435-
elif ext == ".env":
448+
elif is_dotenv:
436449
# Handle .env targets: write flat KEY=VALUE format
437450
lines = []
438451
for k, v in target_data.items():

src/configdrift/loader.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,9 +113,17 @@ def _flatten_nested(d: dict[str, Any], prefix: str = "") -> dict[str, Any]:
113113
for key, value in d.items():
114114
full_key = f"{prefix}.{key}" if prefix else key
115115
if isinstance(value, dict):
116-
result.update(_flatten_nested(value, full_key))
116+
if not value:
117+
# Preserve empty mappings so reconstruction doesn't
118+
# silently drop them when other keys need fixing.
119+
result[full_key] = {}
120+
else:
121+
result.update(_flatten_nested(value, full_key))
117122
elif value is None:
118-
result[full_key] = ""
123+
# Preserve null as None so the fix cycle can distinguish
124+
# "baseline is null" from "baseline is empty string".
125+
# Writers handle None appropriately per format.
126+
result[full_key] = None
119127
else:
120128
# Preserve lists, tuples, ints, floats, bools, and strings as-is
121129
result[full_key] = value

0 commit comments

Comments
 (0)