From 0bc6d474ee907a107e7da7beda7135613a59fe5e Mon Sep 17 00:00:00 2001 From: Simon Brugman Date: Fri, 23 Feb 2024 18:23:03 +0100 Subject: [PATCH 1/2] Support module wildcard expression --- src/importlinter/domain/imports.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/importlinter/domain/imports.py b/src/importlinter/domain/imports.py index 325a33aa..1947e660 100644 --- a/src/importlinter/domain/imports.py +++ b/src/importlinter/domain/imports.py @@ -27,6 +27,9 @@ def __init__(self, name: str) -> None: """ self.name = name + def has_wildcard_expression(self) -> bool: + return "*" in self.name + def __str__(self) -> str: return self.name From 6aa961cfc1968a39372d8b549339649fa04e183c Mon Sep 17 00:00:00 2001 From: Simon Brugman Date: Fri, 23 Feb 2024 23:06:58 +0100 Subject: [PATCH 2/2] Initial version of standalone contract --- docs/contract_types.rst | 27 ++++ src/importlinter/application/use_cases.py | 1 + src/importlinter/contracts/standalone.py | 64 +++++++++ tests/unit/contracts/test_standalone.py | 162 ++++++++++++++++++++++ 4 files changed, 254 insertions(+) create mode 100644 src/importlinter/contracts/standalone.py create mode 100644 tests/unit/contracts/test_standalone.py diff --git a/docs/contract_types.rst b/docs/contract_types.rst index 23c48c55..303fcf3e 100644 --- a/docs/contract_types.rst +++ b/docs/contract_types.rst @@ -318,6 +318,33 @@ Note: you are not allowed to mix different kinds of separators on the same line. mypackage.blue | mypackage.green : mypackage.yellow # Invalid as it mixes separators. mypackage.low +Standalone modules +------------------ + +*Type name:* ``standalone`` + +Standalone contracts check that a set of modules are standalone, that is not importing +or imported by any other modules in the graph. + +**Example:** + +.. code-block:: ini + + [importlinter:contract:my-standalone-contract] + name = My standalone contract + type = standalone + modules = + mypackage.bar + mypackage.baz + ignore_imports = + mypackage.bar.green -> mypackage.utils + mypackage.foo.purple -> mypackage.baz.blue + +**Configuration options** + + - ``modules``: A list of modules/subpackages that should be independent of each other. + - ``ignore_imports``: See :ref:`Shared options`. + Custom contract types --------------------- diff --git a/src/importlinter/application/use_cases.py b/src/importlinter/application/use_cases.py index ed4db07c..3b0b5eae 100644 --- a/src/importlinter/application/use_cases.py +++ b/src/importlinter/application/use_cases.py @@ -248,6 +248,7 @@ def _get_built_in_contract_types() -> List[Tuple[str, Type[Contract]]]: "forbidden: importlinter.contracts.forbidden.ForbiddenContract", "layers: importlinter.contracts.layers.LayersContract", "independence: importlinter.contracts.independence.IndependenceContract", + "standalone: importlinter.contracts.standalone.StandaloneContract", ], ) ) diff --git a/src/importlinter/contracts/standalone.py b/src/importlinter/contracts/standalone.py new file mode 100644 index 00000000..7a227a47 --- /dev/null +++ b/src/importlinter/contracts/standalone.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from grimp import ImportGraph + +from importlinter.application import contract_utils, output +from importlinter.domain import fields +from importlinter.domain.contract import Contract, ContractCheck + + +class StandaloneContract(Contract): + """ + Standalone contracts check that a set of modules are standalone, that is not importing + or imported by any other modules in the graph. + + Configuration options: + + - modules: A list of Modules that should be standalone. + - ignore_imports: A set of ImportExpressions. These imports will be ignored: if the import + would cause a contract to be broken, adding it to the set will cause + the contract be kept instead. (Optional.) + """ + + type_name = "standalone" + + modules = fields.ListField(subfield=fields.ModuleField()) + ignore_imports = fields.SetField(subfield=fields.ImportExpressionField(), required=False) + + def check(self, graph: ImportGraph, verbose: bool) -> ContractCheck: + warnings = contract_utils.remove_ignored_imports( + graph=graph, + ignore_imports=self.ignore_imports, # type: ignore + unmatched_alerting="none", # type: ignore + ) + + self._check_all_modules_exist_in_graph(graph) + + violations = {} + for module in self.modules: # type: ignore + imports = graph.find_modules_directly_imported_by(module.name) + imported_by = graph.find_modules_that_directly_import(module.name) + if imported_by or imports: + violations[module.name] = [ + (module.name, import_expression) for import_expression in imported_by + ] + [(import_expression, module.name) for import_expression in imports] + + kept = all(len(violation) == 0 for violation in violations.values()) + return ContractCheck( + kept=kept, + warnings=warnings, + metadata={"violations": violations}, + ) + + def render_broken_contract(self, check: "ContractCheck") -> None: + for module_name, connections in check.metadata["violations"].items(): + output.print(f"{module_name} must be standalone:") + output.new_line() + for upstream, downstream in connections: + output.print_error(f"- {downstream} is not allowed to import {upstream}") + output.new_line() + + def _check_all_modules_exist_in_graph(self, graph: ImportGraph) -> None: + for module in self.modules: # type: ignore + if module.name not in graph.modules: + raise ValueError(f"Module '{module.name}' does not exist.") diff --git a/tests/unit/contracts/test_standalone.py b/tests/unit/contracts/test_standalone.py new file mode 100644 index 00000000..37e529ea --- /dev/null +++ b/tests/unit/contracts/test_standalone.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +import pytest +from grimp.adaptors.graph import ImportGraph + +from importlinter.application.app_config import settings +from importlinter.contracts.standalone import StandaloneContract +from importlinter.domain.contract import ContractCheck +from tests.adapters.printing import FakePrinter +from tests.adapters.timing import FakeTimer + + +@pytest.fixture(scope="module", autouse=True) +def configure(): + settings.configure(TIMER=FakeTimer()) + + +class TestStandaloneContract: + def _build_default_graph(self): + graph = ImportGraph() + for module in ( + "mypackage", + "mypackage.blue", + "mypackage.blue.alpha", + "mypackage.blue.beta", + "mypackage.blue.beta.foo", + "mypackage.blue.foo", + "mypackage.blue.hello", + "mypackage.blue.world", + "mypackage.green", + "mypackage.green.bar", + "mypackage.yellow", + "mypackage.yellow.gamma", + "mypackage.yellow.delta", + "mypackage.other", + "mypackage.other.sub", + "mypackage.other.sub2", + ): + graph.add_module(module) + return graph + + def _check_default_contract(self, graph): + contract = StandaloneContract( + name="Standalone contract", + session_options={"root_packages": ["mypackage"]}, + contract_options={"modules": ("mypackage.green", "mypackage.yellow")}, + ) + return contract.check(graph=graph, verbose=False) + + def test_when_modules_are_standalone(self): + graph = self._build_default_graph() + graph.add_import( + importer="mypackage.blue", + imported="mypackage.other", + line_number=10, + line_contents="-", + ) + graph.add_import( + importer="mypackage.other", + imported="mypackage.blue.world", + line_number=11, + line_contents="-", + ) + + contract_check = self._check_default_contract(graph) + + assert contract_check.kept, contract_check.metadata + + def test_non_standalone_imported(self): + graph = self._build_default_graph() + graph.add_import( + importer="mypackage.blue", + imported="mypackage.green", + line_number=10, + line_contents="-", + ) + contract_check = self._check_default_contract(graph) + + assert not contract_check.kept + + expected_metadata = { + "violations": {"mypackage.green": [("mypackage.green", "mypackage.blue")]} + } + assert expected_metadata == contract_check.metadata + + def test_non_standalone_imports(self): + graph = self._build_default_graph() + graph.add_import( + importer="mypackage.yellow", + imported="mypackage.other", + line_number=10, + line_contents="-", + ) + + contract_check = self._check_default_contract(graph) + + assert not contract_check.kept + + expected_metadata = { + "violations": {"mypackage.yellow": [("mypackage.other", "mypackage.yellow")]} + } + assert expected_metadata == contract_check.metadata + + def test_standalone_ignore(self): + graph = self._build_default_graph() + graph.add_import( + importer="mypackage.yellow", + imported="mypackage.other", + line_number=10, + line_contents="-", + ) + + contract = StandaloneContract( + name="Standalone contract", + session_options={"root_packages": ["mypackage"]}, + contract_options={ + "modules": ("mypackage.green", "mypackage.yellow"), + "ignore_imports": ["mypackage.yellow -> mypackage.other"], + }, + ) + contract_check = contract.check(graph=graph, verbose=False) + + assert contract_check.kept + + +def test_render_broken_contract(): + settings.configure(PRINTER=FakePrinter()) + contract = StandaloneContract( + name="Standalone contract", + session_options={"root_packages": ["mypackage"]}, + contract_options={"modules": ["mypackage.green"]}, + ) + check = ContractCheck( + kept=False, + metadata={ + "violations": { + "mypackage": [ + ("mypackage.blue.foo", "mypackage.utils.red"), + ("mypackage.blue.red", "mypackage.utils.yellow"), + ], + "mypackage.green": [ + ("mypackage.green.a.b", "mypackage.green.b.a"), + ], + } + }, + ) + + contract.render_broken_contract(check) + + settings.PRINTER.pop_and_assert( + """ + mypackage must be standalone: + + - mypackage.utils.red is not allowed to import mypackage.blue.foo + - mypackage.utils.yellow is not allowed to import mypackage.blue.red + + mypackage.green must be standalone: + + - mypackage.green.b.a is not allowed to import mypackage.green.a.b + + """ # noqa + )