diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 332097c..af791a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/configdiff/cli/app.py b/configdiff/cli/app.py index 6b36c6a..808a315 100644 --- a/configdiff/cli/app.py +++ b/configdiff/cli/app.py @@ -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 diff --git a/configdiff/diff_engine/__init__.py b/configdiff/diff_engine/__init__.py index 0afa852..d1d087f 100644 --- a/configdiff/diff_engine/__init__.py +++ b/configdiff/diff_engine/__init__.py @@ -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"] diff --git a/configdiff/diff_engine/engine.py b/configdiff/diff_engine/engine.py index 7d891b3..ca4ceea 100644 --- a/configdiff/diff_engine/engine.py +++ b/configdiff/diff_engine/engine.py @@ -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( diff --git a/configdiff/output/__init__.py b/configdiff/output/__init__.py index 0081c2c..862f611 100644 --- a/configdiff/output/__init__.py +++ b/configdiff/output/__init__.py @@ -25,8 +25,8 @@ def get_formatter(name: str) -> BaseFormatter: __all__ = [ "BaseFormatter", - "TextFormatter", "JsonFormatter", + "TextFormatter", "YamlFormatter", "get_formatter", ] diff --git a/configdiff/parsers/__init__.py b/configdiff/parsers/__init__.py index 1c3c9d0..6277f7b 100644 --- a/configdiff/parsers/__init__.py +++ b/configdiff/parsers/__init__.py @@ -12,9 +12,9 @@ ParserRegistry.register(IniParser()) __all__ = [ - "JsonParser", - "YamlParser", - "TomlParser", "IniParser", + "JsonParser", "ParserRegistry", + "TomlParser", + "YamlParser", ] diff --git a/configdiff/parsers/base.py b/configdiff/parsers/base.py index e253a2b..7b9d64b 100644 --- a/configdiff/parsers/base.py +++ b/configdiff/parsers/base.py @@ -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]: diff --git a/configdiff/parsers/ini_parser.py b/configdiff/parsers/ini_parser.py index 8fb2d53..0796205 100644 --- a/configdiff/parsers/ini_parser.py +++ b/configdiff/parsers/ini_parser.py @@ -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: diff --git a/configdiff/parsers/json_parser.py b/configdiff/parsers/json_parser.py index 2fdeb41..910a416 100644 --- a/configdiff/parsers/json_parser.py +++ b/configdiff/parsers/json_parser.py @@ -11,7 +11,7 @@ class JsonParser(BaseParser): format_name = "json" - extensions = [".json"] + extensions = (".json",) def parse(self, path: Path) -> dict[str, Any]: try: diff --git a/configdiff/parsers/registry.py b/configdiff/parsers/registry.py index 02dc02d..6308c51 100644 --- a/configdiff/parsers/registry.py +++ b/configdiff/parsers/registry.py @@ -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 @@ -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: diff --git a/configdiff/parsers/toml_parser.py b/configdiff/parsers/toml_parser.py index c348426..e1e2c0f 100644 --- a/configdiff/parsers/toml_parser.py +++ b/configdiff/parsers/toml_parser.py @@ -11,7 +11,7 @@ class TomlParser(BaseParser): format_name = "toml" - extensions = [".toml"] + extensions = (".toml",) def parse(self, path: Path) -> dict[str, Any]: try: diff --git a/configdiff/parsers/yaml_parser.py b/configdiff/parsers/yaml_parser.py index d28512f..ae4a990 100644 --- a/configdiff/parsers/yaml_parser.py +++ b/configdiff/parsers/yaml_parser.py @@ -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: diff --git a/tests/generate_report.py b/tests/generate_report.py index 7abcf8d..1067fd3 100644 --- a/tests/generate_report.py +++ b/tests/generate_report.py @@ -7,7 +7,6 @@ from __future__ import annotations -import json import subprocess import sys import time @@ -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)'), diff --git a/tests/test_determinism.py b/tests/test_determinism.py index ad6939f..b1ed988 100644 --- a/tests/test_determinism.py +++ b/tests/test_determinism.py @@ -8,6 +8,7 @@ import json from pathlib import Path +from typing import ClassVar import pytest @@ -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: diff --git a/tests/test_diff_engine.py b/tests/test_diff_engine.py index 0374c85..1796d3e 100644 --- a/tests/test_diff_engine.py +++ b/tests/test_diff_engine.py @@ -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: @@ -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]}, diff --git a/tests/test_edge_cases.py b/tests/test_edge_cases.py index 581a644..fbc7668 100644 --- a/tests/test_edge_cases.py +++ b/tests/test_edge_cases.py @@ -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]]]]}}} diff --git a/tests/test_golden.py b/tests/test_golden.py index dd49b13..47c41a2 100644 --- a/tests/test_golden.py +++ b/tests/test_golden.py @@ -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" diff --git a/tests/test_output.py b/tests/test_output.py index 25b6a5f..a02f747 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -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 diff --git a/tests/test_performance.py b/tests/test_performance.py index a23039a..47b1c0b 100644 --- a/tests/test_performance.py +++ b/tests/test_performance.py @@ -8,7 +8,6 @@ from __future__ import annotations import json -import sys import time from pathlib import Path diff --git a/tests/test_property_based.py b/tests/test_property_based.py index 69e8b30..45288fb 100644 --- a/tests/test_property_based.py +++ b/tests/test_property_based.py @@ -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 diff --git a/tests/test_realworld_datasets.py b/tests/test_realworld_datasets.py index 5d75c93..51bbd6d 100644 --- a/tests/test_realworld_datasets.py +++ b/tests/test_realworld_datasets.py @@ -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 @@ -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