Skip to content
Merged
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
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ jobs:
--cov-report=term-missing \
--cov-report=xml:results/coverage.xml

- name: Lint
if: matrix.python-version == '3.13'
run: ruff check .

- name: Verify CLI entry point
run: |
configdiff --version
Expand Down
2 changes: 1 addition & 1 deletion configdiff/cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import configdiff
from configdiff.diff_engine import compare
from configdiff.output import get_formatter
from configdiff.parsers import ParserRegistry # noqa: F401 (triggers auto-registration)
from configdiff.parsers import ParserRegistry
from configdiff.utils.format_detection import detect_format
from configdiff.utils.logging import setup_logging

Expand Down
2 changes: 1 addition & 1 deletion configdiff/diff_engine/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@
from configdiff.diff_engine.engine import compare
from configdiff.diff_engine.models import ChangeType, DiffEntry, DiffResult

__all__ = ["compare", "ChangeType", "DiffEntry", "DiffResult"]
__all__ = ["ChangeType", "DiffEntry", "DiffResult", "compare"]
18 changes: 12 additions & 6 deletions configdiff/diff_engine/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,18 @@
logger = logging.getLogger(__name__)


def _sort_key(item: Any) -> Any:
"""Produce a sortable key for heterogeneous lists."""
try:
return (0, str(item))
except Exception:
return (1, id(item))
def _sort_key(item: Any) -> str:
"""Return a deterministic key that ignores mapping and nested-list order."""
if isinstance(item, dict):
normalized = tuple(
(repr(key), _sort_key(value))
for key, value in sorted(item.items(), key=lambda pair: repr(pair[0]))
)
elif isinstance(item, list):
normalized = tuple(sorted(map(_sort_key, item)))
else:
normalized = (type(item).__qualname__, repr(item))
return repr(normalized)


def _deep_diff(
Expand Down
2 changes: 1 addition & 1 deletion configdiff/output/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ def get_formatter(name: str) -> BaseFormatter:

__all__ = [
"BaseFormatter",
"TextFormatter",
"JsonFormatter",
"TextFormatter",
"YamlFormatter",
"get_formatter",
]
6 changes: 3 additions & 3 deletions configdiff/parsers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@
ParserRegistry.register(IniParser())

__all__ = [
"JsonParser",
"YamlParser",
"TomlParser",
"IniParser",
"JsonParser",
"ParserRegistry",
"TomlParser",
"YamlParser",
]
4 changes: 2 additions & 2 deletions configdiff/parsers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ class BaseParser(abc.ABC):
"""Base class that every config format parser must implement.

Subclasses declare ``format_name`` (e.g. ``"json"``) and
``extensions`` (e.g. ``[".json"]``), then implement :meth:`parse`.
``extensions`` (e.g. ``(".json",)``), then implement :meth:`parse`.
"""

format_name: str
extensions: list[str]
extensions: tuple[str, ...]

@abc.abstractmethod
def parse(self, path: Path) -> dict[str, Any]:
Expand Down
2 changes: 1 addition & 1 deletion configdiff/parsers/ini_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

class IniParser(BaseParser):
format_name = "ini"
extensions = [".ini", ".cfg", ".conf"]
extensions = (".ini", ".cfg", ".conf")

def parse(self, path: Path) -> dict[str, Any]:
try:
Expand Down
2 changes: 1 addition & 1 deletion configdiff/parsers/json_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

class JsonParser(BaseParser):
format_name = "json"
extensions = [".json"]
extensions = (".json",)

def parse(self, path: Path) -> dict[str, Any]:
try:
Expand Down
4 changes: 2 additions & 2 deletions configdiff/parsers/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from __future__ import annotations

import logging
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, ClassVar

if TYPE_CHECKING:
from configdiff.parsers.base import BaseParser
Expand All @@ -19,7 +19,7 @@ class ParserRegistry:
the ``configdiff.parsers`` package is first imported.
"""

_parsers: dict[str, BaseParser] = {}
_parsers: ClassVar[dict[str, BaseParser]] = {}

@classmethod
def register(cls, parser: BaseParser) -> None:
Expand Down
2 changes: 1 addition & 1 deletion configdiff/parsers/toml_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

class TomlParser(BaseParser):
format_name = "toml"
extensions = [".toml"]
extensions = (".toml",)

def parse(self, path: Path) -> dict[str, Any]:
try:
Expand Down
2 changes: 1 addition & 1 deletion configdiff/parsers/yaml_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

class YamlParser(BaseParser):
format_name = "yaml"
extensions = [".yaml", ".yml"]
extensions = (".yaml", ".yml")

def parse(self, path: Path) -> dict[str, Any]:
try:
Expand Down
7 changes: 5 additions & 2 deletions tests/generate_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@

from __future__ import annotations

import json
import subprocess
import sys
import time
Expand Down Expand Up @@ -216,7 +215,11 @@ def generate_report() -> None:
findings = [
("Empty file handling", "PASS", "Empty JSON/YAML parsed as empty dict"),
("Null value transitions", "PASS", "null->value and value->null correctly detected"),
("Type coercion awareness", "PASS", "int/float, str/bool, null/str all detected as TYPE_CHANGED"),
(
"Type coercion awareness",
"PASS",
"int/float, str/bool, null/str all detected as TYPE_CHANGED",
),
("Malformed input rejection", "PASS", "Invalid JSON/YAML returns EXIT_ERROR (code 2)"),
("Unicode support", "PASS", "Emoji, accented chars, special escapes handled"),
("Keys with dots", "PASS", 'Keys like \"a.b.c\" work (path uses same dot notation)'),
Expand Down
3 changes: 2 additions & 1 deletion tests/test_determinism.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import json
from pathlib import Path
from typing import ClassVar

import pytest

Expand Down Expand Up @@ -99,7 +100,7 @@ def test_cli_json_output_stable(
else:
assert data == TestCLIDeterminism._k8s_baseline

_k8s_baseline: dict = {}
_k8s_baseline: ClassVar[dict] = {}


class TestOutputDeterminism:
Expand Down
10 changes: 9 additions & 1 deletion tests/test_diff_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from __future__ import annotations

from configdiff.diff_engine import ChangeType, DiffResult, compare
from configdiff.diff_engine import ChangeType, compare


class TestScalarChanges:
Expand Down Expand Up @@ -95,6 +95,14 @@ def test_ignore_order(self) -> None:
)
assert not result.has_changes

def test_ignore_order_canonicalizes_nested_mappings(self) -> None:
result = compare(
{"a": [{"name": "dns", "servers": ["1.1.1.1", "8.8.8.8"]}]},
{"a": [{"servers": ["8.8.8.8", "1.1.1.1"], "name": "dns"}]},
ignore_order=True,
)
assert not result.has_changes

def test_ignore_order_detects_real_diff(self) -> None:
result = compare(
{"a": [1, 2, 3]},
Expand Down
2 changes: 1 addition & 1 deletion tests/test_edge_cases.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ def test_nested_list_of_dicts(self) -> None:
)
assert result.has_changes
assert len(result.entries) == 1
assert "items[1].val" == result.entries[0].path
assert result.entries[0].path == "items[1].val"

def test_deeply_nested_list(self) -> None:
before = {"a": {"b": {"c": [1, [2, [3, [4]]]]}}}
Expand Down
1 change: 0 additions & 1 deletion tests/test_golden.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@

from configdiff.diff_engine import compare
from configdiff.output.json_output import JsonFormatter
from configdiff.parsers.json_parser import JsonParser
from configdiff.parsers.yaml_parser import YamlParser

GOLDEN_DIR = Path(__file__).parent / "golden"
Expand Down
2 changes: 1 addition & 1 deletion tests/test_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import yaml

from configdiff.diff_engine import ChangeType, DiffEntry, DiffResult, compare
from configdiff.diff_engine import DiffResult, compare
from configdiff.output import get_formatter
from configdiff.output.json_output import JsonFormatter
from configdiff.output.text import TextFormatter
Expand Down
1 change: 0 additions & 1 deletion tests/test_performance.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
from __future__ import annotations

import json
import sys
import time
from pathlib import Path

Expand Down
2 changes: 1 addition & 1 deletion tests/test_property_based.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
import pytest

try:
from hypothesis import given, settings, assume, HealthCheck
from hypothesis import HealthCheck, assume, given, settings
from hypothesis import strategies as st

HAS_HYPOTHESIS = True
Expand Down
7 changes: 4 additions & 3 deletions tests/test_realworld_datasets.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,8 @@

import pytest

from configdiff.cli.app import EXIT_CHANGES, EXIT_ERROR, EXIT_NO_CHANGES, run
from configdiff.cli.app import EXIT_CHANGES, EXIT_NO_CHANGES, run
from configdiff.diff_engine import ChangeType, compare
from configdiff.output.json_output import JsonFormatter
from configdiff.parsers.json_parser import JsonParser
from configdiff.parsers.yaml_parser import YamlParser

Expand Down Expand Up @@ -126,7 +125,9 @@ def test_replica_change(self, k8s_result) -> None:

def test_image_tag_changed(self, k8s_result) -> None:
image_entries = [
e for e in k8s_result.entries if "image" in e.path and e.change_type is ChangeType.MODIFIED
entry
for entry in k8s_result.entries
if "image" in entry.path and entry.change_type is ChangeType.MODIFIED
]
assert len(image_entries) >= 1

Expand Down
Loading