diff --git a/README.md b/README.md index cbf03bb..fec7da4 100644 --- a/README.md +++ b/README.md @@ -174,7 +174,7 @@ specific_args = { } ``` -And last but not least, it's possible to use regular expressions to associate specific arguments to +Another possibility is to use regular expressions to associate specific arguments to a set of files: ```python @@ -186,6 +186,22 @@ specific_args = { } ``` +And last but not least, it's possible to filter files from the reference directory (for example +because the reference directory contains temporary files that should not be compared). For +example, the following code will ignore all files whose name does not start with `file_` and does +not ends with `_tmp.yaml`: + +```python +import dir_content_diff + +dir_content_diff.compare_trees( + "reference_dir", + "compared_dir", + include_patterns=[r"file_.*"], + exclude_patterns=[r".*_tmp\.yaml"], +) +``` + ### Export formatted data diff --git a/dir_content_diff/__init__.py b/dir_content_diff/__init__.py index 29628d5..92ec0e0 100644 --- a/dir_content_diff/__init__.py +++ b/dir_content_diff/__init__.py @@ -15,7 +15,17 @@ import copy import importlib.metadata import re +from collections.abc import Callable from pathlib import Path +from typing import Any +from typing import Dict +from typing import Iterable +from typing import Optional +from typing import Pattern +from typing import Tuple +from typing import Union + +import attrs from dir_content_diff.base_comparators import BaseComparator from dir_content_diff.base_comparators import DefaultComparator @@ -28,6 +38,9 @@ from dir_content_diff.util import diff_msg_formatter from dir_content_diff.util import format_ext +# Type alias for comparators +ComparatorType = Union[BaseComparator, Callable] + __version__ = importlib.metadata.version("dir-content-diff") _DEFAULT_COMPARATORS = { @@ -61,13 +74,15 @@ def get_comparators(): return copy.deepcopy(_COMPARATORS) -def register_comparator(ext, comparator, force=False): +def register_comparator( + ext: str, comparator: ComparatorType, force: bool = False +) -> None: """Add a comparator to the registry. Args: - ext (str): The extension to register. - comparator (callable): The comparator that should be associated with the given extension. - force (bool): If set to ``True``, no exception is raised if the given ``ext`` is already + ext: The extension to register. + comparator: The comparator that should be associated with the given extension. + force: If set to ``True``, no exception is raised if the given ``ext`` is already registered and the comparator is replaced. .. note:: @@ -98,12 +113,12 @@ def register_comparator(ext, comparator, force=False): _COMPARATORS[ext] = comparator -def unregister_comparator(ext, quiet=False): +def unregister_comparator(ext: str, quiet: bool = False): """Remove a comparator from the registry. Args: - ext (str): The extension to unregister. - quiet (bool): If set to ``True``, no exception is raised if the given ``ext`` is not + ext: The extension to unregister. + quiet: If set to ``True``, no exception is raised if the given ``ext`` is not registered. Returns: @@ -115,23 +130,212 @@ def unregister_comparator(ext, quiet=False): return _COMPARATORS.pop(ext, None) +def _convert_iterable_to_tuple( + x: Optional[Iterable[str]], +) -> Optional[Tuple[str, ...]]: + """Convert an iterable to a tuple, or return None.""" + if x is None: + return None + return tuple(x) + + +def _validate_specific_args(instance, attribute, value): # pylint: disable=unused-argument + """Validate specific_args structure.""" + for file_path, args in value.items(): + if not isinstance(args, dict): + raise ValueError(f"specific_args['{file_path}'] must be a dictionary") + # Note: regex patterns in specific_args will be validated during compilation + # in __attrs_post_init__, so no need to validate them here + + +def _validate_export_formatted_files(instance, attribute, value): # pylint: disable=unused-argument + """Validate export_formatted_files is either bool or non-empty string.""" + if isinstance(value, str) and len(value.strip()) == 0: + raise ValueError( + "export_formatted_files must be a non-empty string when provided as string" + ) + + +def _validate_comparators(instance, attribute, value): # pylint: disable=unused-argument + """Validate comparators are either BaseComparator instances or callable.""" + for ext, comparator in value.items(): + if not (isinstance(comparator, BaseComparator) or callable(comparator)): + raise ValueError( + f"Comparator for extension '{ext}' must be a BaseComparator instance " + "or callable" + ) + + +@attrs.frozen +class ComparisonConfig: + """Configuration class to store comparison settings. + + Attributes: + include_patterns: A list of regular expression patterns. If the relative path of a + file does not match any of these patterns, it is ignored during the comparison. Note + that this means that any specific arguments for that file will also be ignored. + exclude_patterns: A list of regular expression patterns. If the relative path of a + file matches any of these patterns, it is ignored during the comparison. Note that + this means that any specific arguments for that file will also be ignored. + comparators: A ``dict`` to override the registered comparators. + specific_args: A ``dict`` with the args/kwargs that should be given to the + comparator for a given file. This ``dict`` should be like the following: + + .. code-block:: Python + + { + : { + comparator: ComparatorInstance, + args: [arg1, arg2, ...], + kwargs: { + kwarg_name_1: kwarg_value_1, + kwarg_name_2: kwarg_value_2, + } + }, + : {...}, + : { + "patterns": ["regex1", "regex2", ...], + ... (other arguments) + } + } + + If the "patterns" entry is present, then the name is not considered and is only used as + a helper for the user. When a "patterns" entry is detected, the other arguments are + applied to all files whose relative name matches one of the given regular expression + patterns. If a file could match multiple patterns of different groups, only the first + one is considered. + + Note that all entries in this ``dict`` are optional. + return_raw_diffs: If set to ``True``, only the raw differences are returned instead + of a formatted report. + export_formatted_files: If set to ``True`` or a not empty string, create a + new directory with formatted compared data files. If a string is passed, this string is + used as suffix for the new directory. If `True` is passed, the suffix is + ``_FORMATTED``. + """ + + include_patterns: Optional[Iterable[str]] = attrs.field( + default=None, converter=_convert_iterable_to_tuple + ) + exclude_patterns: Optional[Iterable[str]] = attrs.field( + default=None, converter=_convert_iterable_to_tuple + ) + comparators: Optional[Dict[Optional[str], ComparatorType]] = attrs.field( + default=None, validator=attrs.validators.optional(_validate_comparators) + ) + specific_args: Optional[Dict[str, Dict[str, Any]]] = attrs.field( + default=None, validator=attrs.validators.optional(_validate_specific_args) + ) + return_raw_diffs: bool = attrs.field(default=False) + export_formatted_files: Union[bool, str] = attrs.field( + default=False, validator=_validate_export_formatted_files + ) + + # Compiled patterns - computed once, no caching complexity needed + compiled_include_patterns: Tuple[Pattern[str], ...] = attrs.field(init=False) + compiled_exclude_patterns: Tuple[Pattern[str], ...] = attrs.field(init=False) + pattern_specific_args: Dict[Pattern[str], Dict[str, Any]] = attrs.field( + init=False, repr=False + ) + + def __attrs_post_init__(self): + """Initialize computed fields after attrs initialization.""" + # Validate and compile patterns - with frozen, we compile once and store directly + try: + compiled_include = self._compile_patterns(self.include_patterns) + object.__setattr__(self, "compiled_include_patterns", compiled_include) + except ValueError as e: + raise ValueError(f"Error in include_patterns: {e}") from e + + try: + compiled_exclude = self._compile_patterns(self.exclude_patterns) + object.__setattr__(self, "compiled_exclude_patterns", compiled_exclude) + except ValueError as e: + raise ValueError(f"Error in exclude_patterns: {e}") from e + + # Setup specific args and pattern specific args + if self.specific_args is None: + # Use object.__setattr__ to modify the field even if it's frozen + object.__setattr__(self, "specific_args", {}) + + # Setup pattern specific args + pattern_specific_args = {} + if self.specific_args: # Check if it's not None + for file_path, v in self.specific_args.items(): + if "patterns" in v: + patterns = v.pop("patterns", []) + for pattern in patterns: + try: + compiled_pattern = self._compile_pattern(pattern) + pattern_specific_args[compiled_pattern] = v + except ValueError as e: + raise ValueError( + f"Error in specific_args['{file_path}']['patterns']: {e}" + ) from e + + object.__setattr__(self, "pattern_specific_args", pattern_specific_args) + + # Setup comparators + if self.comparators is None: + object.__setattr__(self, "comparators", get_comparators()) + + def _compile_pattern(self, pattern: str) -> Pattern[str]: + """Compile a regex pattern.""" + try: + return re.compile(pattern) + except re.error as e: + raise ValueError(f"Invalid regex pattern: '{pattern}'") from e + + def _compile_patterns( + self, patterns: Optional[Iterable[str]] + ) -> Tuple[Pattern[str], ...]: + """Compile regex patterns from any iterable to tuple.""" + if patterns is None: + return () + return tuple(self._compile_pattern(pattern) for pattern in patterns) + + # Note: compiled_include_patterns, compiled_exclude_patterns, and pattern_specific_args + # are now direct attributes set in __attrs_post_init__, no properties needed! + + def should_ignore_file(self, relative_path: str) -> bool: + """Check if a file should be ignored.""" + # Check inclusion patterns first + if self.compiled_include_patterns: + included = any( + pattern.match(relative_path) + for pattern in self.compiled_include_patterns + ) + if not included: + return True + + # Check exclusion patterns + return any( + pattern.match(relative_path) for pattern in self.compiled_exclude_patterns + ) + + def compare_files( - ref_file, comp_file, comparator, *args, return_raw_diffs=False, **kwargs -): + ref_file: str, + comp_file: str, + comparator: ComparatorType, + *args, + return_raw_diffs: bool = False, + **kwargs, +) -> Union[bool, str]: """Compare 2 files and return the difference. Args: - ref_file (str): Path to the reference file. - comp_file (str): Path to the compared file. - comparator (callable): The comparator to use (see in :func:`register_comparator` for the + ref_file: Path to the reference file. + comp_file: Path to the compared file. + comparator: The comparator to use (see in :func:`register_comparator` for the comparator signature). - return_raw_diffs (bool): If set to ``True``, only the raw differences are returned instead + return_raw_diffs: If set to ``True``, only the raw differences are returned instead of a formatted report. *args: passed to the comparator. **kwargs: passed to the comparator. Returns: - bool or str: ``False`` if the files are equal or a string with a message explaining the + ``False`` if the files are equal or a string with a message explaining the differences if they are different. """ # Get the compared file @@ -172,15 +376,20 @@ def compare_files( ) -def export_formatted_file(file, formatted_file, comparator, **kwargs): +def export_formatted_file( + file: str, + formatted_file: str, + comparator: ComparatorType, + **kwargs, +) -> None: """Format a data file and export it. .. note:: A new file is created only if the corresponding comparator has saving capability. Args: - file (str): Path to the compared file. - formatted_file (str): Path to the formatted file. - comparator (callable): The comparator to use (see in :func:`register_comparator` for the + file: Path to the compared file. + formatted_file: Path to the formatted file. + comparator: The comparator to use (see in :func:`register_comparator` for the comparator signature). **kwargs: Can contain the following dictionaries: 'load_kwargs', 'format_data_kwargs' and 'save_kwargs'. @@ -252,13 +461,24 @@ def pick_comparator(comparator=None, suffix=None, comparators=None): return _COMPARATORS.get(None) +def _check_config(config=None, **kwargs): + if config is not None: + if kwargs: + # Override config attributes with kwargs + config = attrs.evolve(config, **kwargs) + else: + config = ComparisonConfig( + **kwargs, + ) + return config + + def compare_trees( - ref_path, - comp_path, - comparators=None, - specific_args=None, - return_raw_diffs=False, - export_formatted_files=False, + ref_path: Union[str, Path], + comp_path: Union[str, Path], + *, + config: ComparisonConfig = None, + **kwargs, ): """Compare all files from 2 different directory trees and return the differences. @@ -269,74 +489,36 @@ def compare_trees( ignored. Args: - ref_path (str): Path to the reference directory. - comp_path (str): Path to the directory that must be compared against the reference. - comparators (dict): A ``dict`` to override the registered comparators. - specific_args (dict): A ``dict`` with the args/kwargs that should be given to the - comparator for a given file. This ``dict`` should be like the following: - - .. code-block:: Python - - { - : { - comparator: ComparatorInstance, - args: [arg1, arg2, ...], - kwargs: { - kwarg_name_1: kwarg_value_1, - kwarg_name_2: kwarg_value_2, - } - }, - : {...}, - : { - "patterns": ["regex1", "regex2", ...], - ... (other arguments) - } - } + ref_path: Path to the reference directory. + comp_path: Path to the directory that must be compared against the reference. + config (ComparisonConfig): A config object. If given, all other configuration parameters + should be set to default values. - If the "patterns" entry is present, then the name is not considered and is only used as - a helper for the user. When a "patterns" entry is detected, the other arguments are - applied to all files whose relative name matches one of the given regular expression - patterns. If a file could match multiple patterns of different groups, only the first - one is considered. - - Note that all entries in this ``dict`` are optional. - return_raw_diffs (bool): If set to ``True``, only the raw differences are returned instead - of a formatted report. - export_formatted_files (bool or str): If set to ``True`` or a not empty string, create a - new directory with formatted compared data files. If a string is passed, this string is - used as suffix for the new directory. If `True` is passed, the suffix is - ``_FORMATTED``. + Keyword Args: + **kwargs (dict): Additional keyword arguments are used to build a ComparisonConfig object + and will override the values of the given `config` argument. Returns: dict: A ``dict`` in which the keys are the relative file paths and the values are the difference messages. If the directories are considered as equal, an empty ``dict`` is returned. """ + config = _check_config(config, **kwargs) + ref_path = Path(ref_path) comp_path = Path(comp_path) formatted_data_path = comp_path.with_name( comp_path.name + ( - export_formatted_files - if (export_formatted_files is not True and export_formatted_files) + config.export_formatted_files + if ( + config.export_formatted_files is not True + and config.export_formatted_files + ) else _DEFAULT_EXPORT_SUFFIX ) ) - if specific_args is None: - specific_args = {} - else: - specific_args = copy.deepcopy(specific_args) - - pattern_specific_args = {} - for v in specific_args.values(): - for pattern in v.pop("patterns", []): - pattern_specific_args[re.compile(pattern)] = v - - # Build the comparator registry if not given - if comparators is None: - comparators = get_comparators() - # Loop over all files and call the correct comparator different_files = {} for ref_file in ref_path.glob("**/*"): @@ -346,10 +528,14 @@ def compare_trees( relative_path = ref_file.relative_to(ref_path).as_posix() comp_file = comp_path / relative_path + if config.should_ignore_file(relative_path): + LOGGER.debug("Ignore file: %s", relative_path) + continue + if comp_file.exists(): - specific_file_args = specific_args.get(relative_path, None) + specific_file_args = (config.specific_args or {}).get(relative_path, None) if specific_file_args is None: - for pattern, pattern_args in pattern_specific_args.items(): + for pattern, pattern_args in config.pattern_specific_args.items(): if pattern.match(relative_path): specific_file_args = copy.deepcopy(pattern_args) break @@ -358,7 +544,7 @@ def compare_trees( comparator = pick_comparator( comparator=specific_file_args.pop("comparator", None), suffix=ref_file.suffix, - comparators=comparators, + comparators=config.comparators, ) comparator_args = specific_file_args.pop("args", []) res = compare_files( @@ -366,12 +552,12 @@ def compare_trees( comp_file, comparator, *comparator_args, - return_raw_diffs=return_raw_diffs, + return_raw_diffs=config.return_raw_diffs, **specific_file_args, ) if res is not False: different_files[relative_path] = res - if export_formatted_files is not False: + if config.export_formatted_files is not False: export_formatted_file( comp_file, formatted_data_path / relative_path, diff --git a/docs/source/conf.py b/docs/source/conf.py index 93a6bc0..0d8ced6 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -84,7 +84,7 @@ autosummary_generate = True # autodoc settings -autodoc_typehints = "signature" +autodoc_typehints = "both" autodoc_default_options = { "members": True, "show-inheritance": True, diff --git a/pyproject.toml b/pyproject.toml index 009b869..ec62bc1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ classifiers = [ ] dynamic = ["version", "optional-dependencies"] dependencies = [ + "attrs>=21.3.0", "click>=8", "dictdiffer>=0.8", "dicttoxml>=1.7.12", diff --git a/tests/test_attrs_validation.py b/tests/test_attrs_validation.py new file mode 100644 index 0000000..10291b7 --- /dev/null +++ b/tests/test_attrs_validation.py @@ -0,0 +1,218 @@ +# +# Copyright (c) 2023-2025 Blue Brain Project, EPFL. +# +# This file is part of dir-content-diff. +# See https://github.com/BlueBrain/dir-content-diff for further info. +# +# SPDX-License-Identifier: Apache-2.0 +# + +"""Test the enhanced ComparisonConfig with attrs validation.""" + +from typing import Any + +import attrs +import pytest + +from dir_content_diff import ComparisonConfig +from dir_content_diff.base_comparators import DefaultComparator +from dir_content_diff.base_comparators import JsonComparator + + +class TestAttrsValidation: + """Test attrs validation features in ComparisonConfig.""" + + def test_valid_config_creation(self): + """Test creating a valid configuration.""" + config = ComparisonConfig( + include_patterns=[".*\\.py$", ".*\\.txt$"], + exclude_patterns=[".*test.*", ".*__pycache__.*"], + return_raw_diffs=True, + export_formatted_files="_formatted", + specific_args={ + "file.json": {"comparator": "JsonComparator"}, + "pattern_group": {"patterns": [".*\\.yaml$"], "args": ["some_arg"]}, + }, + ) + + assert config.include_patterns == (".*\\.py$", ".*\\.txt$") + assert config.exclude_patterns == (".*test.*", ".*__pycache__.*") + assert config.return_raw_diffs is True + assert config.export_formatted_files == "_formatted" + assert len(config.compiled_include_patterns) == 2 + assert len(config.compiled_exclude_patterns) == 2 + + def test_invalid_regex_patterns_validation(self): + """Test validation of invalid regex patterns.""" + # Test invalid include patterns + with pytest.raises(ValueError) as exc_info: + ComparisonConfig(include_patterns=["[invalid_regex"]) + + error_str = str(exc_info.value) + assert "Invalid regex pattern" in error_str + assert "[invalid_regex" in error_str + + # Test invalid exclude patterns + with pytest.raises(ValueError) as exc_info: + ComparisonConfig(exclude_patterns=["(unclosed_group"]) + + error_str = str(exc_info.value) + assert "Invalid regex pattern" in error_str + + # Test invalid patterns in specific_args + with pytest.raises(ValueError) as exc_info: + ComparisonConfig( + specific_args={"category": {"patterns": ["[invalid_regex"], "args": []}} + ) + + error_str = str(exc_info.value) + assert "Invalid regex pattern" in error_str + + def test_export_formatted_files_validation(self): + """Test validation of export_formatted_files field.""" + # Valid boolean values + config = ComparisonConfig(export_formatted_files=True) + assert config.export_formatted_files is True + + config = ComparisonConfig(export_formatted_files=False) + assert config.export_formatted_files is False + + # Valid string values + config = ComparisonConfig(export_formatted_files="_formatted") + assert config.export_formatted_files == "_formatted" + + # Invalid empty string + with pytest.raises(ValueError) as exc_info: + ComparisonConfig(export_formatted_files=" ") + + error_str = str(exc_info.value) + assert "must be a non-empty string" in error_str + + def test_comparators_validation(self): + """Test validation of comparators field.""" + # Valid BaseComparator instances + config = ComparisonConfig( + comparators={".json": JsonComparator(), None: DefaultComparator()} + ) + assert config.comparators is not None + assert isinstance(config.comparators[".json"], JsonComparator) + + # Valid callable functions + def custom_comparator(ref, comp, **kwargs): # pylint: disable=unused-argument + return False + + config = ComparisonConfig(comparators={".custom": custom_comparator}) + assert config.comparators is not None + assert callable(config.comparators[".custom"]) + + # Invalid non-callable object + with pytest.raises(ValueError) as exc_info: + ComparisonConfig(comparators={".invalid": "not_a_comparator"}) + + error_str = str(exc_info.value) + assert "must be a BaseComparator instance or callable" in error_str + + def test_specific_args_validation(self): + """Test validation of specific_args structure.""" + # Valid specific_args + config = ComparisonConfig( + specific_args={ + "file.json": { + "comparator": "JsonComparator", + "args": ["arg1", "arg2"], + "kwargs": {"option": True}, + }, + "pattern_category": { + "patterns": [".*\\.yaml$", ".*\\.yml$"], + "comparator": "YamlComparator", + }, + } + ) + + assert config.specific_args is not None + assert "file.json" in config.specific_args + assert "pattern_category" in config.specific_args + + # Invalid specific_args structure + with pytest.raises(ValueError) as exc_info: + # Use Any to bypass type checker for intentional error test + invalid_data: Any = {"file.json": "not_a_dict"} + ComparisonConfig(specific_args=invalid_data) + + error_str = str(exc_info.value) + assert "dictionary" in error_str.lower() + + def test_pattern_compilation_and_usage(self): + """Test that patterns are properly compiled and can be used.""" + config = ComparisonConfig( + include_patterns=[".*\\.py$"], + exclude_patterns=[".*test.*", ".*__pycache__.*"], + ) + + # Test that patterns are compiled + assert len(config.compiled_include_patterns) == 1 + assert len(config.compiled_exclude_patterns) == 2 + + # Test should_ignore_file method + assert not config.should_ignore_file("main.py") # Matches include, no exclude + assert config.should_ignore_file( + "test_main.py" + ) # Matches include but also exclude + assert config.should_ignore_file("script.js") # Doesn't match include + + def test_default_values(self): + """Test that default values are properly set.""" + config = ComparisonConfig() + + assert config.include_patterns is None + assert config.exclude_patterns is None + assert ( + config.specific_args == {} + ) # Default empty dict after __attrs_post_init__ + assert config.return_raw_diffs is False + assert config.export_formatted_files is False + assert ( + config.comparators is not None + ) # Should be populated with default comparators + + def test_attrs_features(self): + """Test attrs-specific features.""" + + config = ComparisonConfig( + include_patterns=[".*\\.py$"], + exclude_patterns=[".*test.*"], + return_raw_diffs=True, + export_formatted_files="_formatted", + ) + + # Test attrs repr + repr_str = repr(config) + assert "ComparisonConfig" in repr_str + assert "include_patterns" in repr_str + + # Test attrs asdict + try: + config_dict = attrs.asdict(config, recurse=False) + assert "include_patterns" in config_dict + assert "exclude_patterns" in config_dict + assert "return_raw_diffs" in config_dict + assert "export_formatted_files" in config_dict + except Exception: # pylint: disable=broad-exception-caught + # If asdict fails due to complex types, that's ok + pass + + def test_immutability_and_validation_on_construction(self): + """Test that validation happens on construction and fields work correctly.""" + # Test that validation happens during construction + with pytest.raises(ValueError): + ComparisonConfig(include_patterns=["[invalid"]) + + # Test that valid config can be created and accessed + config = ComparisonConfig(include_patterns=[".*\\.py$"], return_raw_diffs=True) + + assert config.include_patterns == (".*\\.py$",) + assert config.return_raw_diffs is True + + # Test that computed properties work + assert len(config.compiled_include_patterns) == 1 + assert config.compiled_include_patterns[0].pattern == ".*\\.py$" diff --git a/tests/test_base.py b/tests/test_base.py index a51b116..0e6d81f 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -23,6 +23,7 @@ import pytest import dir_content_diff +from dir_content_diff import ComparisonConfig from dir_content_diff import assert_equal_trees from dir_content_diff import compare_trees @@ -855,6 +856,60 @@ def res_diff_with_nested_file(res_tree_diff): return res_tree_diff +class TestConfig: + """Test the configuration options.""" + + def test_invalid_patterns(self): + """Test invalid patterns.""" + with pytest.raises( + Exception # Can be ValueError or ValidationError + ) as exc_info: + ComparisonConfig(include_patterns=["[BAD PATTERN<+)"]) + assert "Invalid regex pattern" in str(exc_info.value) + assert "[BAD PATTERN<+)" in str(exc_info.value) + + with pytest.raises( + Exception # Can be ValueError or ValidationError + ) as exc_info: + ComparisonConfig(exclude_patterns=["[BAD PATTERN<+)"]) + assert "Invalid regex pattern" in str(exc_info.value) + assert "[BAD PATTERN<+)" in str(exc_info.value) + + with pytest.raises( + Exception # Can be ValueError or ValidationError + ) as exc_info: + ComparisonConfig( + specific_args={"files from pattern": {"patterns": ["[BAD PATTERN<+)"]}} + ) + assert "Invalid regex pattern" in str(exc_info.value) + assert "[BAD PATTERN<+)" in str(exc_info.value) + + def test_config_and_other_params(self): + """Test that config patterns are properly combined with other patterns.""" + config = ComparisonConfig( + include_patterns=[r".*\.json"], + exclude_patterns=[r".*file\.json"], + specific_args={ + "all json files": { + "comparator": dir_content_diff.DefaultComparator(), + "patterns": [r".*file\.json"], + } + }, + ) + + new_config = dir_content_diff._check_config( # pylint: disable=protected-access + config, + include_patterns=[r".*\.yaml"], + exclude_patterns=[r".*file\.yaml"], + ) + assert new_config.include_patterns == (r".*\.yaml",) + assert new_config.exclude_patterns == (r".*file\.yaml",) + assert new_config.specific_args == config.specific_args + assert new_config.comparators == config.comparators + assert new_config.return_raw_diffs == config.return_raw_diffs + assert new_config.export_formatted_files == config.export_formatted_files + + class TestEqualTrees: """Tests that should return no difference.""" @@ -890,10 +945,11 @@ def test_diff_empty(self, empty_ref_tree, empty_res_tree): def test_pass_register(self, empty_ref_tree, empty_res_tree): """Test with empty trees and with an explicit set of comparators.""" + config = ComparisonConfig(comparators=dir_content_diff.get_comparators()) res = compare_trees( empty_ref_tree, empty_res_tree, - comparators=dir_content_diff.get_comparators(), + config=config, ) assert res == {} @@ -1018,6 +1074,30 @@ def test_diff_tree( ]: assert match_i is not None + def test_diff_tree_ignore( + self, ref_tree, res_tree_diff, pdf_diff, dict_diff, xml_diff, ini_diff + ): + """Test that the returned differences are correct even with ignored files.""" + res = compare_trees( + ref_tree, + res_tree_diff, + include_patterns=[r".*\.[ijpy].*"], + exclude_patterns=[r".*\.yaml", r".*\.ini"], + ) + + # 'include_patterns' excludes files whose extension does not start with any of [i,j,p,y], so + # XML files are excluded. + # 'exclude_patterns' excludes yaml and ini files. + assert len(res) == 2 + match_res_0 = re.match(pdf_diff, res["file.pdf"]) + match_res_1 = re.match(dict_diff, res["file.json"]) + + for match_i in [ + match_res_0, + match_res_1, + ]: + assert match_i is not None + def test_assert_equal_trees( self, ref_tree, res_tree_diff, pdf_diff, dict_diff, xml_diff ):