Skip to content

Commit 2883c70

Browse files
2 parents 31278d6 + 16452a9 commit 2883c70

7 files changed

Lines changed: 115 additions & 19 deletions

File tree

README.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ Keep configurations consistent across all environments, automatically. ConfigDri
1010
![Python](https://img.shields.io/badge/python-3.10%2B-blue)
1111
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/Coding-Dev-Tools/configdrift/blob/main/LICENSE)
1212
[![Open Source Alternative](https://img.shields.io/badge/Open_Source_Alternative-%E2%87%92-blue?logo=opensourceinitiative)](https://www.opensourcealternative.to/project/configdrift)
13-
[![CI](https://github.com/Coding-Dev-Tools/configdrift/actions/workflows/ci.yml/badge.svg)](https://github.com/Coding-Dev-Tools/configdrift/actions/workflows/ci.yml)
14-
[![GitHub last commit](https://img.shields.io/github/last-commit/Coding-Dev-Tools/configdrift)](https://github.com/Coding-Dev-Tools/configdrift/commits)
13+
|[![LibHunt](https://img.shields.io/badge/LibHunt-%E2%87%92-blue?logo=codeigniter)](https://www.libhunt.com/r/Coding-Dev-Tools/configdrift)
14+
|[![PyPI](https://img.shields.io/pypi/v/configdrift)](https://pypi.org/project/configdrift/)
1515

1616

1717

@@ -149,6 +149,12 @@ ConfigDrift is one of 11 tools in the Revenue Holdings suite. One license covers
149149
| SSO / SAML / OIDC ||||||
150150
| Priority support | Community | 24h | 24h | 8h | Dedicated |
151151

152+
---
153+
154+
<p align="center">
155+
<sub>Part of <a href="https://coding-dev-tools.github.io/devforge/">Revenue Holdings</a> — CLI tools built by autonomous AI.</sub>
156+
</p>
157+
152158
## License
153159

154160
MIT

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ configdrift = "configdrift.cli:app"
5252
[tool.setuptools.packages.find]
5353
where = ["src"]
5454

55+
56+
[tool.setuptools.package-data]
57+
"*" = ["py.typed"]
5558
[tool.pytest.ini_options]
5659
testpaths = ["tests"]
5760
addopts = "-v --tb=short"

src/configdrift/cli.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -63,11 +63,11 @@ def check(
6363
"dev", "--baseline", "-b",
6464
help="Baseline environment label (default: 'dev').",
6565
), # noqa: B008
66-
target: str = typer.Option("target", "--target", "-t", help="Target env label (default: 'target')."), # noqa: B008
67-
output: OutputFormat = typer.Option( # noqa: B008
68-
OutputFormat.TABLE, "--output", "-o",
69-
help="Format: table, json, silent (exit code only).",
70-
),
66+
target: str = typer.Option("target", "--target", "-t", help="Target environment label (default: 'target')."), # noqa: B008
67+
output: OutputFormat = typer.Option(
68+
OutputFormat.TABLE, "--output", "-o",
69+
help="Output format: table, json, or silent (exit code only).",
70+
), # noqa: B008
7171
strict: bool = typer.Option(False, "--strict", help="Exit 1 on ANY drift, not just breaking changes."), # noqa: B008
7272
):
7373
"""Compare 2+ config files and report drift. Exits 1 if breaking drift found (useful for CI gating)."""
@@ -105,7 +105,7 @@ def check(
105105
raise typer.Exit(code=1)
106106

107107

108-
def _output_table(results: dict[str, Any], baseline_env: str):
108+
def _output_table(results: dict[str, Any], baseline_env: str) -> None:
109109
for env_name, diff_result in results.items():
110110
if not diff_result.changes:
111111
continue
@@ -141,7 +141,7 @@ def _output_table(results: dict[str, Any], baseline_env: str):
141141
console.print()
142142

143143

144-
def _output_json(results: dict[str, Any]):
144+
def _output_json(results: dict[str, Any]) -> None:
145145
import json
146146
output = {}
147147
for env_name, diff_result in results.items():

src/configdrift/loader.py

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
"""Configuration loaders for YAML, JSON, TOML, and .env formats."""
22

3+
import importlib
34
import json
45
import re
56
from pathlib import Path
67
from typing import Any
78

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

912
def load_file(path: str) -> dict[str, Any]:
1013
"""Load a config file based on its extension."""
@@ -50,13 +53,9 @@ def _load_json(path: Path) -> dict[str, Any]:
5053

5154

5255
def _load_toml(path: Path) -> dict[str, Any]:
53-
try:
54-
import tomllib # Python 3.11+
55-
except ImportError:
56-
import tomli as tomllib # Python 3.10
5756
with open(path, "rb") as f:
58-
data = tomllib.load(f)
59-
return _flatten_nested(data)
57+
data = _toml.load(f)
58+
return _flatten_nested(data)
6059

6160

6261
def _strip_inline_comment(value: str) -> str:
@@ -109,8 +108,5 @@ def _flatten_nested(d: dict[str, Any], prefix: str = "") -> dict[str, Any]:
109108
if isinstance(value, dict):
110109
result.update(_flatten_nested(value, full_key))
111110
else:
112-
if value is None:
113-
result[full_key] = ""
114-
else:
115-
result[full_key] = str(value) if not isinstance(value, (str, int, float, bool)) else value
111+
result[full_key] = str(value) if not isinstance(value, str | int | float | bool) else value
116112
return result

src/configdrift/py.typed

Whitespace-only changes.

tests/conftest.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
"""Mock revenueholdings_license for tests so CLI commands don't hit the paywall."""
2+
import sys
3+
from unittest.mock import MagicMock
4+
5+
# Replace the module before any import resolves it
6+
_mock = MagicMock()
7+
_mock.require_license = MagicMock(return_value=None)
8+
sys.modules.setdefault("revenueholdings_license", _mock)
9+
10+
# Also mock submodules
11+
sys.modules.setdefault("revenueholdings_license.gate", MagicMock())
12+
sys.modules.setdefault("revenueholdings_license.rate_limiter", MagicMock())
13+
sys.modules.setdefault("revenueholdings_license.license", MagicMock())
14+
sys.modules.setdefault("revenueholdings_license.integration", MagicMock())

tests/test_coverage_gaps.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
"""Targeted coverage tests for uncovered lines in loader.py and cli.py."""
2+
import pytest
3+
import tempfile
4+
import yaml
5+
from configdrift.cli import app
6+
from configdrift.loader import _strip_inline_comment, load_file
7+
from pathlib import Path
8+
from typer.testing import CliRunner
9+
10+
runner = CliRunner()
11+
12+
13+
class TestStripInlineCommentToggles:
14+
"""Cover loader.py:72, 74 — quote toggling in _strip_inline_comment."""
15+
16+
def test_double_quote_toggle_with_hash(self):
17+
"""Line 72: in_double should toggle when encountering " outside single quotes."""
18+
# A value with a " inside it, followed by # outside quotes
19+
# The " should be detected as the start/end of double-quoting
20+
result = _strip_inline_comment('prefix "hello" # comment')
21+
assert result == 'prefix "hello"', f"Expected comment stripped after quoted section, got: {result!r}"
22+
23+
def test_single_quote_toggle_with_hash(self):
24+
"""Line 74: in_single should toggle when encountering ' outside double quotes."""
25+
result = _strip_inline_comment("prefix 'hello' # comment")
26+
assert result == "prefix 'hello'", f"Expected comment stripped after quoted section, got: {result!r}"
27+
28+
29+
class TestLoadDotenvQuoteStrip:
30+
"""Cover loader.py:99 — quote stripping in _load_dotenv."""
31+
32+
def test_quote_stripping_double(self):
33+
"""Line 99: surrounding double quotes via _load_dotenv direct."""
34+
with tempfile.TemporaryDirectory() as tmpdir:
35+
p = Path(tmpdir) / "test.env"
36+
p.write_text('DB_HOST="localhost"\n')
37+
result = load_file(str(p))
38+
assert result == {"DB_HOST": "localhost"}
39+
40+
def test_quote_stripping_single(self):
41+
"""Line 99: surrounding single quotes via _load_dotenv direct."""
42+
with tempfile.TemporaryDirectory() as tmpdir:
43+
p = Path(tmpdir) / "test.env"
44+
p.write_text("DB_HOST='localhost'\n")
45+
result = load_file(str(p))
46+
assert result == {"DB_HOST": "localhost"}
47+
48+
49+
class TestLoaderLine3132:
50+
"""Cover loader.py:31-32 — ValueError fallback when _load_dotenv fails."""
51+
52+
def test_unknown_ext_invalid_utf8_raises_valueerror(self):
53+
"""Line 31-32: unknown extension with non-UTF-8 content triggers ValueError."""
54+
with tempfile.TemporaryDirectory() as tmpdir:
55+
p = Path(tmpdir) / "config.bin"
56+
# Binary bytes that can't be decoded as UTF-8
57+
p.write_bytes(b'\xff\xfe\x00\x00hello')
58+
with pytest.raises(ValueError, match="Unsupported file format: .bin"):
59+
load_file(str(p))
60+
61+
62+
class TestCliLine206:
63+
"""Cover cli.py:206 — has_drift check after JSON output with breaking drift."""
64+
65+
def test_scan_silent_strict_any_drift(self):
66+
"""Line 206: scan --strict --output silent with non-breaking drift exits 1."""
67+
with tempfile.TemporaryDirectory() as tmpdir:
68+
dev_dir = Path(tmpdir) / "dev"
69+
prod_dir = Path(tmpdir) / "prod"
70+
dev_dir.mkdir()
71+
prod_dir.mkdir()
72+
# Non-breaking drift (info-level): host change
73+
(dev_dir / "c.yaml").write_text(yaml.dump({"host": "localhost"}))
74+
(prod_dir / "c.yaml").write_text(yaml.dump({"host": "prod.example.com"}))
75+
76+
result = runner.invoke(app, ["scan", str(dev_dir), str(prod_dir), "--output", "silent", "--strict"])
77+
assert result.exit_code == 1

0 commit comments

Comments
 (0)