Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 11 additions & 36 deletions src/configdrift/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,7 @@
except ImportError:
import warnings

warnings.warn(
"revenueholdings-license not installed; license checks skipped", stacklevel=2
)
warnings.warn("revenueholdings-license not installed; license checks skipped", stacklevel=2)

def require_license(product: str) -> None: # type: ignore[misc]
pass
Expand Down Expand Up @@ -68,12 +66,11 @@ def main(
) -> None:
"""ConfigDrift CLI — detect and fix configuration drift."""
global _require_license_strict
_require_license_strict = require_license_flag or bool(
os.environ.get("REVENUEHOLDINGS_REQUIRE_LICENSE")
)
_require_license_strict = require_license_flag or bool(os.environ.get("REVENUEHOLDINGS_REQUIRE_LICENSE"))
if _require_license_strict:
try:
from revenueholdings_license import require_license as _rl

_rl("configdrift")
except ImportError:
console.print(
Expand All @@ -97,9 +94,7 @@ class OutputFormat(str, Enum):
_DEFAULT_TARGET = "target"
_DEFAULT_OUTPUT: OutputFormat = OutputFormat.TABLE
_DEFAULT_STRICT = False
_FILES_ARG = typer.Argument(
..., help="Config files to compare (2+ files; first file is baseline)."
)
_FILES_ARG = typer.Argument(..., help="Config files to compare (2+ files; first file is baseline).")
_BASELINE_OPT = typer.Option(
_DEFAULT_BASELINE,
"--baseline",
Expand All @@ -118,9 +113,7 @@ class OutputFormat(str, Enum):
"-o",
help="Output format: table, json, or silent (exit code only).",
)
_STRICT_OPT = typer.Option(
_DEFAULT_STRICT, "--strict", help="Exit 1 on ANY drift, not just breaking changes."
)
_STRICT_OPT = typer.Option(_DEFAULT_STRICT, "--strict", help="Exit 1 on ANY drift, not just breaking changes.")


@app.command()
Expand All @@ -137,11 +130,7 @@ def check(
raise typer.Exit(code=1)

env_configs: dict[str, dict[str, Any]] = {}
env_labels = (
[baseline, target]
if len(files) == 2
else [f"file_{i + 1}" for i in range(len(files))]
)
env_labels = [baseline, target] if len(files) == 2 else [f"file_{i + 1}" for i in range(len(files))]

for label, filepath in zip(env_labels, files, strict=False):
try:
Expand All @@ -165,11 +154,7 @@ def check(
_output_table(results, baseline_env)

# Exit codes for CI gating
has_drift = (
any(r.count > 0 for r in results.values())
if strict
else any(r.has_breaking for r in results.values())
)
has_drift = any(r.count > 0 for r in results.values()) if strict else any(r.has_breaking for r in results.values())
if has_drift:
raise typer.Exit(code=1)

Expand All @@ -187,9 +172,7 @@ def _output_table(results: dict[str, Any], baseline_env: str) -> None:
table.add_column("Severity", style="magenta")

for change in diff_result.changes:
symbol = {"added": "+", "removed": "-", "changed": "~"}[
change.change_type.value
]
symbol = {"added": "+", "removed": "-", "changed": "~"}[change.change_type.value]
old_str = str(change.old_value) if change.old_value is not None else ""
new_str = str(change.new_value) if change.new_value is not None else ""
sev_style = (
Expand Down Expand Up @@ -270,9 +253,7 @@ def scan(
env_name = Path(d).name
dir_mapping[env_name] = d
else:
console.print(
"[red]ERROR: Provide either --config or directories as arguments.[/red]"
)
console.print("[red]ERROR: Provide either --config or directories as arguments.[/red]")
raise typer.Exit(code=1)

if baseline not in dir_mapping:
Expand All @@ -284,9 +265,7 @@ def scan(
env_configs[env_name] = {}
p = Path(dir_path)
if not p.is_dir():
console.print(
f"[yellow]Warning: '{dir_path}' is not a directory, skipping.[/yellow]"
)
console.print(f"[yellow]Warning: '{dir_path}' is not a directory, skipping.[/yellow]")
continue
# Load all supported config files in the directory and merge
for ext in ("*.yaml", "*.yml", "*.json", "*.toml", "*.env"):
Expand All @@ -310,11 +289,7 @@ def scan(
else:
_output_table(results, baseline)

has_drift = (
any(r.count > 0 for r in results.values())
if strict
else any(r.has_breaking for r in results.values())
)
has_drift = any(r.count > 0 for r in results.values()) if strict else any(r.has_breaking for r in results.values())
if has_drift:
raise typer.Exit(code=1)

Expand Down
6 changes: 2 additions & 4 deletions src/configdrift/diff.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ def _key_contains_critical_term(key: str, critical_terms: tuple[str, ...]) -> bo

# Check for contiguous subsequence match (word boundary)
for i in range(len(key_words) - term_len + 1):
if key_words[i:i + term_len] == term_words:
if key_words[i : i + term_len] == term_words:
return True

# Also check concatenated form for MULTI-WORD terms only.
Expand Down Expand Up @@ -177,9 +177,7 @@ def diff_configs(
return result


def diff_environments(
env_configs: dict[str, dict[str, Any]], baseline_env: str = "dev"
) -> dict[str, DiffResult]:
def diff_environments(env_configs: dict[str, dict[str, Any]], baseline_env: str = "dev") -> dict[str, DiffResult]:
"""Compare multiple environments against a baseline."""
if baseline_env not in env_configs:
raise ValueError(f"Baseline environment '{baseline_env}' not found in configs")
Expand Down
16 changes: 4 additions & 12 deletions src/configdrift/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,7 @@
from pathlib import Path
from typing import Any

_toml = importlib.import_module(
"tomllib" if __import__("sys").version_info >= (3, 11) else "tomli"
)
_toml = importlib.import_module("tomllib" if __import__("sys").version_info >= (3, 11) else "tomli")


def load_file(path: str) -> dict[str, Any]:
Expand Down Expand Up @@ -43,19 +41,15 @@ def _load_yaml(path: Path) -> dict[str, Any]:
with open(path, encoding="utf-8") as f:
data = yaml.safe_load(f)
if not isinstance(data, dict):
raise ValueError(
f"YAML file must contain a mapping (dict), got {type(data).__name__}"
)
raise ValueError(f"YAML file must contain a mapping (dict), got {type(data).__name__}")
return _flatten_nested(data)


def _load_json(path: Path) -> dict[str, Any]:
with open(path, encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict):
raise ValueError(
f"JSON file must contain a mapping (dict), got {type(data).__name__}"
)
raise ValueError(f"JSON file must contain a mapping (dict), got {type(data).__name__}")
return _flatten_nested(data)


Expand Down Expand Up @@ -117,7 +111,5 @@ def _flatten_nested(d: dict[str, Any], prefix: str = "") -> dict[str, Any]:
elif value is None:
result[full_key] = ""
else:
result[full_key] = (
str(value) if not isinstance(value, str | int | float | bool) else value
)
result[full_key] = str(value) if not isinstance(value, str | int | float | bool) else value
return result
4 changes: 1 addition & 3 deletions tests/test_ci_workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,7 @@


def test_ci_test_step_executes_full_suite():
workflow = yaml.safe_load(
(ROOT / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8")
)
workflow = yaml.safe_load((ROOT / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8"))
test_steps = workflow["jobs"]["test"]["steps"]
run_tests = next(
(step for step in test_steps if step.get("name") == "Run tests"),
Expand Down
68 changes: 17 additions & 51 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,7 @@ def test_check_json_output(self):
dev.write_text(json.dumps({"host": "localhost"}))
prod.write_text(json.dumps({"host": "prod.example.com", "port": 443}))

result = runner.invoke(
app, ["check", str(dev), str(prod), "--output", "json"]
)
result = runner.invoke(app, ["check", str(dev), str(prod), "--output", "json"])
assert result.exit_code == 0
data = json.loads(result.stdout)
assert "target" in data
Expand Down Expand Up @@ -126,9 +124,7 @@ def test_scan_two_dirs(self):
dev_dir.mkdir()
prod_dir.mkdir()
(dev_dir / "config.yaml").write_text(yaml.dump({"host": "localhost"}))
(prod_dir / "config.yaml").write_text(
yaml.dump({"host": "prod.example.com"})
)
(prod_dir / "config.yaml").write_text(yaml.dump({"host": "prod.example.com"}))

result = runner.invoke(app, ["scan", str(dev_dir), str(prod_dir)])
assert result.exit_code == 0
Expand Down Expand Up @@ -166,9 +162,7 @@ def test_scan_baseline_not_found(self):
dev_dir = Path(tmpdir) / "dev"
dev_dir.mkdir()
(dev_dir / "c.yaml").write_text(yaml.dump({"k": "v"}))
result = runner.invoke(
app, ["scan", str(dev_dir), "--baseline", "nonexistent"]
)
result = runner.invoke(app, ["scan", str(dev_dir), "--baseline", "nonexistent"])
assert result.exit_code == 1
assert "not found" in result.stdout

Expand All @@ -182,9 +176,7 @@ def test_scan_json_output(self):
(dev_dir / "c.yaml").write_text(yaml.dump({"host": "localhost"}))
(prod_dir / "c.yaml").write_text(yaml.dump({"host": "prod.example.com"}))

result = runner.invoke(
app, ["scan", str(dev_dir), str(prod_dir), "--output", "json"]
)
result = runner.invoke(app, ["scan", str(dev_dir), str(prod_dir), "--output", "json"])
assert result.exit_code == 0
data = json.loads(result.stdout)
assert "prod" in data
Expand Down Expand Up @@ -223,12 +215,8 @@ def test_scan_breaking_drift_exit_code(self):
prod_dir = Path(tmpdir) / "prod"
dev_dir.mkdir()
prod_dir.mkdir()
(dev_dir / "c.yaml").write_text(
yaml.dump({"database_url": "postgres://dev"})
)
(prod_dir / "c.yaml").write_text(
yaml.dump({"database_url": "postgres://prod"})
)
(dev_dir / "c.yaml").write_text(yaml.dump({"database_url": "postgres://dev"}))
(prod_dir / "c.yaml").write_text(yaml.dump({"database_url": "postgres://prod"}))

result = runner.invoke(app, ["scan", str(dev_dir), str(prod_dir)])
assert result.exit_code == 1
Expand All @@ -255,9 +243,7 @@ def test_scan_strict_exits_on_any_drift(self):
assert result.exit_code == 0

# With --strict, any drift exits 1
result = runner.invoke(
app, ["scan", str(dev_dir), str(prod_dir), "--strict"]
)
result = runner.invoke(app, ["scan", str(dev_dir), str(prod_dir), "--strict"])
assert result.exit_code == 1

def test_scan_strict_no_drift_exits_zero(self):
Expand All @@ -270,9 +256,7 @@ def test_scan_strict_no_drift_exits_zero(self):
(dev_dir / "c.yaml").write_text(yaml.dump({"host": "localhost"}))
(prod_dir / "c.yaml").write_text(yaml.dump({"host": "localhost"}))

result = runner.invoke(
app, ["scan", str(dev_dir), str(prod_dir), "--strict"]
)
result = runner.invoke(app, ["scan", str(dev_dir), str(prod_dir), "--strict"])
assert result.exit_code == 0

def test_scan_silent_breaking_drift(self):
Expand All @@ -282,16 +266,10 @@ def test_scan_silent_breaking_drift(self):
prod_dir = Path(tmpdir) / "prod"
dev_dir.mkdir()
prod_dir.mkdir()
(dev_dir / "c.yaml").write_text(
yaml.dump({"database_url": "postgres://dev"})
)
(prod_dir / "c.yaml").write_text(
yaml.dump({"database_url": "postgres://prod"})
)
(dev_dir / "c.yaml").write_text(yaml.dump({"database_url": "postgres://dev"}))
(prod_dir / "c.yaml").write_text(yaml.dump({"database_url": "postgres://prod"}))

result = runner.invoke(
app, ["scan", str(dev_dir), str(prod_dir), "--output", "silent"]
)
result = runner.invoke(app, ["scan", str(dev_dir), str(prod_dir), "--output", "silent"])
assert result.exit_code == 1


Expand Down Expand Up @@ -360,9 +338,7 @@ def test_check_strict_silent_exits_on_any_drift(self):
a.write_text(yaml.dump({"host": "localhost"}))
b.write_text(yaml.dump({"host": "staging.example.com"}))

result = runner.invoke(
app, ["check", str(a), str(b), "--output", "silent", "--strict"]
)
result = runner.invoke(app, ["check", str(a), str(b), "--output", "silent", "--strict"])
assert result.exit_code == 1

def test_check_strict_no_drift_exits_zero(self):
Expand Down Expand Up @@ -398,9 +374,7 @@ def test_scan_env_and_toml_dirs(self):
(dev_dir / "app.env").write_text("HOST=localhost\n")
(prod_dir / "app.env").write_text("HOST=prod.example.com\n")

result = runner.invoke(
app, ["scan", str(dev_dir), str(prod_dir), "--output", "json"]
)
result = runner.invoke(app, ["scan", str(dev_dir), str(prod_dir), "--output", "json"])
assert result.exit_code == 0
data = json.loads(result.stdout)
assert "prod" in data
Expand Down Expand Up @@ -447,20 +421,12 @@ def test_scan_no_changes_env_skipped_in_table(self):
prod_dir = Path(tmpdir) / "prod"
for d in [dev_dir, staging_dir, prod_dir]:
d.mkdir()
(dev_dir / "c.yaml").write_text(
yaml.dump({"host": "localhost", "port": 8080})
)
(dev_dir / "c.yaml").write_text(yaml.dump({"host": "localhost", "port": 8080}))
# staging is identical to dev — no changes
(staging_dir / "c.yaml").write_text(
yaml.dump({"host": "localhost", "port": 8080})
)
(prod_dir / "c.yaml").write_text(
yaml.dump({"host": "prod.example.com", "port": 8080})
)
(staging_dir / "c.yaml").write_text(yaml.dump({"host": "localhost", "port": 8080}))
(prod_dir / "c.yaml").write_text(yaml.dump({"host": "prod.example.com", "port": 8080}))

result = runner.invoke(
app, ["scan", str(dev_dir), str(staging_dir), str(prod_dir)]
)
result = runner.invoke(app, ["scan", str(dev_dir), str(staging_dir), str(prod_dir)])
assert result.exit_code == 0
# Should show prod drift but skip staging (no changes)
assert "prod" in result.stdout
Expand Down
8 changes: 2 additions & 6 deletions tests/test_coverage_gaps.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,12 @@ def test_double_quote_toggle_with_hash(self):
# A value with a " inside it, followed by # outside quotes
# The " should be detected as the start/end of double-quoting
result = _strip_inline_comment('prefix "hello" # comment')
assert result == 'prefix "hello"', (
f"Expected comment stripped after quoted section, got: {result!r}"
)
assert result == 'prefix "hello"', f"Expected comment stripped after quoted section, got: {result!r}"

def test_single_quote_toggle_with_hash(self):
"""Line 74: in_single should toggle when encountering ' outside double quotes."""
result = _strip_inline_comment("prefix 'hello' # comment")
assert result == "prefix 'hello'", (
f"Expected comment stripped after quoted section, got: {result!r}"
)
assert result == "prefix 'hello'", f"Expected comment stripped after quoted section, got: {result!r}"


class TestLoadDotenvQuoteStrip:
Expand Down
12 changes: 3 additions & 9 deletions tests/test_diff.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,7 @@ def test_change_str_added(self):
assert "443" in str(c)

def test_change_str_removed(self):
c = Change(
key="host", change_type=ChangeType.REMOVED, old_value="localhost", env="dev"
)
c = Change(key="host", change_type=ChangeType.REMOVED, old_value="localhost", env="dev")
assert "[-]" in str(c)
assert "host" in str(c)

Expand Down Expand Up @@ -76,13 +74,9 @@ def test_by_type(self):
def test_by_severity(self):
r = DiffResult(
changes=[
Change(
key="a", change_type=ChangeType.CHANGED, severity=Severity.BREAKING
),
Change(key="a", change_type=ChangeType.CHANGED, severity=Severity.BREAKING),
Change(key="b", change_type=ChangeType.CHANGED, severity=Severity.INFO),
Change(
key="c", change_type=ChangeType.ADDED, severity=Severity.WARNING
),
Change(key="c", change_type=ChangeType.ADDED, severity=Severity.WARNING),
]
)
breaking = r.by_severity(Severity.BREAKING)
Expand Down
4 changes: 1 addition & 3 deletions tests/test_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,9 +178,7 @@ def test_dotenv_export_prefix(self):
"""Lines with 'export ' prefix should be parsed correctly."""
with tempfile.TemporaryDirectory() as tmpdir:
p = Path(tmpdir) / ".env"
p.write_text(
"export DATABASE_URL=postgres://localhost\nexport API_KEY=secret123\n"
)
p.write_text("export DATABASE_URL=postgres://localhost\nexport API_KEY=secret123\n")
result = load_file(str(p))
assert result["DATABASE_URL"] == "postgres://localhost"
assert result["API_KEY"] == "secret123"
Expand Down
Loading