diff --git a/src/reuse/_config.py b/src/reuse/_config.py index cdbebff58..9c0dc8a00 100644 --- a/src/reuse/_config.py +++ b/src/reuse/_config.py @@ -6,6 +6,8 @@ from dataclasses import dataclass, field from gettext import gettext as _ +from os import PathLike +from pathlib import Path, PurePath from typing import Any, Dict, Optional import yaml @@ -19,6 +21,17 @@ class AnnotateOptions: contact: Optional[str] = None license: Optional[str] = None + def merge(self, other: "AnnotateOptions") -> "AnnotateOptions": + """Return a copy of *self*, but replace attributes with truthy + attributes of *other*. + """ + new_kwargs = {} + for key, value in self.__dict__.items(): + if other_value := getattr(other, key): + value = other_value + new_kwargs[key] = value + return self.__class__(**new_kwargs) + @dataclass class Config: @@ -68,6 +81,19 @@ def from_yaml(cls, text: str) -> "Config": """ return cls.from_dict(yaml.load(text, Loader=yaml.Loader)) + # TODO: We could probably smartly cache the results somehow. + def annotations_for_path(self, path: PathLike) -> AnnotateOptions: + """TODO: Document the precise behaviour.""" + path = PurePath(path) + result = self.global_annotate_options + # This assumes that the override options are ordered by reverse + # precedence. + for o_path, options in self.override_annotate_options.items(): + o_path = Path(o_path).expanduser() + if path.is_relative_to(o_path): + result = result.merge(options) + return result + def _annotate_options_from_dict(value: Dict[str, str]) -> AnnotateOptions: return AnnotateOptions( diff --git a/tests/test_config.py b/tests/test_config.py index aeb57ac7c..0a3048c4f 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -4,13 +4,50 @@ """Tests for some _config.""" +import os from inspect import cleandoc +from textwrap import indent +from unittest import mock -from reuse._config import Config +from reuse._config import AnnotateOptions, Config # REUSE-IgnoreStart +def test_annotate_options_merge_one(): + """Replace one attribute.""" + first = AnnotateOptions( + name="Jane Doe", contact="jane@example.com", license="MIT" + ) + second = AnnotateOptions(name="John Doe") + result = first.merge(second) + assert result.name == second.name + assert result.contact == first.contact + assert result.license == first.license + + +def test_annotate_options_merge_nothing(): + """When merging with an empty AnnotateOptions, do nothing.""" + first = AnnotateOptions( + name="Jane Doe", contact="jane@example.com", license="MIT" + ) + second = AnnotateOptions() + result = first.merge(second) + assert result == first + + +def test_annotate_options_merge_all(): + """When merging with a full AnnotateOptions, replace all attributes.""" + first = AnnotateOptions( + name="Jane Doe", contact="jane@example.com", license="MIT" + ) + second = AnnotateOptions( + name="John Doe", contact="john@example.com", license="0BSD" + ) + result = first.merge(second) + assert result == second + + def test_config_from_dict_global_simple(): """A simple test case for Config.from_dict.""" value = { @@ -87,4 +124,91 @@ def test_config_from_yaml_simple(): ) +def test_config_from_yaml_ordered(): + """The override options are ordered by appearance in the yaml file.""" + overrides = [] + for i in range(100): + overrides.append( + indent( + cleandoc( + f""" + - path: "{i}" + default_name: Jane Doe + """ + ), + prefix=" " * 4, + ) + ) + text = cleandoc( + """ + annotate: + overrides: + {} + """ + ).format("\n".join(overrides)) + result = Config.from_yaml(text) + for i, path in enumerate(result.override_annotate_options): + assert str(i) == path + + +def test_annotations_for_path_global(): + """When there are no overrides, the annotate options for a given path are + always the global options. + """ + options = AnnotateOptions(name="Jane Doe") + config = Config(global_annotate_options=options) + result = config.annotations_for_path("foo") + assert result == options == config.global_annotate_options + + +def test_annotations_for_path_no_match(): + """When the given path doesn't match any overrides, return the global + options. + """ + global_options = AnnotateOptions(name="Jane Doe") + override_options = AnnotateOptions(name="John Doe") + config = Config( + global_annotate_options=global_options, + override_annotate_options={"~/Projects": override_options}, + ) + result = config.annotations_for_path("/etc/foo") + assert result == global_options + + +def test_annotations_for_path_one_match(): + """If one override matches, return the global options merged with the + override options. + """ + global_options = AnnotateOptions(name="Jane Doe") + override_options = AnnotateOptions(contact="jane@example.com") + config = Config( + global_annotate_options=global_options, + override_annotate_options={"/home/jane/Projects": override_options}, + ) + result = config.annotations_for_path( + "/home/jane/Projects/reuse-tool/README.md" + ) + assert result.name == "Jane Doe" + assert result.contact == "jane@example.com" + assert not result.license + + +def test_annotations_for_path_expand_home(): + """When the key path of an override starts with '~', expand it when + checking. + """ + with mock.patch.dict(os.environ, {"HOME": "/home/jane"}): + global_options = AnnotateOptions(name="Jane Doe") + override_options = AnnotateOptions(contact="jane@example.com") + config = Config( + global_annotate_options=global_options, + override_annotate_options={"~/Projects": override_options}, + ) + result = config.annotations_for_path( + # This path must be manually expanded and cannot start with a '~'. + "/home/jane/Projects/reuse-tool/README.md" + ) + assert result.contact == "jane@example.com" + + # REUSE-IgnoreEnd