├── tests ├── __init__.py ├── unit │ ├── __init__.py │ ├── adapters │ │ ├── __init__.py │ │ ├── test_file_finding.py │ │ └── test_user_options.py │ ├── contracts │ │ ├── __init__.py │ │ ├── test_forbidden.py │ │ └── test_independence.py │ ├── domain │ │ ├── __init__.py │ │ ├── test_helpers.py │ │ ├── test_contract.py │ │ ├── test_imports.py │ │ └── test_fields.py │ └── application │ │ ├── __init__.py │ │ └── test_use_cases.py ├── adapters │ ├── __init__.py │ ├── user_options.py │ ├── printing.py │ ├── building.py │ └── filesystem.py ├── functional │ ├── __init__.py │ └── test_lint_imports.py ├── helpers │ ├── __init__.py │ └── contracts.py └── assets │ ├── testpackage │ ├── testpackage │ │ ├── __init__.py │ │ ├── high │ │ │ ├── __init__.py │ │ │ ├── blue │ │ │ │ ├── two.py │ │ │ │ ├── __init__.py │ │ │ │ └── one.py │ │ │ └── green.py │ │ ├── low │ │ │ ├── __init__.py │ │ │ ├── blue │ │ │ │ ├── one.py │ │ │ │ ├── two.py │ │ │ │ └── __init__.py │ │ │ └── green.py │ │ ├── medium │ │ │ ├── green.py │ │ │ ├── __init__.py │ │ │ └── blue │ │ │ │ ├── one.py │ │ │ │ ├── two.py │ │ │ │ └── __init__.py │ │ └── utils.py │ ├── .malformedcontract.ini │ ├── .externalbrokencontract.ini │ ├── .externalkeptcontract.ini │ ├── .brokencontract.ini │ ├── setup.cfg │ └── .customkeptcontract.ini │ └── multipleroots │ ├── rootpackageblue │ ├── __init__.py │ ├── one │ │ ├── __init__.py │ │ └── alpha.py │ ├── three.py │ └── two.py │ ├── rootpackagegreen │ ├── __init__.py │ ├── one.py │ └── two.py │ ├── .multiplerootskeptcontract.ini │ └── .multiplerootsbrokencontract.ini ├── src └── importlinter │ ├── adapters │ ├── __init__.py │ ├── printing.py │ ├── building.py │ ├── filesystem.py │ └── user_options.py │ ├── contracts │ ├── __init__.py │ ├── independence.py │ ├── forbidden.py │ └── layers.py │ ├── domain │ ├── __init__.py │ ├── ports │ │ ├── __init__.py │ │ └── graph.py │ ├── helpers.py │ ├── imports.py │ ├── contract.py │ └── fields.py │ ├── application │ ├── __init__.py │ ├── ports │ │ ├── __init__.py │ │ ├── user_options.py │ │ ├── building.py │ │ ├── printing.py │ │ ├── filesystem.py │ │ └── reporting.py │ ├── app_config.py │ ├── file_finding.py │ ├── user_options.py │ ├── rendering.py │ ├── output.py │ └── use_cases.py │ ├── __init__.py │ └── cli.py ├── docs ├── authors.rst ├── changelog.rst ├── contributing.rst ├── requirements.txt ├── readme.rst ├── installation.rst ├── index.rst ├── Makefile ├── make.bat ├── usage.rst ├── custom_contract_types.rst ├── conf.py └── contract_types.rst ├── setup.cfg ├── pyproject.toml ├── .importlinter ├── .pre-commit-hooks.yaml ├── .coveragerc ├── AUTHORS.rst ├── .gitignore ├── .travis.yml ├── LICENSE ├── tox.ini ├── setup.py ├── CHANGELOG.rst ├── CONTRIBUTING.rst └── README.rst /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/adapters/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/functional/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/helpers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/adapters/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/contracts/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/domain/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/importlinter/adapters/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/importlinter/contracts/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/importlinter/domain/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/application/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/importlinter/application/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/importlinter/domain/ports/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/authors.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../AUTHORS.rst 2 | -------------------------------------------------------------------------------- /src/importlinter/application/ports/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/assets/testpackage/testpackage/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CHANGELOG.rst 2 | -------------------------------------------------------------------------------- /tests/assets/testpackage/testpackage/high/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/assets/testpackage/testpackage/high/blue/two.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/assets/testpackage/testpackage/high/green.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/assets/testpackage/testpackage/low/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/assets/testpackage/testpackage/low/blue/one.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/assets/testpackage/testpackage/low/blue/two.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/assets/testpackage/testpackage/low/green.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/assets/testpackage/testpackage/medium/green.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx>=1.3 2 | sphinx-rtd-theme 3 | -------------------------------------------------------------------------------- /tests/assets/multipleroots/rootpackageblue/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/assets/multipleroots/rootpackageblue/one/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/assets/multipleroots/rootpackagegreen/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/assets/testpackage/testpackage/high/blue/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/assets/testpackage/testpackage/low/blue/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/assets/testpackage/testpackage/medium/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/assets/testpackage/testpackage/medium/blue/one.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/assets/testpackage/testpackage/medium/blue/two.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/assets/testpackage/testpackage/medium/blue/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/assets/multipleroots/rootpackageblue/three.py: -------------------------------------------------------------------------------- 1 | import rootpackageblue.two 2 | -------------------------------------------------------------------------------- /tests/assets/multipleroots/rootpackageblue/two.py: -------------------------------------------------------------------------------- 1 | from .one.alpha import BAR 2 | -------------------------------------------------------------------------------- /tests/assets/multipleroots/rootpackagegreen/one.py: -------------------------------------------------------------------------------- 1 | def foo(x): 2 | return x + 1 3 | -------------------------------------------------------------------------------- /tests/assets/testpackage/testpackage/high/blue/one.py: -------------------------------------------------------------------------------- 1 | from testpackage import utils 2 | -------------------------------------------------------------------------------- /docs/readme.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | 3 | For more details, see :doc:`usage`. 4 | -------------------------------------------------------------------------------- /tests/assets/testpackage/testpackage/utils.py: -------------------------------------------------------------------------------- 1 | from pytest import mark 2 | 3 | from testpackage.high import green 4 | -------------------------------------------------------------------------------- /tests/assets/multipleroots/rootpackagegreen/two.py: -------------------------------------------------------------------------------- 1 | from rootpackageblue.one import alpha 2 | 3 | from . import one 4 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Installation 3 | ============ 4 | 5 | At the command line:: 6 | 7 | pip install import-linter 8 | -------------------------------------------------------------------------------- /tests/assets/multipleroots/rootpackageblue/one/alpha.py: -------------------------------------------------------------------------------- 1 | import sys # Standard library import. 2 | import pytest # Third party library import. 3 | 4 | BAR = "bar" 5 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E731, E203, W503 3 | max-line-length = 100 4 | exclude = */migrations/*, tests/assets/* 5 | 6 | [mypy-pytest] 7 | ignore_missing_imports = True 8 | 9 | -------------------------------------------------------------------------------- /src/importlinter/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "1.2.1" 2 | 3 | from .application import output # noqa 4 | from .domain import fields # noqa 5 | from .domain.contract import Contract, ContractCheck # noqa 6 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 99 3 | 4 | [tool.isort] 5 | multi_line_output = 3 6 | include_trailing_comma = "True" 7 | force_grid_wrap = 0 8 | use_parentheses = "True" 9 | line_length = 99 10 | 11 | -------------------------------------------------------------------------------- /tests/assets/testpackage/.malformedcontract.ini: -------------------------------------------------------------------------------- 1 | [importlinter] 2 | root_package = testpackage 3 | 4 | [importlinter:contract:one] 5 | name=Expected malformed contract 6 | type=layers 7 | containers=someotherpackage 8 | layers= 9 | one 10 | two 11 | three 12 | -------------------------------------------------------------------------------- /.importlinter: -------------------------------------------------------------------------------- 1 | [importlinter] 2 | root_package = importlinter 3 | 4 | [importlinter:contract:1] 5 | name=Layered architecture 6 | type=layers 7 | containers = 8 | importlinter 9 | layers= 10 | cli 11 | adapters 12 | contracts 13 | application 14 | domain 15 | -------------------------------------------------------------------------------- /tests/assets/testpackage/.externalbrokencontract.ini: -------------------------------------------------------------------------------- 1 | [importlinter] 2 | root_package = testpackage 3 | include_external_packages = True 4 | 5 | 6 | [importlinter:contract:one] 7 | name=External kept contract 8 | type=forbidden 9 | source_modules=testpackage.high.blue 10 | forbidden_modules=pytest -------------------------------------------------------------------------------- /tests/assets/testpackage/.externalkeptcontract.ini: -------------------------------------------------------------------------------- 1 | [importlinter] 2 | root_package = testpackage 3 | include_external_packages = True 4 | 5 | 6 | [importlinter:contract:one] 7 | name=External kept contract 8 | type=forbidden 9 | source_modules=testpackage.high.blue 10 | forbidden_modules=sqlalchemy 11 | -------------------------------------------------------------------------------- /.pre-commit-hooks.yaml: -------------------------------------------------------------------------------- 1 | - id: import-linter 2 | name: import-linter 3 | description: Import Linter allows you to define and enforce rules for the internal and external imports within your Python project. 4 | entry: lint-imports 5 | language: python 6 | types: [python] 7 | pass_filenames: false 8 | -------------------------------------------------------------------------------- /tests/assets/multipleroots/.multiplerootskeptcontract.ini: -------------------------------------------------------------------------------- 1 | [importlinter] 2 | root_packages = 3 | rootpackageblue 4 | rootpackagegreen 5 | 6 | 7 | [importlinter:contract:one] 8 | name=Multiple roots kept contract 9 | type=forbidden 10 | source_modules=rootpackageblue.one.alpha 11 | forbidden_modules=rootpackagegreen.two -------------------------------------------------------------------------------- /tests/assets/testpackage/.brokencontract.ini: -------------------------------------------------------------------------------- 1 | [importlinter] 2 | root_package = testpackage 3 | 4 | [importlinter:contract:one] 5 | name=Expected broken contract 6 | ; This should fail, as high.blue.one -> utils -> high.green. 7 | type=independence 8 | modules= 9 | testpackage.high.blue 10 | testpackage.high.green 11 | -------------------------------------------------------------------------------- /tests/assets/testpackage/setup.cfg: -------------------------------------------------------------------------------- 1 | [importlinter] 2 | root_package = testpackage 3 | 4 | [importlinter:contract:one] 5 | name=Test independence contract 6 | type=independence 7 | modules= 8 | testpackage.high.blue 9 | testpackage.high.green 10 | ignore_imports= 11 | testpackage.utils -> testpackage.high.green 12 | -------------------------------------------------------------------------------- /tests/assets/multipleroots/.multiplerootsbrokencontract.ini: -------------------------------------------------------------------------------- 1 | [importlinter] 2 | root_packages = 3 | rootpackageblue 4 | rootpackagegreen 5 | 6 | 7 | [importlinter:contract:one] 8 | name=Multiple roots broken contract 9 | type=forbidden 10 | source_modules=rootpackagegreen.two 11 | forbidden_modules=rootpackageblue.one.alpha -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [paths] 2 | source = 3 | src 4 | */site-packages 5 | 6 | [run] 7 | branch = true 8 | source = 9 | importlinter 10 | tests 11 | parallel = true 12 | 13 | [report] 14 | show_missing = true 15 | precision = 2 16 | omit = *migrations* 17 | exclude_lines = 18 | pragma: nocover 19 | raise NotImplementedError 20 | -------------------------------------------------------------------------------- /tests/assets/testpackage/.customkeptcontract.ini: -------------------------------------------------------------------------------- 1 | [importlinter] 2 | root_package = testpackage 3 | contract_types = 4 | forbidden_import: tests.helpers.contracts.ForbiddenImportContract 5 | 6 | 7 | [importlinter:contract:one] 8 | name=Custom kept contract 9 | type=forbidden_import 10 | importer=testpackage.utils 11 | imported=testpackage.low 12 | -------------------------------------------------------------------------------- /src/importlinter/application/ports/user_options.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from typing import Optional 3 | 4 | from ..user_options import UserOptions 5 | 6 | 7 | class UserOptionReader(abc.ABC): 8 | @abc.abstractmethod 9 | def read_options(self, config_filename: Optional[str] = None) -> Optional[UserOptions]: 10 | raise NotImplementedError 11 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | Contents 3 | ======== 4 | 5 | .. toctree:: 6 | :maxdepth: 2 7 | 8 | readme 9 | installation 10 | usage 11 | contract_types 12 | custom_contract_types 13 | contributing 14 | authors 15 | changelog 16 | 17 | Indices and tables 18 | ================== 19 | 20 | * :ref:`genindex` 21 | * :ref:`modindex` 22 | * :ref:`search` 23 | 24 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | 2 | Authors 3 | ======= 4 | 5 | * David Seddon - https://seddonym.me 6 | 7 | 8 | Contributors 9 | ============ 10 | 11 | * Anthony Sottile - https://github.com/asottile 12 | * Łukasz Skarżyński - https://github.com/skarzi 13 | * Daniel Jurczak - https://github.com/danieljurczak 14 | * Ben Warren - https://github.com/bwarren 15 | * Aaron Gokaslan - https://github.com/Skylion007 16 | -------------------------------------------------------------------------------- /src/importlinter/application/ports/building.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from typing import List 3 | 4 | from importlinter.domain.ports.graph import ImportGraph 5 | 6 | 7 | class GraphBuilder(abc.ABC): 8 | @abc.abstractmethod 9 | def build( 10 | self, root_package_names: List[str], include_external_packages: bool = False 11 | ) -> ImportGraph: 12 | raise NotImplementedError 13 | -------------------------------------------------------------------------------- /src/importlinter/application/ports/printing.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from typing import Optional 3 | 4 | 5 | class Printer(abc.ABC): 6 | @abc.abstractmethod 7 | def print( 8 | self, text: str = "", bold: bool = False, color: Optional[str] = None, newline: bool = True 9 | ) -> None: 10 | """ 11 | Prints a line. 12 | """ 13 | raise NotImplementedError 14 | -------------------------------------------------------------------------------- /src/importlinter/adapters/printing.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | import click 4 | 5 | from importlinter.application.ports.printing import Printer 6 | 7 | 8 | class ClickPrinter(Printer): 9 | """ 10 | Console printer that uses Click's formatting helpers. 11 | """ 12 | 13 | def print( 14 | self, text: str = "", bold: bool = False, color: Optional[str] = None, newline: bool = True 15 | ) -> None: 16 | click.secho(text, bold=bold, fg=color, nl=newline) 17 | -------------------------------------------------------------------------------- /tests/unit/domain/test_helpers.py: -------------------------------------------------------------------------------- 1 | from grimp.adaptors.graph import ImportGraph # type: ignore 2 | from importlinter.domain.helpers import add_imports 3 | 4 | 5 | def test_add_imports(): 6 | graph = ImportGraph() 7 | import_details = [ 8 | {"importer": "a", "imported": "b", "line_number": 1, "line_contents": "lorem ipsum"}, 9 | {"importer": "c", "imported": "d", "line_number": 2, "line_contents": "lorem ipsum 2"}, 10 | ] 11 | assert not graph.modules 12 | add_imports(graph, import_details) 13 | assert graph.modules == {"a", "b", "c", "d"} 14 | -------------------------------------------------------------------------------- /src/importlinter/adapters/building.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | import grimp # type: ignore 4 | from importlinter.application.ports import building as ports 5 | from importlinter.domain.ports.graph import ImportGraph 6 | 7 | 8 | class GraphBuilder(ports.GraphBuilder): 9 | """ 10 | GraphBuilder that just uses Grimp's standard build_graph function. 11 | """ 12 | 13 | def build( 14 | self, root_package_names: List[str], include_external_packages: bool = False 15 | ) -> ImportGraph: 16 | return grimp.build_graph( 17 | *root_package_names, include_external_packages=include_external_packages 18 | ) 19 | -------------------------------------------------------------------------------- /src/importlinter/adapters/filesystem.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from importlinter.application.ports import filesystem as ports 4 | 5 | 6 | class FileSystem(ports.FileSystem): 7 | """ 8 | File system adapter that delegates to built in file system functions. 9 | """ 10 | 11 | def join(self, *components: str) -> str: 12 | return os.path.join(*components) 13 | 14 | def read(self, file_name: str) -> str: 15 | with open(file_name) as file: 16 | return file.read() 17 | 18 | def exists(self, file_name: str) -> bool: 19 | return os.path.isfile(file_name) 20 | 21 | def getcwd(self) -> str: 22 | return os.getcwd() 23 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = ImportLinter 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /tests/adapters/user_options.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from importlinter.application.ports.user_options import UserOptionReader 4 | from importlinter.application.user_options import UserOptions 5 | 6 | 7 | class FakeUserOptionReader(UserOptionReader): 8 | def __init__(self, user_options: UserOptions): 9 | self._user_options = user_options 10 | 11 | def read_options(self, config_filename: Optional[str] = None) -> Optional[UserOptions]: 12 | return self._user_options 13 | 14 | 15 | class ExceptionRaisingUserOptionReader(UserOptionReader): 16 | def __init__(self, exception: Exception): 17 | self._exception = exception 18 | 19 | def read_options(self, config_filename: Optional[str] = None) -> Optional[UserOptions]: 20 | raise self._exception 21 | -------------------------------------------------------------------------------- /src/importlinter/application/app_config.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | 4 | class Settings: 5 | """ 6 | Configuration of the application itself. This allows us to inject different dependencies 7 | dependending on the run context, e.g. in a test run. 8 | """ 9 | 10 | def __init__(self): 11 | self._config = {} 12 | 13 | def configure(self, **config_dict: Any): 14 | self._config.update(config_dict) 15 | 16 | def __getattr__(self, name): 17 | if name[:2] != "__": 18 | return self._config[name] 19 | return super().__getattr__(name) 20 | 21 | def copy(self) -> "Settings": 22 | new_instance = self.__class__() 23 | new_instance.configure(**self._config) 24 | return new_instance 25 | 26 | 27 | settings = Settings() 28 | -------------------------------------------------------------------------------- /src/importlinter/application/ports/filesystem.py: -------------------------------------------------------------------------------- 1 | import abc 2 | 3 | 4 | class FileSystem(abc.ABC): 5 | """ 6 | Abstraction around file system calls. 7 | """ 8 | 9 | @abc.abstractmethod 10 | def join(self, *components: str) -> str: 11 | raise NotImplementedError 12 | 13 | @abc.abstractmethod 14 | def read(self, file_name: str) -> str: 15 | """ 16 | Given a file name, return the contents of the file. 17 | """ 18 | raise NotImplementedError 19 | 20 | @abc.abstractmethod 21 | def exists(self, file_name: str) -> bool: 22 | """ 23 | Return whether a file exists. 24 | """ 25 | raise NotImplementedError 26 | 27 | @abc.abstractmethod 28 | def getcwd(self) -> str: 29 | """ 30 | Return the current working directory. 31 | """ 32 | raise NotImplementedError 33 | -------------------------------------------------------------------------------- /src/importlinter/application/file_finding.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable, List 2 | 3 | from importlinter.application.app_config import settings 4 | 5 | 6 | def find_any(*filenames: Iterable[str]) -> List[str]: 7 | """ 8 | Return a list of names of any potential files that contain config. 9 | 10 | Args: 11 | *filenames: list of filenames, e.g. ('setup.cfg', '.importlinter'). 12 | 13 | Returns: 14 | List of absolute filenames that could be found. 15 | """ 16 | found_files: List[str] = [] 17 | 18 | filesystem = settings.FILE_SYSTEM 19 | current_working_directory = filesystem.getcwd() 20 | 21 | for filename in filenames: 22 | candidate_filename = filesystem.join(current_working_directory, filename) 23 | 24 | if filesystem.exists(candidate_filename): 25 | found_files.append(candidate_filename) 26 | 27 | return found_files 28 | -------------------------------------------------------------------------------- /src/importlinter/application/user_options.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, List 2 | 3 | 4 | class InvalidUserOptions(Exception): 5 | pass 6 | 7 | 8 | class UserOptions: 9 | """ 10 | Configuration supplied by the end user. 11 | 12 | Arguments: 13 | - session_options: General options relating to the running of the linter. 14 | - contracts_options: List of the options that will be used to build the contracts. 15 | """ 16 | 17 | def __init__( 18 | self, session_options: Dict[str, Any], contracts_options: List[Dict[str, Any]] 19 | ) -> None: 20 | self.session_options = session_options 21 | self.contracts_options = contracts_options 22 | 23 | def __eq__(self, other): 24 | if not isinstance(other, UserOptions): 25 | return False 26 | return (self.session_options == other.session_options) and ( 27 | self.contracts_options == other.contracts_options 28 | ) 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | .eggs 13 | parts 14 | bin 15 | var 16 | sdist 17 | wheelhouse 18 | develop-eggs 19 | .installed.cfg 20 | lib 21 | lib64 22 | venv*/ 23 | pyvenv*/ 24 | 25 | # Installer logs 26 | pip-log.txt 27 | 28 | # Unit test / coverage reports 29 | .coverage 30 | .tox 31 | .coverage.* 32 | .pytest_cache/ 33 | nosetests.xml 34 | coverage.xml 35 | htmlcov 36 | 37 | # Translations 38 | *.mo 39 | 40 | # Mr Developer 41 | .mr.developer.cfg 42 | .project 43 | .pydevproject 44 | .idea 45 | *.iml 46 | *.komodoproject 47 | 48 | # Complexity 49 | output/*.html 50 | output/*/index.html 51 | 52 | # Sphinx 53 | docs/_build 54 | 55 | .DS_Store 56 | *~ 57 | .*.sw[po] 58 | .build 59 | .ve 60 | .env 61 | .cache 62 | .pytest 63 | .bootstrap 64 | .appveyor.token 65 | *.bak 66 | 67 | # Mypy Cache 68 | .mypy_cache/ 69 | 70 | .python-version 71 | 72 | pip-wheel-metadata 73 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | set SPHINXPROJ=ImportLinter 13 | 14 | if "%1" == "" goto help 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 20 | echo.installed, then set the SPHINXBUILD environment variable to point 21 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 22 | echo.may add the Sphinx directory to PATH. 23 | echo. 24 | echo.If you don't have Sphinx installed, grab it from 25 | echo.http://sphinx-doc.org/ 26 | exit /b 1 27 | ) 28 | 29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 30 | goto end 31 | 32 | :help 33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 34 | 35 | :end 36 | popd 37 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | sudo: false 3 | cache: pip 4 | env: 5 | global: 6 | - LD_PRELOAD=/lib/x86_64-linux-gnu/libSegFault.so 7 | - SEGFAULT_SIGNALS=all 8 | matrix: 9 | include: 10 | - python: '3.6' 11 | env: 12 | - TOXENV=py36,report,check,docs 13 | - python: '3.7' 14 | dist: xenial 15 | sudo: required 16 | env: 17 | - TOXENV=py37,report 18 | - python: '3.8' 19 | dist: xenial 20 | sudo: required 21 | env: 22 | - TOXENV=py38,report 23 | - python: '3.9' 24 | dist: xenial 25 | sudo: required 26 | env: 27 | - TOXENV=py39,report 28 | before_install: 29 | - python --version 30 | - uname -a 31 | - lsb_release -a 32 | install: 33 | - pip install tox 34 | - virtualenv --version 35 | - easy_install --version 36 | - pip --version 37 | - tox --version 38 | script: 39 | - tox -v 40 | after_failure: 41 | - more .tox/log/* | cat 42 | - more .tox/*/log/* | cat 43 | notifications: 44 | email: 45 | on_success: never 46 | on_failure: never 47 | -------------------------------------------------------------------------------- /tests/unit/adapters/test_file_finding.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from importlinter.application import file_finding 4 | from importlinter.application.app_config import settings 5 | from tests.adapters.filesystem import FakeFileSystem 6 | 7 | 8 | @pytest.mark.parametrize( 9 | "filenames, expected_result", 10 | ( 11 | (["foo.txt"], ["/path/to/folder/foo.txt"]), 12 | (["foo.txt", ".another"], ["/path/to/folder/foo.txt", "/path/to/folder/.another"]), 13 | (["bar.txt"], []), 14 | ), 15 | ) 16 | def test_finds_file_in_current_directory(filenames, expected_result): 17 | settings.configure( 18 | FILE_SYSTEM=FakeFileSystem( 19 | """ 20 | /path/to/folder/ 21 | foo.txt 22 | .another 23 | another/ 24 | foo.txt 25 | bar.txt 26 | """, 27 | working_directory="/path/to/folder", 28 | ) 29 | ) 30 | 31 | result = file_finding.find_any(*filenames) 32 | 33 | assert expected_result == result 34 | -------------------------------------------------------------------------------- /tests/adapters/printing.py: -------------------------------------------------------------------------------- 1 | import textwrap 2 | from typing import Optional 3 | 4 | from importlinter.application.ports.printing import Printer 5 | 6 | 7 | class FakePrinter(Printer): 8 | def __init__(self) -> None: 9 | self._buffer = "" 10 | 11 | def print( 12 | self, text: str = "", bold: bool = False, color: Optional[str] = None, newline: bool = True 13 | ) -> None: 14 | """ 15 | Prints a line. 16 | """ 17 | self._buffer += text 18 | if newline: 19 | self._buffer += "\n" 20 | 21 | def pop_and_assert(self, expected_string): 22 | """ 23 | Assert that the string is what is in the buffer, removing it from the buffer. 24 | 25 | To aid with readable test assertions, the expected string will have leading and trailing 26 | lines removed, and the whole thing will be dedented, before comparison. 27 | """ 28 | 29 | modified_expected_string = textwrap.dedent(expected_string.strip("\n")) 30 | 31 | popped_string = self._buffer 32 | self._buffer = "" 33 | 34 | assert popped_string == modified_expected_string 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) 2019, David Seddon 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following 7 | conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this list of conditions and the following 10 | disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following 13 | disclaimer in the documentation and/or other materials provided with the distribution. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, 16 | INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 17 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 18 | EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF 19 | USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, 20 | STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF 21 | ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 22 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | clean, 4 | check, 5 | docs, 6 | {py36,py37,py38,py39}, 7 | report 8 | 9 | [testenv] 10 | basepython = 11 | py36: {env:TOXPYTHON:python3.6} 12 | py37: {env:TOXPYTHON:python3.7} 13 | py38: {env:TOXPYTHON:python3.8} 14 | py39: {env:TOXPYTHON:python3.9} 15 | {clean,check,docs,report}: {env:TOXPYTHON:python3} 16 | setenv = 17 | PYTHONPATH={toxinidir}/tests 18 | PYTHONUNBUFFERED=yes 19 | passenv = 20 | * 21 | usedevelop = false 22 | deps = 23 | pytest==3.10.0 24 | pytest-travis-fold==1.3.0 25 | pytest-cov==2.6.0 26 | PyYAML==5.1.2 27 | commands = 28 | {posargs:pytest --cov --cov-report=term-missing -vv tests} 29 | 30 | [testenv:check] 31 | deps = 32 | black==20.8b1 33 | flake8==3.7.8 34 | mypy==0.730 35 | commands = 36 | black --check src tests 37 | flake8 src tests setup.py 38 | mypy src/importlinter tests 39 | lint-imports 40 | 41 | [testenv:docs] 42 | deps = 43 | -r{toxinidir}/docs/requirements.txt 44 | commands = 45 | sphinx-build {posargs:-E} -b html docs dist/docs 46 | sphinx-build -b linkcheck docs dist/docs 47 | 48 | [testenv:report] 49 | deps = coverage==5.1 50 | skip_install = true 51 | commands = 52 | coverage report 53 | coverage html 54 | 55 | [testenv:clean] 56 | commands = coverage erase 57 | skip_install = true 58 | deps = coverage 59 | -------------------------------------------------------------------------------- /tests/adapters/building.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from grimp.adaptors.graph import ImportGraph as GrimpImportGraph # type: ignore 4 | from importlinter.application.ports.building import GraphBuilder 5 | from importlinter.domain.ports.graph import ImportGraph 6 | 7 | 8 | class FakeGraphBuilder(GraphBuilder): 9 | """ 10 | Graph builder for when you don't actually want to build a graph. 11 | 12 | Features 13 | ======== 14 | 15 | Injecting a graph 16 | ----------------- 17 | 18 | This builder doesn't build a graph. Instead, call inject_graph with the graph you wish to 19 | inject, ahead of when the builder would be called. 20 | 21 | If inject_graph isn't called, an empty Grimp ImportGraph will be returned. 22 | 23 | Determining the build arguments 24 | ------------------------------- 25 | 26 | The arguments the builder was last called with are stored in self.build_arguments. 27 | """ 28 | 29 | def build( 30 | self, root_package_names: List[str], include_external_packages: bool = False 31 | ) -> ImportGraph: 32 | self.build_arguments = { 33 | "root_package_names": root_package_names, 34 | "include_external_packages": include_external_packages, 35 | } 36 | return getattr(self, "_graph", GrimpImportGraph()) 37 | 38 | def inject_graph(self, graph: ImportGraph) -> None: 39 | self._graph = graph 40 | -------------------------------------------------------------------------------- /src/importlinter/application/ports/reporting.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Iterator, List, Tuple 2 | 3 | from importlinter.domain.contract import Contract, ContractCheck, InvalidContractOptions 4 | from importlinter.domain.ports.graph import ImportGraph 5 | 6 | 7 | class Reporter: 8 | ... 9 | 10 | 11 | class ExceptionReporter: 12 | ... 13 | 14 | 15 | class Report: 16 | def __init__(self, graph: ImportGraph) -> None: 17 | self.graph = graph 18 | self.could_not_run = False 19 | self.invalid_contract_options: Dict[str, InvalidContractOptions] = {} 20 | self.contains_failures = False 21 | self.contracts: List[Contract] = [] 22 | self._check_map: Dict[Contract, ContractCheck] = {} 23 | self.broken_count = 0 24 | self.kept_count = 0 25 | self.module_count = len(graph.modules) 26 | self.import_count = graph.count_imports() 27 | 28 | def add_contract_check(self, contract: Contract, contract_check: ContractCheck) -> None: 29 | self.contracts.append(contract) 30 | self._check_map[contract] = contract_check 31 | if contract_check.kept: 32 | self.kept_count += 1 33 | else: 34 | self.broken_count += 1 35 | self.contains_failures = True 36 | 37 | def get_contracts_and_checks(self) -> Iterator[Tuple[Contract, ContractCheck]]: 38 | for contract in self.contracts: 39 | yield contract, self._check_map[contract] 40 | 41 | def add_invalid_contract_options( 42 | self, contract_name: str, exception: InvalidContractOptions 43 | ) -> None: 44 | self.invalid_contract_options[contract_name] = exception 45 | self.could_not_run = True 46 | self.contains_failures = True 47 | -------------------------------------------------------------------------------- /tests/functional/test_lint_imports.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | 5 | from importlinter import cli 6 | 7 | testpackage_directory = os.path.join(os.path.dirname(__file__), "..", "assets", "testpackage") 8 | multipleroots_directory = os.path.join(os.path.dirname(__file__), "..", "assets", "multipleroots") 9 | 10 | 11 | @pytest.mark.parametrize( 12 | "working_directory, config_filename, expected_result", 13 | ( 14 | (testpackage_directory, None, cli.EXIT_STATUS_SUCCESS), 15 | (testpackage_directory, ".brokencontract.ini", cli.EXIT_STATUS_ERROR), 16 | (testpackage_directory, ".malformedcontract.ini", cli.EXIT_STATUS_ERROR), 17 | (testpackage_directory, ".customkeptcontract.ini", cli.EXIT_STATUS_SUCCESS), 18 | (testpackage_directory, ".externalkeptcontract.ini", cli.EXIT_STATUS_SUCCESS), 19 | (testpackage_directory, ".externalbrokencontract.ini", cli.EXIT_STATUS_ERROR), 20 | (multipleroots_directory, ".multiplerootskeptcontract.ini", cli.EXIT_STATUS_SUCCESS), 21 | (multipleroots_directory, ".multiplerootsbrokencontract.ini", cli.EXIT_STATUS_ERROR), 22 | ), 23 | ) 24 | def test_lint_imports(working_directory, config_filename, expected_result): 25 | 26 | os.chdir(working_directory) 27 | 28 | if config_filename: 29 | result = cli.lint_imports(config_filename=config_filename) 30 | else: 31 | result = cli.lint_imports() 32 | 33 | assert expected_result == result 34 | 35 | 36 | @pytest.mark.parametrize("is_debug_mode", (True, False)) 37 | def test_lint_imports_debug_mode(is_debug_mode): 38 | kwargs = dict(config_filename=".nonexistentcontract.ini", is_debug_mode=is_debug_mode) 39 | if is_debug_mode: 40 | with pytest.raises(FileNotFoundError): 41 | cli.lint_imports(**kwargs) 42 | else: 43 | assert cli.EXIT_STATUS_ERROR == cli.lint_imports(**kwargs) 44 | -------------------------------------------------------------------------------- /src/importlinter/cli.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from typing import Optional 4 | 5 | import click 6 | 7 | from .adapters.building import GraphBuilder 8 | from .adapters.filesystem import FileSystem 9 | from .adapters.printing import ClickPrinter 10 | from .adapters.user_options import IniFileUserOptionReader 11 | from .application import use_cases 12 | from .application.app_config import settings 13 | 14 | settings.configure( 15 | USER_OPTION_READERS=[IniFileUserOptionReader()], 16 | GRAPH_BUILDER=GraphBuilder(), 17 | PRINTER=ClickPrinter(), 18 | FILE_SYSTEM=FileSystem(), 19 | ) 20 | 21 | EXIT_STATUS_SUCCESS = 0 22 | EXIT_STATUS_ERROR = 1 23 | 24 | 25 | @click.command() 26 | @click.option("--config", default=None, help="The config file to use.") 27 | @click.option("--debug", is_flag=True, help="Run in debug mode.") 28 | def lint_imports_command(config: Optional[str], debug: bool) -> int: 29 | """ 30 | The entry point for the CLI command. 31 | """ 32 | exit_code = lint_imports(config_filename=config, is_debug_mode=debug) 33 | sys.exit(exit_code) 34 | 35 | 36 | def lint_imports(config_filename: Optional[str] = None, is_debug_mode: bool = False) -> int: 37 | """ 38 | Check that a project adheres to a set of contracts. 39 | 40 | This is the main function that runs the linter. 41 | 42 | Args: 43 | config_filename: The configuration file to use. If not supplied, Import Linter will look 44 | for setup.cfg or .importlinter in the current directory. 45 | is_debug_mode: Whether debugging should be turned on. In debug mode, exceptions are 46 | not swallowed at the top level, so the stack trace can be seen. 47 | 48 | Returns: 49 | EXIT_STATUS_SUCCESS or EXIT_STATUS_ERROR. 50 | """ 51 | # Add current directory to the path, as this doesn't happen automatically. 52 | sys.path.insert(0, os.getcwd()) 53 | 54 | passed = use_cases.lint_imports(config_filename=config_filename, is_debug_mode=is_debug_mode) 55 | 56 | if passed: 57 | return EXIT_STATUS_SUCCESS 58 | else: 59 | return EXIT_STATUS_ERROR 60 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- encoding: utf-8 -*- 3 | import io 4 | from glob import glob 5 | from os.path import basename, dirname, join, splitext 6 | 7 | from setuptools import find_packages, setup 8 | 9 | 10 | def read(*names, **kwargs): 11 | with io.open( 12 | join(dirname(__file__), *names), encoding=kwargs.get("encoding", "utf8") 13 | ) as fh: 14 | return fh.read() 15 | 16 | 17 | setup( 18 | name="import-linter", 19 | version="1.2.1", 20 | license="BSD 2-Clause License", 21 | description="Enforces rules for the imports within and between Python packages.", 22 | long_description=read("README.rst"), 23 | long_description_content_type="text/x-rst", 24 | author="David Seddon", 25 | author_email="david@seddonym.me", 26 | project_urls={ 27 | "Documentation": "https://import-linter.readthedocs.io/", 28 | "Source code": "https://github.com/seddonym/import-linter/", 29 | }, 30 | packages=find_packages("src"), 31 | package_dir={"": "src"}, 32 | py_modules=[splitext(basename(path))[0] for path in glob("src/*.py")], 33 | include_package_data=True, 34 | zip_safe=False, 35 | classifiers=[ 36 | # complete classifier list: http://pypi.python.org/pypi?%3Aaction=list_classifiers 37 | "Development Status :: 5 - Production/Stable", 38 | "Intended Audience :: Developers", 39 | "License :: OSI Approved :: BSD License", 40 | "Operating System :: Unix", 41 | "Operating System :: POSIX", 42 | "Operating System :: Microsoft :: Windows", 43 | "Programming Language :: Python", 44 | "Programming Language :: Python :: 3.6", 45 | "Programming Language :: Python :: 3.7", 46 | "Programming Language :: Python :: 3.8", 47 | "Programming Language :: Python :: 3.9", 48 | "Programming Language :: Python :: Implementation :: CPython", 49 | "Topic :: Utilities", 50 | ], 51 | python_requires=">=3.6", 52 | install_requires=["click>=6,<8", "grimp>=1.2.3,<2"], 53 | entry_points={ 54 | "console_scripts": ["lint-imports = importlinter.cli:lint_imports_command"] 55 | }, 56 | ) 57 | -------------------------------------------------------------------------------- /src/importlinter/domain/helpers.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Iterable, List, Union 2 | 3 | from importlinter.domain.imports import DirectImport 4 | from importlinter.domain.ports.graph import ImportGraph 5 | 6 | 7 | class MissingImport(Exception): 8 | pass 9 | 10 | 11 | def pop_imports( 12 | graph: ImportGraph, imports: Iterable[DirectImport] 13 | ) -> List[Dict[str, Union[str, int]]]: 14 | """ 15 | Removes the supplied direct imports from the graph. 16 | 17 | Returns: 18 | The list of import details that were removed, including any additional metadata. 19 | 20 | Raises: 21 | MissingImport if the import is not present in the graph. 22 | """ 23 | removed_imports: List[Dict[str, Union[str, int]]] = [] 24 | for import_to_remove in imports: 25 | import_details = graph.get_import_details( 26 | importer=import_to_remove.importer.name, imported=import_to_remove.imported.name 27 | ) 28 | if not import_details: 29 | raise MissingImport(f"Ignored import {import_to_remove} not present in the graph.") 30 | removed_imports.extend(import_details) 31 | graph.remove_import( 32 | importer=import_to_remove.importer.name, imported=import_to_remove.imported.name 33 | ) 34 | return removed_imports 35 | 36 | 37 | def add_imports(graph: ImportGraph, import_details: List[Dict[str, Union[str, int]]]) -> None: 38 | """ 39 | Adds the supplied import details to the graph. 40 | 41 | Intended to be the reverse of pop_imports, so the following code should leave the 42 | graph unchanged: 43 | 44 | import_details = pop_imports(graph, imports) 45 | add_imports(graph, import_details) 46 | """ 47 | for details in import_details: 48 | assert isinstance(details["importer"], str) 49 | assert isinstance(details["imported"], str) 50 | assert isinstance(details["line_number"], int) 51 | assert isinstance(details["line_contents"], str) 52 | graph.add_import( 53 | importer=details["importer"], 54 | imported=details["imported"], 55 | line_number=details["line_number"], 56 | line_contents=details["line_contents"], 57 | ) 58 | -------------------------------------------------------------------------------- /src/importlinter/adapters/user_options.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | from typing import Any, Dict, Optional 3 | 4 | from importlinter.application import file_finding 5 | from importlinter.application.app_config import settings 6 | from importlinter.application.ports import user_options as ports 7 | from importlinter.application.user_options import UserOptions 8 | 9 | 10 | class IniFileUserOptionReader(ports.UserOptionReader): 11 | """ 12 | Reader that looks for and parses the contents of INI files. 13 | """ 14 | 15 | potential_config_filenames = ("setup.cfg", ".importlinter") 16 | section_name = "importlinter" 17 | 18 | def read_options(self, config_filename: Optional[str] = None) -> Optional[UserOptions]: 19 | if config_filename: 20 | config_filenames = file_finding.find_any(config_filename) 21 | if not config_filenames: 22 | # If we specify a filename, raise an exception. 23 | raise FileNotFoundError(f"Could not find {config_filename}.") 24 | else: 25 | config_filenames = file_finding.find_any(*self.potential_config_filenames) 26 | if not config_filenames: 27 | return None 28 | 29 | for config_filename in config_filenames: 30 | config = configparser.ConfigParser() 31 | file_contents = settings.FILE_SYSTEM.read(config_filename) 32 | config.read_string(file_contents) 33 | if self.section_name in config.sections(): 34 | return self._build_from_config(config) 35 | return None 36 | 37 | def _build_from_config(self, config: configparser.ConfigParser) -> UserOptions: 38 | session_options = self._clean_section_config(dict(config[self.section_name])) 39 | contract_options = [] 40 | for section_name in config.sections(): 41 | if section_name.startswith(f"{self.section_name}:"): 42 | contract_options.append(self._clean_section_config(dict(config[section_name]))) 43 | return UserOptions(session_options=session_options, contracts_options=contract_options) 44 | 45 | @staticmethod 46 | def _clean_section_config(section_config: Dict[str, Any]) -> Dict[str, Any]: 47 | section_dict: Dict[str, Any] = {} 48 | for key, value in section_config.items(): 49 | if "\n" not in value: 50 | section_dict[key] = value 51 | else: 52 | section_dict[key] = value.strip().split("\n") 53 | return section_dict 54 | -------------------------------------------------------------------------------- /src/importlinter/application/rendering.py: -------------------------------------------------------------------------------- 1 | from . import output 2 | from .ports.reporting import Report 3 | 4 | # Public functions 5 | # ---------------- 6 | 7 | 8 | def render_report(report: Report) -> None: 9 | """ 10 | Output the supplied report to the console. 11 | """ 12 | if report.could_not_run: 13 | _render_could_not_run(report) 14 | return 15 | 16 | output.print_heading("Import Linter", output.HEADING_LEVEL_ONE) 17 | output.print_heading("Contracts", output.HEADING_LEVEL_TWO) 18 | file_count = report.module_count 19 | dependency_count = report.import_count 20 | output.print_heading( 21 | f"Analyzed {file_count} files, {dependency_count} dependencies.", 22 | output.HEADING_LEVEL_THREE, 23 | ) 24 | 25 | for contract, contract_check in report.get_contracts_and_checks(): 26 | result_text = "KEPT" if contract_check.kept else "BROKEN" 27 | color_key = output.SUCCESS if contract_check.kept else output.ERROR 28 | color = output.COLORS[color_key] 29 | output.print(f"{contract.name} ", newline=False) 30 | output.print(result_text, color=color) 31 | output.new_line() 32 | 33 | output.print(f"Contracts: {report.kept_count} kept, {report.broken_count} broken.") 34 | 35 | if report.broken_count: 36 | output.new_line() 37 | output.new_line() 38 | _render_broken_contracts_details(report) 39 | 40 | 41 | def render_exception(exception: Exception) -> None: 42 | """ 43 | Render any exception to the console. 44 | """ 45 | output.print_error(str(exception)) 46 | 47 | 48 | # Private functions 49 | # ----------------- 50 | 51 | 52 | def _render_could_not_run(report: Report) -> None: 53 | for contract_name, exception in report.invalid_contract_options.items(): 54 | output.print_error(f'Contract "{contract_name}" is not configured correctly:') 55 | for field_name, message in exception.errors.items(): 56 | output.indent_cursor() 57 | output.print_error(f"{field_name}: {message}", bold=False) 58 | 59 | 60 | def _render_broken_contracts_details(report: Report) -> None: 61 | output.print_heading("Broken contracts", output.HEADING_LEVEL_TWO, style=output.ERROR) 62 | 63 | for contract, check in report.get_contracts_and_checks(): 64 | if check.kept: 65 | continue 66 | output.print_heading(contract.name, output.HEADING_LEVEL_THREE, style=output.ERROR) 67 | 68 | contract.render_broken_contract(check) 69 | -------------------------------------------------------------------------------- /src/importlinter/domain/ports/graph.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from typing import Dict, List, Optional, Set, Tuple, Union 3 | 4 | 5 | class ImportGraph(abc.ABC): 6 | @property 7 | @abc.abstractmethod 8 | def modules(self) -> Set[str]: 9 | """ 10 | The names of all the modules in the graph. 11 | """ 12 | raise NotImplementedError 13 | 14 | @abc.abstractmethod 15 | def count_imports(self) -> int: 16 | """ 17 | Return the number of imports in the graph. 18 | """ 19 | raise NotImplementedError 20 | 21 | @abc.abstractmethod 22 | def find_descendants(self, module: str) -> Set[str]: 23 | raise NotImplementedError 24 | 25 | @abc.abstractmethod 26 | def find_shortest_chain(self, importer: str, imported: str) -> Optional[Tuple[str, ...]]: 27 | raise NotImplementedError 28 | 29 | @abc.abstractmethod 30 | def find_shortest_chains(self, importer: str, imported: str) -> Set[Tuple[str, ...]]: 31 | """ 32 | Find the shortest import chains that exist between the importer and imported, and 33 | between any modules contained within them. Only one chain per upstream/downstream pair 34 | will be included. Any chains that are contained within other chains in the result set 35 | will be excluded. 36 | 37 | Returns: 38 | A set of tuples of strings. Each tuple is ordered from importer to imported modules. 39 | """ 40 | raise NotImplementedError 41 | 42 | @abc.abstractmethod 43 | def get_import_details( 44 | self, *, importer: str, imported: str 45 | ) -> List[Dict[str, Union[str, int]]]: 46 | """ 47 | Returns a list of the details of every direct import between two modules, in the form: 48 | [ 49 | { 50 | 'importer': 'mypackage.importer', 51 | 'imported': 'mypackage.imported', 52 | 'line_number': 5, 53 | 'line_contents': 'from mypackage import imported', 54 | }, 55 | (additional imports here) 56 | ] 57 | """ 58 | raise NotImplementedError 59 | 60 | @abc.abstractmethod 61 | def add_import( 62 | self, 63 | *, 64 | importer: str, 65 | imported: str, 66 | line_number: Optional[int] = None, 67 | line_contents: Optional[str] = None 68 | ) -> None: 69 | raise NotImplementedError 70 | 71 | @abc.abstractmethod 72 | def remove_import(self, *, importer: str, imported: str) -> None: 73 | raise NotImplementedError 74 | -------------------------------------------------------------------------------- /src/importlinter/domain/imports.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Optional 2 | 3 | 4 | class ValueObject: 5 | def __repr__(self) -> str: 6 | return "<{}: {}>".format(self.__class__.__name__, self) 7 | 8 | def __eq__(self, other: Any) -> bool: 9 | if isinstance(other, self.__class__): 10 | return hash(self) == hash(other) 11 | else: 12 | return False 13 | 14 | def __hash__(self) -> int: 15 | return hash(str(self)) 16 | 17 | 18 | class Module(ValueObject): 19 | """ 20 | A Python module. 21 | """ 22 | 23 | def __init__(self, name: str) -> None: 24 | """ 25 | Args: 26 | name: The fully qualified name of a Python module, e.g. 'package.foo.bar'. 27 | """ 28 | self.name = name 29 | 30 | def __str__(self) -> str: 31 | return self.name 32 | 33 | @property 34 | def root_package_name(self) -> str: 35 | return self.name.split(".")[0] 36 | 37 | @property 38 | def parent(self) -> "Module": 39 | components = self.name.split(".") 40 | if len(components) == 1: 41 | raise ValueError("Module has no parent.") 42 | return Module(".".join(components[:-1])) 43 | 44 | def is_child_of(self, module: "Module") -> bool: 45 | try: 46 | return module == self.parent 47 | except ValueError: 48 | # If this module has no parent, then it cannot be a child of the supplied module. 49 | return False 50 | 51 | def is_descendant_of(self, module: "Module") -> bool: 52 | return self.name.startswith(f"{module.name}.") 53 | 54 | def is_package(self) -> bool: 55 | """ 56 | Whether the module can contain other modules. 57 | 58 | Practically, this corresponds to whether a module is an __init__.py file. 59 | """ 60 | raise NotImplementedError 61 | 62 | 63 | class DirectImport(ValueObject): 64 | """ 65 | An import between one module and another. 66 | """ 67 | 68 | def __init__( 69 | self, 70 | *, 71 | importer: Module, 72 | imported: Module, 73 | line_number: Optional[int] = None, 74 | line_contents: Optional[str] = None, 75 | ) -> None: 76 | self.importer = importer 77 | self.imported = imported 78 | self.line_number = line_number 79 | self.line_contents = line_contents 80 | 81 | def __str__(self) -> str: 82 | if self.line_number: 83 | return "{} -> {} (l. {})".format(self.importer, self.imported, self.line_number) 84 | else: 85 | return "{} -> {}".format(self.importer, self.imported) 86 | 87 | def __hash__(self) -> int: 88 | return hash((str(self), self.line_contents)) 89 | -------------------------------------------------------------------------------- /tests/unit/domain/test_contract.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from importlinter.domain import fields 4 | from importlinter.domain.contract import ( 5 | Contract, 6 | ContractRegistry, 7 | InvalidContractOptions, 8 | NoSuchContractType, 9 | ) 10 | 11 | 12 | class MyField(fields.Field): 13 | def parse(self, raw_data): 14 | if raw_data == "something invalid": 15 | raise fields.ValidationError(f'"{raw_data}" is not a valid value.') 16 | return raw_data 17 | 18 | 19 | class MyContract(Contract): 20 | foo = MyField() 21 | bar = MyField(required=False) 22 | 23 | def check(self, *args, **kwargs): 24 | raise NotImplementedError 25 | 26 | def render_broken_contract(self, *args, **kwargs): 27 | raise NotImplementedError 28 | 29 | 30 | class AnotherContract(Contract): 31 | def check(self, *args, **kwargs): 32 | raise NotImplementedError 33 | 34 | def render_broken_contract(self, *args, **kwargs): 35 | raise NotImplementedError 36 | 37 | 38 | @pytest.mark.parametrize( 39 | "contract_options, expected_errors", 40 | ( 41 | ( 42 | {"foo": "The quick brown fox jumps over the lazy dog.", "bar": "To be, or not to be."}, 43 | None, # Valid. 44 | ), 45 | ({}, {"foo": "This is a required field."}), # No data. 46 | ({"foo": "something invalid"}, {"foo": '"something invalid" is not a valid value.'}), 47 | ), 48 | ) 49 | def test_contract_validation(contract_options, expected_errors): 50 | contract_kwargs = dict( 51 | name="My contract", session_options={}, contract_options=contract_options 52 | ) 53 | 54 | if expected_errors is None: 55 | contract = MyContract(**contract_kwargs) 56 | for key, value in contract_options.items(): 57 | assert getattr(contract, key) == value 58 | return 59 | 60 | try: 61 | MyContract(**contract_kwargs) 62 | except InvalidContractOptions as e: 63 | assert e.errors == expected_errors 64 | else: 65 | assert False, "Did not raise InvalidContractOptions." # pragma: nocover 66 | 67 | 68 | class TestContractRegistry: 69 | @pytest.mark.parametrize( 70 | "name, expected_result", 71 | (("foo", MyContract), ("bar", AnotherContract), ("baz", NoSuchContractType())), 72 | ) 73 | def test_registry(self, name, expected_result): 74 | registry = ContractRegistry() 75 | 76 | registry.register(MyContract, name="foo") 77 | registry.register(AnotherContract, name="bar") 78 | 79 | if isinstance(expected_result, Exception): 80 | with pytest.raises(NoSuchContractType): 81 | registry.get_contract_class(name) 82 | else: 83 | assert expected_result == registry.get_contract_class(name) 84 | -------------------------------------------------------------------------------- /tests/unit/domain/test_imports.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager 2 | 3 | import pytest 4 | from importlinter.domain.imports import DirectImport, Module 5 | 6 | 7 | @contextmanager 8 | def does_not_raise(): 9 | yield 10 | 11 | 12 | class TestModule: 13 | def test_object_representation(self): 14 | test_object = Module("new_module") 15 | assert repr(test_object) == "" 16 | 17 | @pytest.mark.parametrize( 18 | ("first_object", "second_object", "expected_bool"), 19 | [ 20 | (Module("first"), Module("second"), False), 21 | (Module("same"), Module("same"), True), 22 | (Module("different"), "different", False), 23 | ], 24 | ) 25 | def test_equal_magic_method(self, first_object, second_object, expected_bool): 26 | comparison_result = first_object == second_object 27 | assert comparison_result is expected_bool 28 | 29 | @pytest.mark.parametrize( 30 | ("module", "expected_parent", "exception"), 31 | [ 32 | (Module("parent.child"), Module("parent"), does_not_raise()), 33 | (Module("child"), Module(""), pytest.raises(ValueError)), 34 | ], 35 | ) 36 | def test_parent(self, module, expected_parent, exception): 37 | with exception: 38 | assert module.parent == expected_parent 39 | 40 | @pytest.mark.parametrize( 41 | ("child", "parent", "expected_bool"), 42 | [ 43 | (Module("parent.child"), Module("parent"), True), 44 | (Module("grandparent.parent.child"), Module("grandparent"), False), 45 | (Module("first_child"), Module("second_child"), False), 46 | ], 47 | ) 48 | def test_is_child_of(self, child, parent, expected_bool): 49 | assert child.is_child_of(parent) is expected_bool 50 | 51 | 52 | class TestDirectImport: 53 | def test_object_representation(self): 54 | test_object = DirectImport( 55 | importer=Module("mypackage.foo"), 56 | imported=Module("mypackage.bar"), 57 | ) 58 | assert repr(test_object) == " mypackage.bar>" 59 | 60 | @pytest.mark.parametrize( 61 | ("test_object", "expected_string"), 62 | [ 63 | ( 64 | DirectImport(importer=Module("mypackage.foo"), imported=Module("mypackage.bar")), 65 | "mypackage.foo -> mypackage.bar", 66 | ), 67 | ( 68 | DirectImport( 69 | importer=Module("mypackage.foo"), 70 | imported=Module("mypackage.bar"), 71 | line_number=10, 72 | ), 73 | "mypackage.foo -> mypackage.bar (l. 10)", 74 | ), 75 | ], 76 | ) 77 | def test_string_object_representation(self, test_object, expected_string): 78 | assert str(test_object) == expected_string 79 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | 5 | 1.2.1 (2021-01-22) 6 | ------------------ 7 | 8 | * Add allow_indirect_imports to Forbidden Contract type 9 | * Upgrade Grimp to 1.2.3. 10 | * Officially support Python 3.9. 11 | 12 | 1.2 (2020-09-23) 13 | ---------------- 14 | 15 | * Upgrade Grimp to 1.2.2. 16 | * Add SetField. 17 | * Use a SetField for ignore_imports options. 18 | * Add support for non `\w` characters in import exceptions. 19 | 20 | 1.1 (2020-06-29) 21 | ---------------- 22 | 23 | * Bring 1.1 out of beta. 24 | 25 | 1.1b2 (2019-11-27) 26 | ------------------ 27 | 28 | * Update to Grimp v1.2, significantly increasing speed of building the graph. 29 | 30 | 1.1b1 (2019-11-24) 31 | ------------------ 32 | 33 | * Provide debug mode. 34 | * Allow contracts to mutate the graph without affecting other contracts. 35 | * Update to Grimp v1.1. 36 | * Change the rendering of broken layers contracts by combining any shared chain beginning or endings. 37 | * Speed up and make more comprehensive the algorithm for finding illegal chains in layer contracts. Prior to this, 38 | layers contracts used Grimp's find_shortest_chains method for each pairing of layers. This found the shortest chain 39 | between each pair of modules across the two layers. The algorithm was very slow and not comprehensive. With this 40 | release, for each pair of layers, a copy of the graph is made. All other layers are removed from the graph, any 41 | direct imports between the two layers are stored. Next, the two layers in question are 'squashed', the shortest 42 | chain is repeatedly popped from the graph until no more chains remain. This results in more comprehensive results, 43 | and at significantly increased speed. 44 | 45 | 1.0 (2019-17-10) 46 | ---------------- 47 | 48 | * Officially support Python 3.8. 49 | 50 | 1.0b5 (2019-10-05) 51 | ------------------ 52 | 53 | * Allow multiple root packages. 54 | * Make containers optional in Layers contracts. 55 | 56 | 1.0b4 (2019-07-03) 57 | ------------------ 58 | 59 | * Add https://pre-commit.com configuration. 60 | * Use find_shortest_chains instead of find_shortest_chain on the Grimp import graph. 61 | * Add Forbidden Modules contract type. 62 | 63 | 1.0b3 (2019-05-15) 64 | ------------------ 65 | 66 | * Update to Grimp v1.0b10, fixing Windows incompatibility. 67 | 68 | 1.0b2 (2019-04-16) 69 | ------------------ 70 | 71 | * Update to Grimp v1.0b9, fixing error with using importlib.util.find_spec. 72 | 73 | 1.0b1 (2019-04-06) 74 | ------------------ 75 | 76 | * Improve error handling of modules/containers not in the graph. 77 | * Return the exit code correctly. 78 | * Run lint-imports on Import Linter itself. 79 | * Allow single values in ListField. 80 | 81 | 1.0a3 (2019-03-27) 82 | ------------------ 83 | 84 | * Include the ability to build the graph with external packages. 85 | 86 | 1.0a2 (2019-03-26) 87 | ------------------ 88 | 89 | * First usable alpha release. 90 | 91 | 1.0a1 (2019-01-27) 92 | ------------------ 93 | 94 | * Release blank project on PyPI. 95 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Contributing 3 | ============ 4 | 5 | Contributions are welcome, and they are greatly appreciated! Every 6 | little bit helps, and credit will always be given. 7 | 8 | Bug reports 9 | =========== 10 | 11 | When `reporting a bug `_ please include: 12 | 13 | * Your operating system name and version. 14 | * Any details about your local setup that might be helpful in troubleshooting. 15 | * Detailed steps to reproduce the bug. 16 | 17 | Documentation improvements 18 | ========================== 19 | 20 | Nameless could always use more documentation, whether as part of the 21 | official Nameless docs, in docstrings, or even on the web in blog posts, 22 | articles, and such. 23 | 24 | Feature requests and feedback 25 | ============================= 26 | 27 | The best way to send feedback is to file an issue at https://github.com/seddonym/import-linter/issues. 28 | 29 | If you are proposing a feature: 30 | 31 | * Explain in detail how it would work. 32 | * Keep the scope as narrow as possible, to make it easier to implement. 33 | * Remember that this is a volunteer-driven project, and that code contributions are welcome :) 34 | 35 | Development 36 | =========== 37 | 38 | To set up `import-linter` for local development: 39 | 40 | 1. Fork `import-linter `_ 41 | (look for the "Fork" button). 42 | 2. Clone your fork locally:: 43 | 44 | git clone git@github.com:your_name_here/import-linter.git 45 | 46 | 3. Create a branch for local development:: 47 | 48 | git checkout -b name-of-your-bugfix-or-feature 49 | 50 | Now you can make your changes locally. 51 | 52 | 4. When you're done making changes, run all the checks, doc builder and spell checker with `tox `_ one command:: 53 | 54 | tox 55 | 56 | 5. Commit your changes and push your branch to GitHub:: 57 | 58 | git add . 59 | git commit -m "Your detailed description of your changes." 60 | git push origin name-of-your-bugfix-or-feature 61 | 62 | 6. Submit a pull request through the GitHub website. 63 | 64 | Pull Request Guidelines 65 | ----------------------- 66 | 67 | If you need some code review or feedback while you're developing the code just make the pull request. 68 | 69 | For merging, you should: 70 | 71 | 1. Include passing tests (run ``tox``) [1]_. 72 | 2. Update documentation when there's new API, functionality etc. 73 | 3. Add a note to ``CHANGELOG.rst`` about the changes. 74 | 4. Add yourself to ``AUTHORS.rst``. 75 | 76 | .. [1] If you don't have all the necessary python versions available locally you can rely on Travis - it will 77 | `run the tests `_ for each change you add in the pull request. 78 | 79 | It will be slower though ... 80 | 81 | Tips 82 | ---- 83 | 84 | To run a subset of tests:: 85 | 86 | tox -e envname -- pytest -k test_myfeature 87 | 88 | To run all the test environments in *parallel* (you need to ``pip install detox``):: 89 | 90 | detox -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ============= 2 | Import Linter 3 | ============= 4 | 5 | .. image:: https://img.shields.io/pypi/v/import-linter.svg 6 | :target: https://pypi.org/project/import-linter 7 | 8 | .. image:: https://img.shields.io/pypi/pyversions/import-linter.svg 9 | :alt: Python versions 10 | :target: https://pypi.org/project/import-linter/ 11 | 12 | .. image:: https://api.travis-ci.com/seddonym/import-linter.svg?branch=master 13 | :target: https://travis-ci.com/seddonym/import-linter 14 | 15 | 16 | Import Linter allows you to define and enforce rules for the imports within and between Python packages. 17 | 18 | * Free software: BSD license 19 | * Documentation: https://import-linter.readthedocs.io. 20 | 21 | Overview 22 | -------- 23 | 24 | Import Linter is a command line tool to check that you are following a self-imposed 25 | architecture within your Python project. It does this by analysing the imports between all the modules in one 26 | or more Python packages, and compares this against a set of rules that you provide in a configuration file. 27 | 28 | The configuration file contains one or more 'contracts'. Each contract has a specific 29 | type, which determines the sort of rules it will apply. For example, the ``forbidden`` 30 | contract type allows you to check that certain modules or packages are not imported by 31 | parts of your project. 32 | 33 | Import Linter is particularly useful if you are working on a complex codebase within a team, 34 | when you want to enforce a particular architectural style. In this case you can add 35 | Import Linter to your deployment pipeline, so that any code that does not follow 36 | the architecture will fail tests. 37 | 38 | If there isn't a built in contract type that fits your desired architecture, you can define 39 | a custom one. 40 | 41 | Quick start 42 | ----------- 43 | 44 | Install Import Linter:: 45 | 46 | pip install import-linter 47 | 48 | Decide on the dependency flows you wish to check. In this example, we have 49 | decided to make sure that ``myproject.foo`` has dependencies on neither 50 | ``myproject.bar`` nor ``myproject.baz``, so we will use the ``forbidden`` contract type. 51 | 52 | Create an ``.importlinter`` file in the root of your project to define your contract(s). In this case: 53 | 54 | .. code-block:: ini 55 | 56 | [importlinter] 57 | root_package = myproject 58 | 59 | [importlinter:contract:1] 60 | name=Foo doesn't import bar or baz 61 | type=forbidden 62 | source_modules= 63 | myproject.foo 64 | forbidden_modules= 65 | myproject.bar 66 | myproject.baz 67 | 68 | Now, from your project root, run:: 69 | 70 | lint-imports 71 | 72 | If your code violates the contract, you will see an error message something like this: 73 | 74 | .. code-block:: text 75 | 76 | ============= 77 | Import Linter 78 | ============= 79 | 80 | --------- 81 | Contracts 82 | --------- 83 | 84 | Analyzed 23 files, 44 dependencies. 85 | ----------------------------------- 86 | 87 | Foo doesn't import bar or baz BROKEN 88 | 89 | Contracts: 1 broken. 90 | 91 | 92 | ---------------- 93 | Broken contracts 94 | ---------------- 95 | 96 | Foo doesn't import bar or baz 97 | ----------------------------- 98 | 99 | myproject.foo is not allowed to import myproject.bar: 100 | 101 | - myproject.foo.blue -> myproject.utils.red (l.16) 102 | myproject.utils.red -> myproject.utils.green (l.1) 103 | myproject.utils.green -> myproject.bar.yellow (l.3) 104 | -------------------------------------------------------------------------------- /tests/unit/domain/test_fields.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Optional, Type 2 | 3 | import pytest 4 | 5 | from importlinter.domain.fields import ( 6 | DirectImportField, 7 | Field, 8 | ListField, 9 | ModuleField, 10 | SetField, 11 | StringField, 12 | ValidationError, 13 | ) 14 | from importlinter.domain.imports import DirectImport, Module 15 | 16 | 17 | class BaseFieldTest: 18 | field_class: Optional[Type[Field]] = None 19 | field_kwargs: Dict[str, Any] = {} 20 | 21 | def test_field(self, raw_data, expected_value): 22 | field = self.field_class(**self.field_kwargs) 23 | 24 | if isinstance(expected_value, ValidationError): 25 | try: 26 | field.parse(raw_data) == expected_value 27 | except ValidationError as e: 28 | assert e.message == expected_value.message 29 | else: 30 | assert field.parse(raw_data) == expected_value 31 | 32 | 33 | @pytest.mark.parametrize( 34 | "raw_data, expected_value", 35 | ( 36 | ("Hello, world!", "Hello, world!"), 37 | ( 38 | ["one", "two", "three"], 39 | ValidationError("Expected a single value, got multiple values."), 40 | ), 41 | ), 42 | ) 43 | class TestStringField(BaseFieldTest): 44 | field_class = StringField 45 | 46 | 47 | @pytest.mark.parametrize( 48 | "raw_data, expected_value", 49 | ( 50 | ("mypackage.foo.bar", Module("mypackage.foo.bar")), 51 | ( 52 | ["one", "two", "three"], 53 | ValidationError("Expected a single value, got multiple values."), 54 | ), 55 | # TODO - test that it belongs in the root package. 56 | ), 57 | ) 58 | class TestModuleField(BaseFieldTest): 59 | field_class = ModuleField 60 | 61 | 62 | @pytest.mark.parametrize( 63 | "raw_data, expected_value", 64 | ( 65 | ( 66 | "mypackage.foo -> mypackage.bar", 67 | DirectImport(importer=Module("mypackage.foo"), imported=Module("mypackage.bar")), 68 | ), 69 | ( 70 | ["one", "two", "three"], 71 | ValidationError("Expected a single value, got multiple values."), 72 | ), 73 | ( 74 | "mypackage.foo - mypackage.bar", 75 | ValidationError('Must be in the form "package.importer -> package.imported".'), 76 | ), 77 | ( 78 | "my-package.foo -> my-package.bar", 79 | DirectImport(importer=Module("my-package.foo"), imported=Module("my-package.bar")), 80 | ), 81 | ), 82 | ) 83 | class TestDirectImportField(BaseFieldTest): 84 | field_class = DirectImportField 85 | 86 | 87 | @pytest.mark.parametrize( 88 | "raw_data, expected_value", 89 | ( 90 | (["mypackage.foo", "mypackage.bar"], [Module("mypackage.foo"), Module("mypackage.bar")]), 91 | (["mypackage.foo", "mypackage.foo"], [Module("mypackage.foo"), Module("mypackage.foo")]), 92 | ("singlevalue", [Module("singlevalue")]), 93 | ), 94 | ) 95 | class TestListField(BaseFieldTest): 96 | field_class = ListField 97 | field_kwargs = dict(subfield=ModuleField()) 98 | 99 | 100 | @pytest.mark.parametrize( 101 | "raw_data, expected_value", 102 | ( 103 | (["mypackage.foo", "mypackage.bar"], {Module("mypackage.foo"), Module("mypackage.bar")}), 104 | (["mypackage.foo", "mypackage.foo"], {Module("mypackage.foo")}), 105 | ("singlevalue", {Module("singlevalue")}), 106 | ), 107 | ) 108 | class TestSetField(BaseFieldTest): 109 | field_class = SetField 110 | field_kwargs = dict(subfield=ModuleField()) 111 | -------------------------------------------------------------------------------- /src/importlinter/domain/contract.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from typing import Any, Dict, List, Optional, Type 3 | 4 | from . import fields 5 | from .ports.graph import ImportGraph 6 | 7 | 8 | class Contract(abc.ABC): 9 | def __init__( 10 | self, name: str, session_options: Dict[str, Any], contract_options: Dict[str, Any] 11 | ) -> None: 12 | self.name = name 13 | self.session_options = session_options 14 | self.contract_options = contract_options 15 | 16 | self._populate_fields() 17 | 18 | def _populate_fields(self) -> None: 19 | """ 20 | Populate the contract's fields from the contract options. 21 | 22 | Raises: 23 | InvalidContractOptions if the contract options could not be matched to the fields. 24 | """ 25 | errors = {} 26 | for field_name in self.__class__._get_field_names(): 27 | field = self.__class__._get_field(field_name) 28 | 29 | try: 30 | raw_data = self.contract_options[field_name] 31 | except KeyError: 32 | if field.required: 33 | errors[field_name] = "This is a required field." 34 | else: 35 | setattr(self, field_name, None) 36 | continue 37 | 38 | try: 39 | clean_data = field.parse(raw_data) 40 | except fields.ValidationError as e: 41 | errors[field_name] = str(e) 42 | continue 43 | setattr(self, field_name, clean_data) 44 | 45 | if errors: 46 | raise InvalidContractOptions(errors) 47 | 48 | @classmethod 49 | def _get_field_names(cls) -> List[str]: 50 | """ 51 | Returns: 52 | The names of all the fields on this contract class. 53 | """ 54 | return [name for name, attr in cls.__dict__.items() if isinstance(attr, fields.Field)] 55 | 56 | @classmethod 57 | def _get_field(cls, field_name: str) -> fields.Field: 58 | return getattr(cls, field_name) 59 | 60 | @abc.abstractmethod 61 | def check(self, graph: ImportGraph) -> "ContractCheck": 62 | raise NotImplementedError 63 | 64 | @abc.abstractmethod 65 | def render_broken_contract(self, check: "ContractCheck") -> None: 66 | raise NotImplementedError 67 | 68 | 69 | class InvalidContractOptions(Exception): 70 | """ 71 | Exception if a contract itself is invalid. 72 | 73 | N. B. This is not the same thing as if a contract is violated; this is raised if the contract 74 | is not suitable for checking in the first place. 75 | """ 76 | 77 | def __init__(self, errors: Dict[str, str]) -> None: 78 | self.errors = errors 79 | 80 | 81 | class ContractCheck: 82 | """ 83 | Data class to store the result of checking a contract. 84 | """ 85 | 86 | def __init__(self, kept: bool, metadata: Optional[Dict[str, Any]] = None) -> None: 87 | self.kept = kept 88 | self.metadata = metadata if metadata else {} 89 | 90 | 91 | class NoSuchContractType(Exception): 92 | pass 93 | 94 | 95 | class ContractRegistry: 96 | def __init__(self): 97 | self._classes_by_name = {} 98 | 99 | def register(self, contract_class: Type[Contract], name: str) -> None: 100 | self._classes_by_name[name] = contract_class 101 | 102 | def get_contract_class(self, name: str) -> Type[Contract]: 103 | try: 104 | return self._classes_by_name[name] 105 | except KeyError: 106 | raise NoSuchContractType(name) 107 | 108 | 109 | registry = ContractRegistry() 110 | -------------------------------------------------------------------------------- /tests/helpers/contracts.py: -------------------------------------------------------------------------------- 1 | from importlinter.application import output 2 | from importlinter.domain import fields 3 | from importlinter.domain.contract import Contract, ContractCheck 4 | from importlinter.domain.ports.graph import ImportGraph 5 | 6 | 7 | class AlwaysPassesContract(Contract): 8 | def check(self, graph: ImportGraph) -> ContractCheck: 9 | return ContractCheck(kept=True) 10 | 11 | def render_broken_contract(self, check: "ContractCheck") -> None: 12 | # No need to implement, will never fail. 13 | raise NotImplementedError # pragma: nocover 14 | 15 | 16 | class AlwaysFailsContract(Contract): 17 | def check(self, graph: ImportGraph) -> ContractCheck: 18 | return ContractCheck(kept=False) 19 | 20 | def render_broken_contract(self, check: "ContractCheck") -> None: 21 | output.print("This contract will always fail.") 22 | 23 | 24 | class ForbiddenImportContract(Contract): 25 | """ 26 | Contract that defines a single forbidden import between 27 | two modules. 28 | """ 29 | 30 | importer = fields.ModuleField() 31 | imported = fields.ModuleField() 32 | 33 | def check(self, graph: ImportGraph) -> ContractCheck: 34 | forbidden_import_details = graph.get_import_details( 35 | importer=self.importer.name, imported=self.imported.name # type: ignore 36 | ) 37 | import_exists = bool(forbidden_import_details) 38 | 39 | return ContractCheck( 40 | kept=not import_exists, metadata={"forbidden_import_details": forbidden_import_details} 41 | ) 42 | 43 | def render_broken_contract(self, check: "ContractCheck") -> None: 44 | output.print(f"{self.importer} is not allowed to import {self.imported}:") 45 | output.print() 46 | for details in check.metadata["forbidden_import_details"]: 47 | line_number = details["line_number"] 48 | line_contents = details["line_contents"] 49 | output.indent_cursor() 50 | output.print(f"{self.importer}:{line_number}: {line_contents}") 51 | 52 | 53 | class FieldsContract(Contract): 54 | single_field = fields.StringField() 55 | multiple_field = fields.ListField(subfield=fields.StringField()) 56 | import_field = fields.DirectImportField() 57 | required_field = fields.StringField() # Fields are required by default. 58 | 59 | def check(self, graph: ImportGraph) -> ContractCheck: 60 | raise NotImplementedError 61 | 62 | def render_broken_contract(self, check: "ContractCheck") -> None: 63 | raise NotImplementedError 64 | 65 | 66 | class MutationCheckContract(Contract): 67 | """ 68 | Contract for checking that contracts can't mutate the graph for other contracts. 69 | 70 | It checks that there are a certain number of modules and imports in the graph, then adds 71 | an extra import containing two new modules. We can check two such contracts and the second one 72 | will fail, if the graph gets mutated by other contracts. 73 | """ 74 | 75 | number_of_modules = fields.StringField() 76 | number_of_imports = fields.StringField() 77 | 78 | def check(self, graph: ImportGraph) -> ContractCheck: 79 | number_of_modules: int = int(self.number_of_modules) # type: ignore 80 | number_of_imports: int = int(self.number_of_imports) # type: ignore 81 | if not all( 82 | [number_of_modules == len(graph.modules), number_of_imports == graph.count_imports()] 83 | ): 84 | raise RuntimeError("Contract was mutated.") 85 | 86 | # Mutate graph. 87 | graph.add_import(importer="added-by-contract-1", imported="added-by-contract-2") 88 | return ContractCheck(kept=True) 89 | 90 | def render_broken_contract(self, check: "ContractCheck") -> None: 91 | raise NotImplementedError 92 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | ===== 2 | Usage 3 | ===== 4 | 5 | Configuration file location 6 | --------------------------- 7 | 8 | Before running the linter, you need to supply configuration in a file, in INI format. Import Linter will look in the 9 | current directory for one of the following files: 10 | 11 | - ``setup.cfg`` 12 | - ``.importlinter`` 13 | 14 | (Different filenames / locations can be specified as a command line argument, see below.) 15 | 16 | Top level configuration 17 | ----------------------- 18 | 19 | Your file must contain an ``importlinter`` section providing top-level (i.e. non-contract based) configuration: 20 | 21 | .. code-block:: ini 22 | 23 | [importlinter] 24 | root_package = mypackage 25 | # Optional: 26 | include_external_packages = True 27 | 28 | Or, with multiple root packages: 29 | 30 | .. code-block:: ini 31 | 32 | [importlinter] 33 | root_packages= 34 | packageone 35 | packagetwo 36 | # Optional: 37 | include_external_packages = True 38 | 39 | **Options:** 40 | 41 | - ``root_package``: 42 | The name of the top-level Python package to validate. This package must be importable: usually this 43 | means it is has been installed using pip, or it's in the current directory. (Either this or ``root_packages`` is required.) 44 | - ``root_packages``: 45 | The names of the top-level Python packages to validate. This should be used in place of ``root_package`` if you want 46 | to analyse the imports of multiple packages. (Either this or ``root_package`` is required.) 47 | - ``include_external_packages``: 48 | Whether to include external packages when building the import graph. Unlike root packages, external packages are 49 | *not* statically analyzed, so no imports from external packages will be checked. However, imports *of* external 50 | packages will be available for checking. Not every contract type uses this. 51 | For more information, see `the Grimp build_graph documentation`_. (Optional.) 52 | 53 | .. _the Grimp build_graph documentation: https://grimp.readthedocs.io/en/latest/usage.html#grimp.build_graph 54 | 55 | Contracts 56 | --------- 57 | 58 | Additionally, you will want to include one or more contract configurations. These take the following form: 59 | 60 | .. code-block:: ini 61 | 62 | [importlinter:contract:1] 63 | name = Contract One 64 | type = some_contract_type 65 | (additional options) 66 | 67 | [importlinter:contract:2] 68 | name = Contract Two 69 | type = another_contract_type 70 | (additional options) 71 | 72 | Notice each contract has its own INI section, which begins ``importlinter:contract:`` and ends in an 73 | arbitrary, unique code (in this example, the codes are ``1`` and ``2``). These codes are purely 74 | to adhere to the INI format, which does not allow duplicate section names. 75 | 76 | Every contract will always have the following key/value pairs: 77 | 78 | - ``name``: A human-readable name for the contract. 79 | - ``type``: The type of contract to use (see :doc:`contract_types`.) 80 | 81 | Each contract type defines additional options that you supply here. 82 | 83 | Running the linter 84 | ------------------ 85 | 86 | Import Linter provides a single command: ``lint-imports``. 87 | 88 | Running this will check that your project adheres to the contracts you've defined. 89 | 90 | **Arguments:** 91 | 92 | - ``--config``: 93 | The configuration file to use. If not supplied, Import Linter will look for ``setup.cfg`` 94 | or ``.importlinter`` in the current directory. (Optional.) 95 | 96 | **Default usage:** 97 | 98 | .. code-block:: text 99 | 100 | lint-imports 101 | 102 | **Using a different filename or location:** 103 | 104 | .. code-block:: text 105 | 106 | lint-imports --config path/to/alternative-config.ini 107 | -------------------------------------------------------------------------------- /src/importlinter/domain/fields.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from typing import Generic, Iterable, List, Set, TypeVar, Union 3 | 4 | from importlinter.domain.imports import DirectImport, Module 5 | 6 | FieldValue = TypeVar("FieldValue") 7 | 8 | 9 | class ValidationError(Exception): 10 | def __init__(self, message: str) -> None: 11 | self.message = message 12 | 13 | 14 | class Field(Generic[FieldValue], abc.ABC): 15 | """ 16 | Base class for containers for some data on a Contract. 17 | 18 | Designed to be subclassed, Fields should override the ``parse`` method. 19 | """ 20 | 21 | def __init__(self, required: bool = True) -> None: 22 | self.required = required 23 | 24 | @abc.abstractmethod 25 | def parse(self, raw_data: Union[str, List[str]]) -> FieldValue: 26 | """ 27 | Given some raw data supplied by a user, return some clean data. 28 | 29 | Raises: 30 | ValidationError if the data is invalid. 31 | """ 32 | raise NotImplementedError 33 | 34 | 35 | class StringField(Field): 36 | """ 37 | A field for single values of strings. 38 | """ 39 | 40 | def parse(self, raw_data: Union[str, List]) -> str: 41 | if isinstance(raw_data, list): 42 | raise ValidationError("Expected a single value, got multiple values.") 43 | return str(raw_data) 44 | 45 | 46 | class BaseMultipleValueField(Field): 47 | """ 48 | An abstract field for multiple values of any type. 49 | 50 | Arguments: 51 | - subfield: An instance of a single-value Field. Each item in the iterable will be 52 | the return value of this subfield. 53 | 54 | """ 55 | 56 | def __init__(self, subfield: Field, *args, **kwargs) -> None: 57 | super().__init__(*args, **kwargs) 58 | self.subfield = subfield 59 | 60 | @abc.abstractmethod 61 | def parse(self, raw_data: Union[str, List]) -> Iterable[FieldValue]: 62 | if isinstance(raw_data, tuple): 63 | raw_data = list(raw_data) 64 | if not isinstance(raw_data, list): 65 | raw_data = [raw_data] # Single values should just be treated as a single item list. 66 | clean_list = [] 67 | for raw_line in raw_data: 68 | clean_list.append(self.subfield.parse(raw_line)) 69 | return clean_list 70 | 71 | 72 | class ListField(BaseMultipleValueField): 73 | """ 74 | A field for multiple values of any type. 75 | 76 | Fields values are returned in list sorted by parsing order. 77 | 78 | Usage: 79 | 80 | field = ListField(subfield=AnotherField()) 81 | """ 82 | 83 | def parse(self, raw_data: Union[str, List]) -> List[FieldValue]: 84 | return list(super().parse(raw_data)) 85 | 86 | 87 | class SetField(BaseMultipleValueField): 88 | """ 89 | A field for multiple, unique values of any type. 90 | 91 | Fields values are returned inordered in set. 92 | 93 | Usage: 94 | 95 | field = SetField(subfield=AnotherField()) 96 | 97 | """ 98 | 99 | def parse(self, raw_data: Union[str, List]) -> Set[FieldValue]: 100 | return set(super().parse(raw_data)) 101 | 102 | 103 | class ModuleField(Field): 104 | """ 105 | A field for Modules. 106 | """ 107 | 108 | def parse(self, raw_data: Union[str, List]) -> Module: 109 | return Module(StringField().parse(raw_data)) 110 | 111 | 112 | class DirectImportField(Field): 113 | """ 114 | A field for DirectImports. 115 | 116 | Expects raw data in the form: "mypackage.foo.importer -> mypackage.bar.imported". 117 | """ 118 | 119 | def parse(self, raw_data: Union[str, List]) -> DirectImport: 120 | string = StringField().parse(raw_data) 121 | importer, _, imported = string.partition(" -> ") 122 | if not (importer and imported): 123 | raise ValidationError('Must be in the form "package.importer -> package.imported".') 124 | return DirectImport(importer=Module(importer), imported=Module(imported)) 125 | -------------------------------------------------------------------------------- /src/importlinter/application/output.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from .app_config import settings 4 | from .ports.printing import Printer 5 | 6 | ERROR = "error" 7 | SUCCESS = "success" 8 | COLORS = {ERROR: "red", SUCCESS: "green"} 9 | 10 | HEADING_LEVEL_ONE = 1 11 | HEADING_LEVEL_TWO = 2 12 | HEADING_LEVEL_THREE = 3 13 | 14 | HEADING_MAP = { 15 | HEADING_LEVEL_ONE: ("=", True), 16 | HEADING_LEVEL_TWO: ("-", True), 17 | HEADING_LEVEL_THREE: ("-", False), 18 | } 19 | 20 | INDENT_SIZE = 4 21 | 22 | 23 | class Output: 24 | """ 25 | A class for writing output to the console. 26 | 27 | This should always be used instead of the built in print function, as it uses the Printer 28 | port. This makes it easier for tests to swap in a different Printer so we can more easily 29 | assert what would be written to the console. 30 | """ 31 | 32 | def print( 33 | self, text: str = "", bold: bool = False, color: Optional[str] = None, newline: bool = True 34 | ) -> None: 35 | """ 36 | Print a line. 37 | 38 | Args: 39 | text (str): The text to print. 40 | bold (bool, optional): Whether to style the text in bold. (Default False.) 41 | color (str, optional): The color of text to use. One of the values of the 42 | COLORS dictionary. 43 | newline (bool, optional): Whether to include a new line after the text. 44 | (Default True.) 45 | """ 46 | self.printer.print(text, bold, color, newline) 47 | 48 | def indent_cursor(self): 49 | """ 50 | Indents the cursor ready to print a line. 51 | """ 52 | self.printer.print(" " * INDENT_SIZE, newline=False) 53 | 54 | def new_line(self): 55 | """ 56 | Print a blank line. 57 | """ 58 | self.printer.print() 59 | 60 | def print_heading(self, text: str, level: int, style: Optional[str] = None) -> None: 61 | """ 62 | Prints the supplied text to the console, formatted as a heading. 63 | 64 | Args: 65 | text (str): The text to format as a heading. 66 | level (int): The level of heading to display (one of the keys 67 | of HEADING_MAP). 68 | style (str, optional): ERROR or SUCCESS style to apply (default None). 69 | Usage: 70 | 71 | ClickPrinter.print_heading('Foo', ClickPrinter.HEADING_LEVEL_ONE) 72 | """ 73 | # Setup styling variables. 74 | is_bold = True 75 | color = COLORS[style] if style else None 76 | line_char, show_line_above = HEADING_MAP[level] 77 | heading_line = line_char * len(text) 78 | 79 | # Print lines. 80 | if show_line_above: 81 | self.printer.print(heading_line, bold=is_bold, color=color) 82 | self.printer.print(text, bold=is_bold, color=color) 83 | self.printer.print(heading_line, bold=is_bold, color=color) 84 | self.printer.print() 85 | 86 | def print_success(self, text, bold=True): 87 | """ 88 | Prints a line to the console, formatted as a success. 89 | """ 90 | self.printer.print(text, color=COLORS[SUCCESS], bold=bold) 91 | 92 | def print_error(self, text, bold=True): 93 | """ 94 | Prints a line to the console, formatted as an error. 95 | """ 96 | self.printer.print(text, color=COLORS[ERROR], bold=bold) 97 | 98 | @property 99 | def printer(self) -> Printer: 100 | return settings.PRINTER 101 | 102 | 103 | # Use prebound method pattern to provide a simple API. 104 | # https://python-patterns.guide/python/prebound-methods/ 105 | _instance = Output() 106 | print = _instance.print 107 | indent_cursor = _instance.indent_cursor 108 | new_line = _instance.new_line 109 | print_success = _instance.print_success 110 | print_heading = _instance.print_heading 111 | print_error = _instance.print_error 112 | -------------------------------------------------------------------------------- /tests/unit/adapters/test_user_options.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from importlinter.adapters.user_options import IniFileUserOptionReader 4 | from importlinter.application.app_config import settings 5 | from importlinter.application.user_options import UserOptions 6 | from tests.adapters.filesystem import FakeFileSystem 7 | 8 | 9 | @pytest.mark.parametrize("filename", ("setup.cfg", ".importlinter")) 10 | @pytest.mark.parametrize( 11 | "contents, expected_options", 12 | ( 13 | ( 14 | """ 15 | [something] 16 | # This file has no import-linter section. 17 | foo = 1 18 | bar = hello 19 | """, 20 | None, 21 | ), 22 | ( 23 | """ 24 | [something] 25 | foo = 1 26 | bar = hello 27 | 28 | [importlinter] 29 | foo = hello 30 | bar = 999 31 | """, 32 | UserOptions(session_options={"foo": "hello", "bar": "999"}, contracts_options=[]), 33 | ), 34 | ( 35 | """ 36 | [importlinter] 37 | foo = hello 38 | 39 | [importlinter:contract:contract-one] 40 | name=Contract One 41 | key=value 42 | multiple_values= 43 | one 44 | two 45 | three 46 | foo.one -> foo.two 47 | 48 | [importlinter:contract:contract-two]; 49 | name=Contract Two 50 | baz=3 51 | """, 52 | UserOptions( 53 | session_options={"foo": "hello"}, 54 | contracts_options=[ 55 | { 56 | "name": "Contract One", 57 | "key": "value", 58 | "multiple_values": ["one", "two", "three", "foo.one -> foo.two"], 59 | }, 60 | {"name": "Contract Two", "baz": "3"}, 61 | ], 62 | ), 63 | ), 64 | ), 65 | ) 66 | def test_ini_file_reader(filename, contents, expected_options): 67 | settings.configure( 68 | FILE_SYSTEM=FakeFileSystem( 69 | content_map={f"/path/to/folder/{filename}": contents}, 70 | working_directory="/path/to/folder", 71 | ) 72 | ) 73 | 74 | options = IniFileUserOptionReader().read_options() 75 | 76 | assert expected_options == options 77 | 78 | 79 | @pytest.mark.parametrize( 80 | "passed_filename, expected_foo_value", 81 | ( 82 | (None, "green"), 83 | ("custom.ini", "blue"), 84 | ("deeper/custom.ini", "purple"), 85 | ("nonexistent.ini", FileNotFoundError()), 86 | ), 87 | ) 88 | def test_respects_passed_filename(passed_filename, expected_foo_value): 89 | settings.configure( 90 | FILE_SYSTEM=FakeFileSystem( 91 | content_map={ 92 | "/path/to/folder/.importlinter": """ 93 | [importlinter] 94 | foo = green 95 | """, 96 | "/path/to/folder/custom.ini": """ 97 | [importlinter] 98 | foo = blue 99 | """, 100 | "/path/to/folder/deeper/custom.ini": """ 101 | [importlinter] 102 | foo = purple 103 | """, 104 | }, 105 | working_directory="/path/to/folder", 106 | ) 107 | ) 108 | expected_options = UserOptions( 109 | session_options={"foo": expected_foo_value}, contracts_options=[] 110 | ) 111 | 112 | reader = IniFileUserOptionReader() 113 | 114 | if isinstance(expected_foo_value, Exception): 115 | with pytest.raises( 116 | expected_foo_value.__class__, match=f"Could not find {passed_filename}." 117 | ): 118 | reader.read_options(config_filename=passed_filename) 119 | else: 120 | options = reader.read_options(config_filename=passed_filename) 121 | assert expected_options == options 122 | -------------------------------------------------------------------------------- /docs/custom_contract_types.rst: -------------------------------------------------------------------------------- 1 | ===================== 2 | Custom contract types 3 | ===================== 4 | 5 | If none of the built in contract types serve your needs, you can define a custom contract type. The steps to do 6 | this are: 7 | 8 | 1. Somewhere in your Python path, create a module that implements a ``Contract`` class for your supplied type. 9 | 2. Register the contract type in your configuration file. 10 | 3. Define one or more contracts of your custom type, also in your configuration file. 11 | 12 | Step one: implementing a Contract class 13 | --------------------------------------- 14 | 15 | You define a custom contract type by subclassing ``importlinter.Contract`` and implementing the 16 | following methods: 17 | 18 | - ``check(graph)``: 19 | Given an import graph of your project, return a ``ContractCheck`` describing whether the contract was adhered to. 20 | 21 | Arguments: 22 | - ``graph``: a Grimp ``ImportGraph`` of your project, which can be used to inspect / analyse any dependencies. 23 | For full details of how to use this, see the `Grimp documentation`_. 24 | 25 | Returns: 26 | - An ``importlinter.ContractCheck`` instance. This is a simple dataclass with two attributes, 27 | ``kept`` (a boolean indicating if the contract was kept) and ``metadata`` (a dictionary of data about the 28 | check). The metadata can contain anything you want, as it is only used in the ``render_broken_contract`` 29 | method that you also define in this class. 30 | 31 | - ``render_broken_contract(check)``: 32 | 33 | Renders the results of a broken contract check. For output, this should use the 34 | ``importlinter.output`` module. 35 | 36 | Arguments: 37 | - ``check``: the ``ContractCheck`` instance returned by the ``check`` method above. 38 | 39 | **Contract fields** 40 | 41 | A contract will usually need some further configuration. This can be done using *fields*. For an example, 42 | see ``importlinter.contracts.layers``. 43 | 44 | **Example custom contract** 45 | 46 | .. code-block:: python 47 | 48 | from importlinter import Contract, ContractCheck, fields, output 49 | 50 | 51 | class ForbiddenImportContract(Contract): 52 | """ 53 | Contract that defines a single forbidden import between 54 | two modules. 55 | """ 56 | importer = fields.StringField() 57 | imported = fields.StringField() 58 | 59 | def check(self, graph): 60 | forbidden_import_details = graph.get_import_details( 61 | importer=self.importer, 62 | imported=self.imported, 63 | ) 64 | import_exists = bool(forbidden_import_details) 65 | 66 | return ContractCheck( 67 | kept=not import_exists, 68 | metadata={ 69 | 'forbidden_import_details': forbidden_import_details, 70 | } 71 | ) 72 | 73 | def render_broken_contract(self, check): 74 | output.print_error( 75 | f'{self.importer} is not allowed to import {self.imported}:', 76 | bold=True, 77 | ) 78 | output.new_line() 79 | for details in check.metadata['forbidden_import_details']: 80 | line_number = details['line_number'] 81 | line_contents = details['line_contents'] 82 | output.indent_cursor() 83 | output.print_error(f'{self.importer}:{line_number}: {line_contents}') 84 | 85 | 86 | Step two: register the contract type 87 | ------------------------------------ 88 | 89 | In the ``[importlinter]`` section of your configuration file, include a list of ``contract_types`` that map type names 90 | onto the Python path of your custom class: 91 | 92 | .. code-block:: ini 93 | 94 | [importlinter] 95 | root_package_name = mypackage 96 | contract_types = 97 | forbidden_import: somepackage.contracts.ForbiddenImportContract 98 | 99 | Step three: define your contracts 100 | --------------------------------- 101 | 102 | You may now use the type name defined in the previous step to define a contract: 103 | 104 | .. code-block:: ini 105 | 106 | [importlinter:contract:1] 107 | name = My custom contract 108 | type = forbidden_import 109 | importer = mypackage.foo 110 | imported = mypackage.bar 111 | 112 | .. _Grimp documentation: https://grimp.readthedocs.io 113 | -------------------------------------------------------------------------------- /src/importlinter/contracts/independence.py: -------------------------------------------------------------------------------- 1 | from itertools import permutations 2 | 3 | from importlinter.application import output 4 | from importlinter.domain import fields, helpers 5 | from importlinter.domain.contract import Contract, ContractCheck 6 | from importlinter.domain.ports.graph import ImportGraph 7 | 8 | 9 | class IndependenceContract(Contract): 10 | """ 11 | Independence contracts check that a set of modules do not depend on each other. 12 | 13 | They do this by checking that there are no imports in any direction between the modules, 14 | even indirectly. 15 | 16 | Configuration options: 17 | 18 | - modules: A list of Modules that should be independent from each other. 19 | - ignore_imports: A set of DirectImports. These imports will be ignored: if the import 20 | would cause a contract to be broken, adding it to the set will cause 21 | the contract be kept instead. (Optional.) 22 | """ 23 | 24 | type_name = "independence" 25 | 26 | modules = fields.ListField(subfield=fields.ModuleField()) 27 | ignore_imports = fields.SetField(subfield=fields.DirectImportField(), required=False) 28 | 29 | def check(self, graph: ImportGraph) -> ContractCheck: 30 | is_kept = True 31 | invalid_chains = [] 32 | 33 | helpers.pop_imports( 34 | graph, self.ignore_imports if self.ignore_imports else [] # type: ignore 35 | ) 36 | 37 | self._check_all_modules_exist_in_graph(graph) 38 | 39 | for subpackage_1, subpackage_2 in permutations(self.modules, r=2): # type: ignore 40 | subpackage_chain_data = { 41 | "upstream_module": subpackage_2.name, 42 | "downstream_module": subpackage_1.name, 43 | "chains": [], 44 | } 45 | assert isinstance(subpackage_chain_data["chains"], list) # For type checker. 46 | chains = graph.find_shortest_chains( 47 | importer=subpackage_1.name, imported=subpackage_2.name 48 | ) 49 | if chains: 50 | is_kept = False 51 | for chain in chains: 52 | chain_data = [] 53 | for importer, imported in [ 54 | (chain[i], chain[i + 1]) for i in range(len(chain) - 1) 55 | ]: 56 | import_details = graph.get_import_details( 57 | importer=importer, imported=imported 58 | ) 59 | line_numbers = tuple(j["line_number"] for j in import_details) 60 | chain_data.append( 61 | { 62 | "importer": importer, 63 | "imported": imported, 64 | "line_numbers": line_numbers, 65 | } 66 | ) 67 | subpackage_chain_data["chains"].append(chain_data) 68 | if subpackage_chain_data["chains"]: 69 | invalid_chains.append(subpackage_chain_data) 70 | 71 | return ContractCheck(kept=is_kept, metadata={"invalid_chains": invalid_chains}) 72 | 73 | def render_broken_contract(self, check: "ContractCheck") -> None: 74 | count = 0 75 | for chains_data in check.metadata["invalid_chains"]: 76 | downstream, upstream = ( 77 | chains_data["downstream_module"], 78 | chains_data["upstream_module"], 79 | ) 80 | output.print_error(f"{downstream} is not allowed to import {upstream}:") 81 | output.new_line() 82 | count += len(chains_data["chains"]) 83 | for chain in chains_data["chains"]: 84 | first_line = True 85 | for direct_import in chain: 86 | importer, imported = (direct_import["importer"], direct_import["imported"]) 87 | line_numbers = ", ".join(f"l.{n}" for n in direct_import["line_numbers"]) 88 | import_string = f"{importer} -> {imported} ({line_numbers})" 89 | if first_line: 90 | output.print_error(f"- {import_string}", bold=False) 91 | first_line = False 92 | else: 93 | output.indent_cursor() 94 | output.print_error(import_string, bold=False) 95 | output.new_line() 96 | 97 | output.new_line() 98 | 99 | def _check_all_modules_exist_in_graph(self, graph: ImportGraph) -> None: 100 | for module in self.modules: # type: ignore 101 | if module.name not in graph.modules: 102 | raise ValueError(f"Module '{module.name}' does not exist.") 103 | -------------------------------------------------------------------------------- /tests/adapters/filesystem.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List, Optional 2 | 3 | import yaml 4 | 5 | from importlinter.application.ports import filesystem as ports 6 | 7 | 8 | class FakeFileSystem(ports.FileSystem): 9 | def __init__( 10 | self, 11 | contents: str = None, 12 | content_map: Dict[str, str] = None, 13 | working_directory: str = None, 14 | ) -> None: 15 | """ 16 | Files can be declared as existing in the file system in two different ways, either 17 | in a contents string (which is a quick way of defining a lot of files), or in content_map 18 | (which specifies the actual contents of a file in the file system). For a file to be 19 | treated as existing, it needs to be declared in at least one of these. If it isn't 20 | declared in content_map, the file will behave as an empty file. 21 | 22 | Args: 23 | contents: a string in the following format: 24 | 25 | /path/to/mypackage/ 26 | __init__.py 27 | foo/ 28 | __init__.py 29 | one.py 30 | two/ 31 | __init__.py 32 | green.py 33 | blue.py 34 | 35 | content_map: A dictionary keyed with filenames, with values that are the contents. 36 | If present in content_map, .read(filename) will return the string. 37 | { 38 | '/path/to/foo/__init__.py': "from . import one", 39 | } 40 | 41 | working_directory: The path to be treated as the current working directory, e.g. 42 | '/path/to/directory'. 43 | """ 44 | self.contents = self._parse_contents(contents) 45 | self.content_map = content_map if content_map else {} 46 | self.working_directory = working_directory 47 | 48 | def join(self, *components: str) -> str: 49 | return "/".join(components) 50 | 51 | def _parse_contents(self, raw_contents: Optional[str]): 52 | """ 53 | Returns the raw contents parsed in the form: 54 | { 55 | '/path/to/mypackage': { 56 | '__init__.py': None, 57 | 'foo': { 58 | '__init__.py': None, 59 | 'one.py': None, 60 | 'two': { 61 | '__init__.py': None, 62 | 'blue.py': None, 63 | 'green.py': None, 64 | } 65 | } 66 | } 67 | } 68 | """ 69 | if raw_contents is None: 70 | return {} 71 | 72 | # Convert to yaml for ease of parsing. 73 | yamlified_lines = [] 74 | raw_lines = [line for line in raw_contents.split("\n") if line.strip()] 75 | 76 | dedented_lines = self._dedent(raw_lines) 77 | 78 | for line in dedented_lines: 79 | trimmed_line = line.rstrip().rstrip("/") 80 | yamlified_line = trimmed_line + ":" 81 | yamlified_lines.append(yamlified_line) 82 | 83 | yamlified_string = "\n".join(yamlified_lines) 84 | 85 | return yaml.safe_load(yamlified_string) 86 | 87 | def _dedent(self, lines: List[str]) -> List[str]: 88 | """ 89 | Dedent all lines by the same amount. 90 | """ 91 | first_line = lines[0] 92 | first_line_indent = len(first_line) - len(first_line.lstrip()) 93 | dedented = lambda line: line[first_line_indent:] 94 | return list(map(dedented, lines)) 95 | 96 | def read(self, file_name: str) -> str: 97 | if not self.exists(file_name): 98 | raise FileNotFoundError # pragma: nocover 99 | try: 100 | file_contents = self.content_map[file_name] 101 | except KeyError: 102 | return "" 103 | raw_lines = [line for line in file_contents.split("\n") if line.strip()] 104 | dedented_lines = self._dedent(raw_lines) 105 | return "\n".join(dedented_lines) 106 | 107 | def exists(self, file_name: str) -> bool: 108 | # The file should exist if it's either declared in contents or in content_map. 109 | if file_name in self.content_map.keys(): 110 | return True 111 | 112 | found_directory = None 113 | for directory in self.contents.keys(): 114 | if file_name.startswith(directory): 115 | found_directory = directory 116 | if not found_directory: 117 | return False 118 | 119 | relative_file_name = file_name[len(found_directory) + 1 :] 120 | file_components = relative_file_name.split("/") 121 | 122 | contents = self.contents[found_directory] 123 | for component in file_components: 124 | try: 125 | contents = contents[component] 126 | except KeyError: 127 | return False 128 | return True 129 | 130 | def getcwd(self) -> str: 131 | if self.working_directory: 132 | return self.working_directory 133 | raise RuntimeError("No working directory specified.") # pragma: nocover 134 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Configuration file for the Sphinx documentation builder. 4 | # 5 | # This file does only contain a selection of the most common options. For a 6 | # full list see the documentation: 7 | # http://www.sphinx-doc.org/en/stable/config 8 | 9 | # -- Path setup -------------------------------------------------------------- 10 | 11 | # If extensions (or modules to document with autodoc) are in another directory, 12 | # add these directories to sys.path here. If the directory is relative to the 13 | # documentation root, use os.path.abspath to make it absolute, like shown here. 14 | # 15 | import os 16 | 17 | # import sys 18 | # sys.path.insert(0, os.path.abspath('.')) 19 | 20 | 21 | # -- Project information ----------------------------------------------------- 22 | 23 | project = 'Import Linter' 24 | copyright = '2019 David Seddon' 25 | author = 'David Seddon' 26 | 27 | # The short X.Y version 28 | version = '' 29 | # The full version, including alpha/beta/rc tags 30 | release = '1.2.1' 31 | 32 | 33 | # -- General configuration --------------------------------------------------- 34 | 35 | # If your documentation needs a minimal Sphinx version, state it here. 36 | # 37 | # needs_sphinx = '1.0' 38 | 39 | # Add any Sphinx extension module names here, as strings. They can be 40 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 41 | # ones. 42 | extensions = [ 43 | ] 44 | 45 | # Add any paths that contain templates here, relative to this directory. 46 | templates_path = ['_templates'] 47 | 48 | # The suffix(es) of source filenames. 49 | # You can specify multiple suffix as a list of string: 50 | # 51 | # source_suffix = ['.rst', '.md'] 52 | source_suffix = '.rst' 53 | 54 | # The master toctree document. 55 | master_doc = 'index' 56 | 57 | # The language for content autogenerated by Sphinx. Refer to documentation 58 | # for a list of supported languages. 59 | # 60 | # This is also used if you do content translation via gettext catalogs. 61 | # Usually you set "language" from the command line for these cases. 62 | language = None 63 | 64 | # List of patterns, relative to source directory, that match files and 65 | # directories to ignore when looking for source files. 66 | # This pattern also affects html_static_path and html_extra_path . 67 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 68 | 69 | # The name of the Pygments (syntax highlighting) style to use. 70 | pygments_style = 'sphinx' 71 | 72 | 73 | # -- Options for HTML output ------------------------------------------------- 74 | 75 | # The theme to use for HTML and HTML Help pages. See the documentation for 76 | # a list of builtin themes. 77 | # 78 | # on_rtd is whether we are on readthedocs.org 79 | on_rtd = os.environ.get('READTHEDOCS', None) == 'True' 80 | 81 | if not on_rtd: # only set the theme if we're building docs locally 82 | html_theme = 'sphinx_rtd_theme' 83 | 84 | # Theme options are theme-specific and customize the look and feel of a theme 85 | # further. For a list of options available for each theme, see the 86 | # documentation. 87 | # 88 | # html_theme_options = {} 89 | 90 | # Add any paths that contain custom static files (such as style sheets) here, 91 | # relative to this directory. They are copied after the builtin static files, 92 | # so a file named "default.css" will overwrite the builtin "default.css". 93 | html_static_path = ['_static'] 94 | 95 | # Custom sidebar templates, must be a dictionary that maps document names 96 | # to template names. 97 | # 98 | # The default sidebars (for documents that don't match any pattern) are 99 | # defined by theme itself. Builtin themes are using these templates by 100 | # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', 101 | # 'searchbox.html']``. 102 | # 103 | # html_sidebars = {} 104 | 105 | 106 | # -- Options for HTMLHelp output --------------------------------------------- 107 | 108 | # Output file base name for HTML help builder. 109 | htmlhelp_basename = 'import-linter' 110 | 111 | 112 | # -- Options for LaTeX output ------------------------------------------------ 113 | 114 | latex_elements = { 115 | # The paper size ('letterpaper' or 'a4paper'). 116 | # 117 | # 'papersize': 'letterpaper', 118 | 119 | # The font size ('10pt', '11pt' or '12pt'). 120 | # 121 | # 'pointsize': '10pt', 122 | 123 | # Additional stuff for the LaTeX preamble. 124 | # 125 | # 'preamble': '', 126 | 127 | # Latex figure (float) alignment 128 | # 129 | # 'figure_align': 'htbp', 130 | } 131 | 132 | # Grouping the document tree into LaTeX files. List of tuples 133 | # (source start file, target name, title, 134 | # author, documentclass [howto, manual, or own class]). 135 | latex_documents = [ 136 | (master_doc, 'import-linter.tex', 'Import Linter Documentation', 137 | 'David Seddon', 'manual'), 138 | ] 139 | 140 | 141 | # -- Options for manual page output ------------------------------------------ 142 | 143 | # One entry per manual page. List of tuples 144 | # (source start file, name, description, authors, manual section). 145 | man_pages = [ 146 | (master_doc, 'import-linter', 'Import Linter Documentation', 147 | [author], 1) 148 | ] 149 | 150 | 151 | # -- Options for Texinfo output ---------------------------------------------- 152 | 153 | # Grouping the document tree into Texinfo files. List of tuples 154 | # (source start file, target name, title, author, 155 | # dir menu entry, description, category) 156 | texinfo_documents = [ 157 | (master_doc, 'import-linter', 'Import Linter Documentation', 158 | author, 'Import Linter', "Lint your Python project's imports.", 159 | 'Miscellaneous'), 160 | ] 161 | -------------------------------------------------------------------------------- /src/importlinter/contracts/forbidden.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple, cast 2 | 3 | from importlinter.application import output 4 | from importlinter.domain import fields, helpers 5 | from importlinter.domain.contract import Contract, ContractCheck 6 | from importlinter.domain.ports.graph import ImportGraph 7 | 8 | 9 | class ForbiddenContract(Contract): 10 | """ 11 | Forbidden contracts check that one set of modules are not imported by another set of modules. 12 | Indirect imports will also be checked. 13 | Configuration options: 14 | - source_modules: A list of Modules that should not import the forbidden modules. 15 | - forbidden_modules: A list of Modules that should not be imported by the source modules. 16 | - ignore_imports: A set of DirectImports. These imports will be ignored: if the import 17 | would cause a contract to be broken, adding it to the set will cause 18 | the contract be kept instead. (Optional.) 19 | - allow_indirect_imports: Whether to allow indirect imports to forbidden modules. 20 | "True" or "true" will be treated as True. (Optional.)``` 21 | """ 22 | 23 | type_name = "forbidden" 24 | 25 | source_modules = fields.ListField(subfield=fields.ModuleField()) 26 | forbidden_modules = fields.ListField(subfield=fields.ModuleField()) 27 | ignore_imports = fields.SetField(subfield=fields.DirectImportField(), required=False) 28 | allow_indirect_imports = fields.StringField(required=False) 29 | 30 | def check(self, graph: ImportGraph) -> ContractCheck: 31 | is_kept = True 32 | invalid_chains = [] 33 | 34 | helpers.pop_imports( 35 | graph, self.ignore_imports if self.ignore_imports else [] # type: ignore 36 | ) 37 | 38 | self._check_all_modules_exist_in_graph(graph) 39 | self._check_external_forbidden_modules(graph) 40 | 41 | # We only need to check for illegal imports for forbidden modules that are in the graph. 42 | forbidden_modules_in_graph = [ 43 | m for m in self.forbidden_modules if m.name in graph.modules # type: ignore 44 | ] 45 | 46 | for source_module in self.source_modules: # type: ignore 47 | for forbidden_module in forbidden_modules_in_graph: 48 | subpackage_chain_data = { 49 | "upstream_module": forbidden_module.name, 50 | "downstream_module": source_module.name, 51 | "chains": [], 52 | } 53 | 54 | if str(self.allow_indirect_imports).lower() == "true": 55 | chains = { 56 | cast( 57 | Tuple[str, ...], 58 | (str(import_det["importer"]), str(import_det["imported"])), 59 | ) 60 | for import_det in graph.get_import_details( 61 | importer=source_module.name, imported=forbidden_module.name 62 | ) 63 | } 64 | else: 65 | chains = graph.find_shortest_chains( 66 | importer=source_module.name, imported=forbidden_module.name 67 | ) 68 | if chains: 69 | is_kept = False 70 | for chain in chains: 71 | chain_data = [] 72 | for importer, imported in [ 73 | (chain[i], chain[i + 1]) for i in range(len(chain) - 1) 74 | ]: 75 | import_details = graph.get_import_details( 76 | importer=importer, imported=imported 77 | ) 78 | line_numbers = tuple(j["line_number"] for j in import_details) 79 | chain_data.append( 80 | { 81 | "importer": importer, 82 | "imported": imported, 83 | "line_numbers": line_numbers, 84 | } 85 | ) 86 | subpackage_chain_data["chains"].append(chain_data) 87 | if subpackage_chain_data["chains"]: 88 | invalid_chains.append(subpackage_chain_data) 89 | 90 | return ContractCheck(kept=is_kept, metadata={"invalid_chains": invalid_chains}) 91 | 92 | def render_broken_contract(self, check: "ContractCheck") -> None: 93 | count = 0 94 | for chains_data in check.metadata["invalid_chains"]: 95 | downstream, upstream = chains_data["downstream_module"], chains_data["upstream_module"] 96 | output.print_error(f"{downstream} is not allowed to import {upstream}:") 97 | output.new_line() 98 | count += len(chains_data["chains"]) 99 | for chain in chains_data["chains"]: 100 | first_line = True 101 | for direct_import in chain: 102 | importer, imported = direct_import["importer"], direct_import["imported"] 103 | line_numbers = ", ".join(f"l.{n}" for n in direct_import["line_numbers"]) 104 | import_string = f"{importer} -> {imported} ({line_numbers})" 105 | if first_line: 106 | output.print_error(f"- {import_string}", bold=False) 107 | first_line = False 108 | else: 109 | output.indent_cursor() 110 | output.print_error(import_string, bold=False) 111 | output.new_line() 112 | 113 | output.new_line() 114 | 115 | def _check_all_modules_exist_in_graph(self, graph: ImportGraph) -> None: 116 | for module in self.source_modules: # type: ignore 117 | if module.name not in graph.modules: 118 | raise ValueError(f"Module '{module.name}' does not exist.") 119 | 120 | def _check_external_forbidden_modules(self, graph: ImportGraph) -> None: 121 | if ( 122 | self._contains_external_forbidden_modules(graph) 123 | and not self._graph_was_built_with_externals() 124 | ): 125 | raise ValueError( 126 | "The top level configuration must have include_external_packages=True " 127 | "when there are external forbidden modules." 128 | ) 129 | 130 | def _contains_external_forbidden_modules(self, graph: ImportGraph) -> bool: 131 | root_packages = self.session_options["root_packages"] 132 | return not all( 133 | m.root_package_name in root_packages for m in self.forbidden_modules # type: ignore 134 | ) 135 | 136 | def _graph_was_built_with_externals(self) -> bool: 137 | return str(self.session_options.get("include_external_packages")).lower() == "true" 138 | -------------------------------------------------------------------------------- /src/importlinter/application/use_cases.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | from copy import copy, deepcopy 3 | from typing import List, Optional, Tuple, Type 4 | 5 | from ..domain.contract import Contract, InvalidContractOptions, registry 6 | from ..domain.ports.graph import ImportGraph 7 | from .app_config import settings 8 | from .ports.reporting import Report 9 | from .rendering import render_exception, render_report 10 | from .user_options import UserOptions 11 | 12 | # Public functions 13 | # ---------------- 14 | 15 | SUCCESS = True 16 | FAILURE = False 17 | 18 | 19 | def lint_imports(config_filename: Optional[str] = None, is_debug_mode: bool = False) -> bool: 20 | """ 21 | Analyse whether a Python package follows a set of contracts, and report on the results. 22 | 23 | This function attempts to handle and report all exceptions, too. 24 | 25 | Args: 26 | config_filename: the filename to use to parse user options. 27 | is_debug_mode: whether debugging should be turned on. In debug mode, exceptions are 28 | not swallowed at the top level, so the stack trace can be seen. 29 | 30 | Returns: 31 | True if the linting passed, False if it didn't. 32 | """ 33 | try: 34 | user_options = _read_user_options(config_filename=config_filename) 35 | _register_contract_types(user_options) 36 | report = create_report(user_options) 37 | except Exception as e: 38 | if is_debug_mode: 39 | raise e 40 | render_exception(e) 41 | return FAILURE 42 | 43 | render_report(report) 44 | 45 | if report.contains_failures: 46 | return FAILURE 47 | else: 48 | return SUCCESS 49 | 50 | 51 | def create_report(user_options: UserOptions) -> Report: 52 | """ 53 | Analyse whether a Python package follows a set of contracts, returning a report on the results. 54 | 55 | Raises: 56 | InvalidUserOptions: if the report could not be run due to invalid user configuration, 57 | such as a module that could not be imported. 58 | """ 59 | include_external_packages = _get_include_external_packages(user_options) 60 | graph = _build_graph( 61 | root_package_names=user_options.session_options["root_packages"], 62 | include_external_packages=include_external_packages, 63 | ) 64 | return _build_report(graph=graph, user_options=user_options) 65 | 66 | 67 | # Private functions 68 | # ----------------- 69 | 70 | 71 | def _read_user_options(config_filename: Optional[str] = None) -> UserOptions: 72 | for reader in settings.USER_OPTION_READERS: 73 | options = reader.read_options(config_filename=config_filename) 74 | if options: 75 | normalized_options = _normalize_user_options(options) 76 | return normalized_options 77 | raise RuntimeError("Could not read any configuration.") 78 | 79 | 80 | def _normalize_user_options(user_options: UserOptions) -> UserOptions: 81 | normalized_options = copy(user_options) 82 | if "root_packages" not in normalized_options.session_options: 83 | normalized_options.session_options["root_packages"] = [ 84 | normalized_options.session_options["root_package"] 85 | ] 86 | return normalized_options 87 | 88 | 89 | def _build_graph( 90 | root_package_names: List[str], include_external_packages: Optional[bool] 91 | ) -> ImportGraph: 92 | return settings.GRAPH_BUILDER.build( 93 | root_package_names=root_package_names, include_external_packages=include_external_packages 94 | ) 95 | 96 | 97 | def _build_report(graph: ImportGraph, user_options: UserOptions) -> Report: 98 | report = Report(graph=graph) 99 | for contract_options in user_options.contracts_options: 100 | contract_class = registry.get_contract_class(contract_options["type"]) 101 | try: 102 | contract = contract_class( 103 | name=contract_options["name"], 104 | session_options=user_options.session_options, 105 | contract_options=contract_options, 106 | ) 107 | except InvalidContractOptions as e: 108 | report.add_invalid_contract_options(contract_options["name"], e) 109 | return report 110 | 111 | # Make a copy so that contracts can mutate the graph without affecting 112 | # other contract checks. 113 | copy_of_graph = deepcopy(graph) 114 | check = contract.check(copy_of_graph) 115 | report.add_contract_check(contract, check) 116 | return report 117 | 118 | 119 | def _register_contract_types(user_options: UserOptions) -> None: 120 | contract_types = _get_built_in_contract_types() + _get_plugin_contract_types(user_options) 121 | for name, contract_class in contract_types: 122 | registry.register(contract_class, name) 123 | 124 | 125 | def _get_built_in_contract_types() -> List[Tuple[str, Type[Contract]]]: 126 | return list( 127 | map( 128 | _parse_contract_type_string, 129 | [ 130 | "forbidden: importlinter.contracts.forbidden.ForbiddenContract", 131 | "layers: importlinter.contracts.layers.LayersContract", 132 | "independence: importlinter.contracts.independence.IndependenceContract", 133 | ], 134 | ) 135 | ) 136 | 137 | 138 | def _get_plugin_contract_types(user_options: UserOptions) -> List[Tuple[str, Type[Contract]]]: 139 | contract_types = [] 140 | if "contract_types" in user_options.session_options: 141 | for contract_type_string in user_options.session_options["contract_types"]: 142 | contract_types.append(_parse_contract_type_string(contract_type_string)) 143 | return contract_types 144 | 145 | 146 | def _parse_contract_type_string(string) -> Tuple[str, Type[Contract]]: 147 | components = string.split(": ") 148 | assert len(components) == 2 149 | name, contract_class_string = components 150 | contract_class = _string_to_class(contract_class_string) 151 | if not issubclass(contract_class, Contract): 152 | raise TypeError(f"{contract_class} is not a subclass of Contract.") 153 | return name, contract_class 154 | 155 | 156 | def _string_to_class(string: str) -> Type: 157 | """ 158 | Parse a string into a Python class. 159 | 160 | Args: 161 | string: a fully qualified string of a class, e.g. 'mypackage.foo.MyClass'. 162 | 163 | Returns: 164 | The class. 165 | """ 166 | components = string.split(".") 167 | class_name = components[-1] 168 | module_name = ".".join(components[:-1]) 169 | module = importlib.import_module(module_name) 170 | cls = getattr(module, class_name) 171 | assert isinstance(cls, type) 172 | return cls 173 | 174 | 175 | def _get_include_external_packages(user_options: UserOptions) -> Optional[bool]: 176 | """ 177 | Get a boolean (or None) for the include_external_packages option in user_options. 178 | """ 179 | try: 180 | include_external_packages_str = user_options.session_options["include_external_packages"] 181 | except KeyError: 182 | return None 183 | # Cast the string to a boolean. 184 | return include_external_packages_str in ("True", "true") 185 | -------------------------------------------------------------------------------- /docs/contract_types.rst: -------------------------------------------------------------------------------- 1 | ============== 2 | Contract types 3 | ============== 4 | 5 | Forbidden modules 6 | ----------------- 7 | 8 | *Type name:* ``forbidden`` 9 | 10 | Forbidden contracts check that one set of modules are not imported by another set of modules. 11 | 12 | Descendants of each module will be checked - so if ``mypackage.one`` is forbidden from importing ``mypackage.two``, then 13 | ``mypackage.one.blue`` will be forbidden from importing ``mypackage.two.green``. Indirect imports will also be checked. 14 | 15 | External packages may also be forbidden. 16 | 17 | **Examples:** 18 | 19 | .. code-block:: ini 20 | 21 | [importlinter] 22 | root_package = mypackage 23 | 24 | [importlinter:contract:1] 25 | name = My forbidden contract (internal packages only) 26 | type = forbidden 27 | source_modules = 28 | mypackage.one 29 | mypackage.two 30 | mypackage.three.blue 31 | forbidden_modules = 32 | mypackage.four 33 | mypackage.five.green 34 | ignore_imports = 35 | mypackage.one.green -> mypackage.utils 36 | mypackage.two -> mypackage.four 37 | 38 | .. code-block:: ini 39 | 40 | [importlinter] 41 | root_package = mypackage 42 | include_external_packages = True 43 | 44 | [importlinter:contract:1] 45 | name = My forbidden contract (internal and external packages) 46 | type = forbidden 47 | source_modules = 48 | mypackage.one 49 | mypackage.two 50 | forbidden_modules = 51 | mypackage.three 52 | django 53 | requests 54 | ignore_imports = 55 | mypackage.one.green -> sqlalchemy 56 | 57 | **Configuration options** 58 | 59 | Configuration options: 60 | 61 | - ``source_modules``: A list of modules that should not import the forbidden modules. 62 | - ``forbidden_modules``: A list of modules that should not be imported by the source modules. These may include 63 | root level external packages (i.e. ``django``, but not ``django.db.models``). If external packages are included, 64 | the top level configuration must have ``internal_external_packages = True``. 65 | - ``ignore_imports``: 66 | A list of imports, each in the form ``mypackage.foo.importer -> mypackage.bar.imported``. These imports 67 | will be ignored: if the import would cause a contract to be broken, adding it to the list will cause the 68 | contract be kept instead. (Optional.) 69 | - ``allow_indirect_imports``: If ``True``, allow indirect imports to forbidden modules. (Optional.) 70 | 71 | Independence 72 | ------------ 73 | 74 | *Type name:* ``independence`` 75 | 76 | Independence contracts check that a set of modules do not depend on each other. 77 | 78 | They do this by checking that there are no imports in any direction between the modules, even indirectly. 79 | 80 | **Example:** 81 | 82 | .. code-block:: ini 83 | 84 | [importlinter:contract:1] 85 | name = My independence contract 86 | type = independence 87 | modules = 88 | mypackage.foo 89 | mypackage.bar 90 | mypackage.baz 91 | ignore_imports = 92 | mypackage.bar.green -> mypackage.utils 93 | mypackage.baz.blue -> mypackage.foo.purple 94 | 95 | **Configuration options** 96 | 97 | - ``modules``: A list of modules/subpackages that should be independent from each other. 98 | - ``ignore_imports``: 99 | A list of imports, each in the form ``mypackage.foo.importer -> mypackage.bar.imported``. These imports 100 | will be ignored: if the import would cause a contract to be broken, adding it to the list will cause the 101 | contract be kept instead. (Optional.) 102 | 103 | Layers 104 | ------ 105 | 106 | *Type name:* ``layers`` 107 | 108 | Layers contracts enforce a 'layered architecture', where higher layers may depend on lower layers, but not the other 109 | way around. 110 | 111 | They do this by checking, for an ordered list of modules, that none higher up the list imports anything from a module 112 | lower down the list, even indirectly. 113 | 114 | Layers are required by default: if a layer is listed in the contract, the contract will be broken if the layer 115 | doesn't exist. You can make a layer optional by wrapping it in parentheses. 116 | 117 | You may also define a set of 'containers'. These allow for a repeated pattern of layers across a project. If containers 118 | are provided, these are treated as the parent package of the layers. 119 | 120 | **Examples** 121 | 122 | .. code-block:: ini 123 | 124 | [importlinter] 125 | root_package = mypackage 126 | 127 | [importlinter:contract:1] 128 | name = My three-tier layers contract 129 | type = layers 130 | layers= 131 | mypackage.high 132 | mypackage.medium 133 | mypackage.low 134 | 135 | This contract will not allow imports from lower layers to higher layers. For example, it will not allow 136 | ``mypackage.low`` to import ``mypackage.high``, even indirectly. 137 | 138 | .. code-block:: ini 139 | 140 | [importlinter] 141 | root_packages= 142 | high 143 | medium 144 | low 145 | 146 | [importlinter:contract:1] 147 | name = My three-tier layers contract (multiple root packages) 148 | type = layers 149 | layers= 150 | high 151 | medium 152 | low 153 | 154 | This contract is similar to the one above, but is suitable if the packages are not contained within a root package 155 | (i.e. the Python project consists of several packages in a directory that does not contain an ``__init__.py`` file). 156 | In this case, ``high``, ``medium`` and ``low`` all need to be specified as ``root_packages`` in the 157 | ``[importlinter]`` configuration. 158 | 159 | .. code-block:: ini 160 | 161 | [importlinter:contract:1] 162 | name = My multiple package layers contract 163 | type = layers 164 | layers= 165 | high 166 | (medium) 167 | low 168 | containers= 169 | mypackage.foo 170 | mypackage.bar 171 | mypackage.baz 172 | 173 | In this example, each container has its own layered architecture. For example, it will not allow ``mypackage.foo.low`` 174 | to import ``mypackage.foo.high``. However, it will allow ``mypackage.foo.low`` to import ``mypackage.bar.high``, 175 | as they are in different containers: 176 | 177 | Notice that ``medium`` is an optional layer. This means that if it is missing from any of the containers, Import Linter 178 | won't complain. 179 | 180 | **Configuration options** 181 | 182 | - ``layers``: 183 | An ordered list with the name of each layer module. If containers are specified, then these names must be 184 | *relative to the container*. The order is from higher to lower level layers. Layers wrapped in parentheses 185 | (e.g. ``(foo)``) will be ignored if they are not present in the file system. 186 | - ``containers``: 187 | List of the parent modules of the layers, as *absolute names* that you could import, such as 188 | ``mypackage.foo``. (Optional.) 189 | - ``ignore_imports``: 190 | A list of imports, each in the form ``mypackage.foo.importer -> mypackage.bar.imported``. These imports 191 | will be ignored: if the import would cause a contract to be broken, adding it to the list will cause the 192 | contract be kept instead. (Optional.) 193 | 194 | 195 | Custom contract types 196 | --------------------- 197 | 198 | If none of the built in contract types meets your needs, you can define a custom contract type: see 199 | :doc:`custom_contract_types`. 200 | -------------------------------------------------------------------------------- /tests/unit/contracts/test_forbidden.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from grimp.adaptors.graph import ImportGraph # type: ignore 3 | from importlinter.application.app_config import settings 4 | from importlinter.contracts.forbidden import ForbiddenContract 5 | from importlinter.domain.contract import ContractCheck 6 | 7 | from tests.adapters.printing import FakePrinter 8 | 9 | 10 | class TestForbiddenContract: 11 | def test_is_kept_when_no_forbidden_modules_imported(self): 12 | graph = self._build_graph() 13 | contract = self._build_contract(forbidden_modules=("mypackage.blue", "mypackage.yellow")) 14 | 15 | contract_check = contract.check(graph=graph) 16 | 17 | assert contract_check.kept 18 | 19 | def test_is_broken_when_forbidden_modules_imported(self): 20 | graph = self._build_graph() 21 | contract = self._build_contract( 22 | forbidden_modules=( 23 | "mypackage.blue", 24 | "mypackage.green", 25 | "mypackage.yellow", 26 | "mypackage.purple", 27 | ) 28 | ) 29 | 30 | contract_check = contract.check(graph=graph) 31 | 32 | assert not contract_check.kept 33 | 34 | expected_metadata = { 35 | "invalid_chains": [ 36 | { 37 | "upstream_module": "mypackage.green", 38 | "downstream_module": "mypackage.one", 39 | "chains": [ 40 | [ 41 | { 42 | "importer": "mypackage.one.alpha", 43 | "imported": "mypackage.green.beta", 44 | "line_numbers": (3,), 45 | } 46 | ] 47 | ], 48 | }, 49 | { 50 | "upstream_module": "mypackage.purple", 51 | "downstream_module": "mypackage.two", 52 | "chains": [ 53 | [ 54 | { 55 | "importer": "mypackage.two", 56 | "imported": "mypackage.utils", 57 | "line_numbers": (9,), 58 | }, 59 | { 60 | "importer": "mypackage.utils", 61 | "imported": "mypackage.purple", 62 | "line_numbers": (1,), 63 | }, 64 | ] 65 | ], 66 | }, 67 | { 68 | "upstream_module": "mypackage.green", 69 | "downstream_module": "mypackage.three", 70 | "chains": [ 71 | [ 72 | { 73 | "importer": "mypackage.three", 74 | "imported": "mypackage.green", 75 | "line_numbers": (4,), 76 | } 77 | ] 78 | ], 79 | }, 80 | ] 81 | } 82 | 83 | assert expected_metadata == contract_check.metadata 84 | 85 | def test_is_broken_when_forbidden_external_modules_imported(self): 86 | graph = self._build_graph() 87 | contract = self._build_contract( 88 | forbidden_modules=("sqlalchemy", "requests"), include_external_packages=True 89 | ) 90 | 91 | contract_check = contract.check(graph=graph) 92 | 93 | assert not contract_check.kept 94 | 95 | expected_metadata = { 96 | "invalid_chains": [ 97 | { 98 | "upstream_module": "sqlalchemy", 99 | "downstream_module": "mypackage.three", 100 | "chains": [ 101 | [ 102 | { 103 | "importer": "mypackage.three", 104 | "imported": "sqlalchemy", 105 | "line_numbers": (1,), 106 | } 107 | ] 108 | ], 109 | } 110 | ] 111 | } 112 | 113 | assert expected_metadata == contract_check.metadata 114 | 115 | def test_is_invalid_when_forbidden_externals_but_graph_does_not_include_externals(self): 116 | graph = self._build_graph() 117 | contract = self._build_contract(forbidden_modules=("sqlalchemy", "requests")) 118 | 119 | with pytest.raises( 120 | ValueError, 121 | match=( 122 | "The top level configuration must have include_external_packages=True " 123 | "when there are external forbidden modules." 124 | ), 125 | ): 126 | contract.check(graph=graph) 127 | 128 | def test_ignore_imports_tolerates_duplicates(self): 129 | graph = self._build_graph() 130 | contract = self._build_contract( 131 | forbidden_modules=("mypackage.blue", "mypackage.yellow"), 132 | ignore_imports=( 133 | "mypackage.three -> mypackage.green", 134 | "mypackage.utils -> mypackage.purple", 135 | "mypackage.three -> mypackage.green", 136 | ), 137 | include_external_packages=False, 138 | ) 139 | assert contract.check(graph=graph) 140 | 141 | @pytest.mark.parametrize( 142 | "allow_indirect_imports, contract_is_kept", 143 | ((None, False), ("false", False), ("True", True), ("true", True), ("anything", False)), 144 | ) 145 | def test_allow_indirect_imports(self, allow_indirect_imports, contract_is_kept): 146 | graph = self._build_graph() 147 | contract = self._build_contract( 148 | forbidden_modules=("mypackage.purple"), 149 | allow_indirect_imports=allow_indirect_imports, 150 | ) 151 | contract_check = contract.check(graph=graph) 152 | assert contract_check.kept == contract_is_kept 153 | 154 | def _build_graph(self): 155 | graph = ImportGraph() 156 | for module in ( 157 | "one", 158 | "one.alpha", 159 | "two", 160 | "three", 161 | "blue", 162 | "green", 163 | "green.beta", 164 | "yellow", 165 | "purple", 166 | "utils", 167 | ): 168 | graph.add_module(f"mypackage.{module}") 169 | for external_module in ("sqlalchemy", "requests"): 170 | graph.add_module(external_module, is_squashed=True) 171 | graph.add_import( 172 | importer="mypackage.one.alpha", 173 | imported="mypackage.green.beta", 174 | line_number=3, 175 | line_contents="foo", 176 | ) 177 | graph.add_import( 178 | importer="mypackage.three", 179 | imported="mypackage.green", 180 | line_number=4, 181 | line_contents="foo", 182 | ) 183 | graph.add_import( 184 | importer="mypackage.two", 185 | imported="mypackage.utils", 186 | line_number=9, 187 | line_contents="foo", 188 | ) 189 | graph.add_import( 190 | importer="mypackage.utils", 191 | imported="mypackage.purple", 192 | line_number=1, 193 | line_contents="foo", 194 | ) 195 | graph.add_import( 196 | importer="mypackage.three", imported="sqlalchemy", line_number=1, line_contents="foo" 197 | ) 198 | return graph 199 | 200 | def _build_contract( 201 | self, 202 | forbidden_modules, 203 | ignore_imports=None, 204 | include_external_packages=False, 205 | allow_indirect_imports=None, 206 | ): 207 | session_options = {"root_packages": ["mypackage"]} 208 | if include_external_packages: 209 | session_options["include_external_packages"] = "True" 210 | 211 | return ForbiddenContract( 212 | name="Forbid contract", 213 | session_options=session_options, 214 | contract_options={ 215 | "source_modules": ("mypackage.one", "mypackage.two", "mypackage.three"), 216 | "forbidden_modules": forbidden_modules, 217 | "ignore_imports": ignore_imports or [], 218 | "allow_indirect_imports": allow_indirect_imports, 219 | }, 220 | ) 221 | 222 | 223 | def test_render_broken_contract(): 224 | settings.configure(PRINTER=FakePrinter()) 225 | contract = ForbiddenContract( 226 | name="Forbid contract", 227 | session_options={"root_packages": ["mypackage"]}, 228 | contract_options={ 229 | "source_modules": ("mypackage.one", "mypackage.two", "mypackage.three"), 230 | "forbidden_modules": ( 231 | "mypackage.blue", 232 | "mypackage.green", 233 | "mypackage.yellow", 234 | "mypackage.purple", 235 | ), 236 | }, 237 | ) 238 | check = ContractCheck( 239 | kept=False, 240 | metadata={ 241 | "invalid_chains": [ 242 | { 243 | "upstream_module": "mypackage.purple", 244 | "downstream_module": "mypackage.two", 245 | "chains": [ 246 | [ 247 | { 248 | "importer": "mypackage.two", 249 | "imported": "mypackage.utils", 250 | "line_numbers": (9,), 251 | }, 252 | { 253 | "importer": "mypackage.utils", 254 | "imported": "mypackage.purple", 255 | "line_numbers": (1,), 256 | }, 257 | ] 258 | ], 259 | }, 260 | { 261 | "upstream_module": "mypackage.green", 262 | "downstream_module": "mypackage.three", 263 | "chains": [ 264 | [ 265 | { 266 | "importer": "mypackage.three", 267 | "imported": "mypackage.green", 268 | "line_numbers": (4,), 269 | } 270 | ] 271 | ], 272 | }, 273 | ] 274 | }, 275 | ) 276 | 277 | contract.render_broken_contract(check) 278 | 279 | settings.PRINTER.pop_and_assert( 280 | """ 281 | mypackage.two is not allowed to import mypackage.purple: 282 | 283 | - mypackage.two -> mypackage.utils (l.9) 284 | mypackage.utils -> mypackage.purple (l.1) 285 | 286 | 287 | mypackage.three is not allowed to import mypackage.green: 288 | 289 | - mypackage.three -> mypackage.green (l.4) 290 | 291 | 292 | """ 293 | ) 294 | -------------------------------------------------------------------------------- /tests/unit/application/test_use_cases.py: -------------------------------------------------------------------------------- 1 | import string 2 | from typing import Any, Dict, List, Optional 3 | 4 | import pytest 5 | from grimp.adaptors.graph import ImportGraph # type: ignore 6 | 7 | from importlinter.application.app_config import settings 8 | from importlinter.application.use_cases import FAILURE, SUCCESS, create_report, lint_imports 9 | from importlinter.application.user_options import UserOptions 10 | from tests.adapters.building import FakeGraphBuilder 11 | from tests.adapters.printing import FakePrinter 12 | from tests.adapters.user_options import ExceptionRaisingUserOptionReader, FakeUserOptionReader 13 | 14 | 15 | class TestCheckContractsAndPrintReport: 16 | def test_all_successful(self): 17 | self._configure( 18 | contracts_options=[ 19 | {"type": "always_passes", "name": "Contract foo"}, 20 | {"type": "always_passes", "name": "Contract bar"}, 21 | ] 22 | ) 23 | 24 | result = lint_imports() 25 | 26 | assert result == SUCCESS 27 | 28 | settings.PRINTER.pop_and_assert( 29 | """ 30 | ============= 31 | Import Linter 32 | ============= 33 | 34 | --------- 35 | Contracts 36 | --------- 37 | 38 | Analyzed 26 files, 10 dependencies. 39 | ----------------------------------- 40 | 41 | Contract foo KEPT 42 | Contract bar KEPT 43 | 44 | Contracts: 2 kept, 0 broken. 45 | """ 46 | ) 47 | 48 | def test_invalid_contract(self): 49 | self._configure( 50 | contracts_options=[ 51 | { 52 | "type": "fields", 53 | "name": "Contract foo", 54 | "single_field": ["one", "two"], 55 | "multiple_field": "one", 56 | "import_field": "foobar", 57 | }, 58 | {"type": "always_passes", "name": "Contract bar"}, 59 | ] 60 | ) 61 | 62 | result = lint_imports() 63 | 64 | assert result == FAILURE 65 | 66 | settings.PRINTER.pop_and_assert( 67 | """ 68 | Contract "Contract foo" is not configured correctly: 69 | single_field: Expected a single value, got multiple values. 70 | import_field: Must be in the form "package.importer -> package.imported". 71 | required_field: This is a required field. 72 | """ 73 | ) 74 | 75 | def test_one_failure(self): 76 | self._configure( 77 | contracts_options=[ 78 | {"type": "always_fails", "name": "Contract foo"}, 79 | {"type": "always_passes", "name": "Contract bar"}, 80 | ] 81 | ) 82 | 83 | result = lint_imports() 84 | 85 | assert result == FAILURE 86 | 87 | settings.PRINTER.pop_and_assert( 88 | """ 89 | ============= 90 | Import Linter 91 | ============= 92 | 93 | --------- 94 | Contracts 95 | --------- 96 | 97 | Analyzed 26 files, 10 dependencies. 98 | ----------------------------------- 99 | 100 | Contract foo BROKEN 101 | Contract bar KEPT 102 | 103 | Contracts: 1 kept, 1 broken. 104 | 105 | 106 | ---------------- 107 | Broken contracts 108 | ---------------- 109 | 110 | Contract foo 111 | ------------ 112 | 113 | This contract will always fail. 114 | """ 115 | ) 116 | 117 | def test_forbidden_import(self): 118 | """ 119 | Tests the ForbiddenImportContract - a simple contract that 120 | looks at the graph. 121 | """ 122 | graph = self._build_default_graph() 123 | graph.add_import( 124 | importer="mypackage.foo", 125 | imported="mypackage.bar", 126 | line_number=8, 127 | line_contents="from mypackage import bar", 128 | ) 129 | graph.add_import( 130 | importer="mypackage.foo", 131 | imported="mypackage.bar", 132 | line_number=16, 133 | line_contents="from mypackage.bar import something", 134 | ) 135 | self._configure( 136 | contracts_options=[ 137 | {"type": "always_passes", "name": "Contract foo"}, 138 | { 139 | "type": "forbidden", 140 | "name": "Forbidden contract one", 141 | "importer": "mypackage.foo", 142 | "imported": "mypackage.bar", 143 | }, 144 | { 145 | "type": "forbidden", 146 | "name": "Forbidden contract two", 147 | "importer": "mypackage.foo", 148 | "imported": "mypackage.baz", 149 | }, 150 | ], 151 | graph=graph, 152 | ) 153 | 154 | result = lint_imports() 155 | 156 | assert result == FAILURE 157 | 158 | # Expecting 28 files (default graph has 26 modules, we add 2). 159 | # Expecting 11 dependencies (default graph has 10 imports, we add 2, 160 | # but it counts as 1 as it's between the same modules). 161 | settings.PRINTER.pop_and_assert( 162 | """ 163 | ============= 164 | Import Linter 165 | ============= 166 | 167 | --------- 168 | Contracts 169 | --------- 170 | 171 | Analyzed 28 files, 11 dependencies. 172 | ----------------------------------- 173 | 174 | Contract foo KEPT 175 | Forbidden contract one BROKEN 176 | Forbidden contract two KEPT 177 | 178 | Contracts: 2 kept, 1 broken. 179 | 180 | 181 | ---------------- 182 | Broken contracts 183 | ---------------- 184 | 185 | Forbidden contract one 186 | ---------------------- 187 | 188 | mypackage.foo is not allowed to import mypackage.bar: 189 | 190 | mypackage.foo:8: from mypackage import bar 191 | mypackage.foo:16: from mypackage.bar import something 192 | """ 193 | ) 194 | 195 | def test_debug_mode_doesnt_swallow_exception(self): 196 | some_exception = RuntimeError("There was some sort of exception.") 197 | reader = ExceptionRaisingUserOptionReader(exception=some_exception) 198 | settings.configure( 199 | USER_OPTION_READERS=[reader], GRAPH_BUILDER=FakeGraphBuilder(), PRINTER=FakePrinter() 200 | ) 201 | 202 | with pytest.raises(some_exception.__class__, match=str(some_exception)): 203 | lint_imports(is_debug_mode=True) 204 | 205 | def test_non_debug_mode_prints_exception(self): 206 | some_exception = RuntimeError("There was some sort of exception.") 207 | reader = ExceptionRaisingUserOptionReader(exception=some_exception) 208 | settings.configure( 209 | USER_OPTION_READERS=[reader], GRAPH_BUILDER=FakeGraphBuilder(), PRINTER=FakePrinter() 210 | ) 211 | 212 | lint_imports(is_debug_mode=False) 213 | 214 | settings.PRINTER.pop_and_assert( 215 | """There was some sort of exception. 216 | """ 217 | ) 218 | 219 | def _configure( 220 | self, 221 | contracts_options: List[Dict[str, Any]], 222 | contract_types: Optional[List[str]] = None, 223 | graph: Optional[ImportGraph] = None, 224 | ): 225 | session_options = {"root_package": "mypackage"} 226 | if not contract_types: 227 | contract_types = [ 228 | "always_passes: tests.helpers.contracts.AlwaysPassesContract", 229 | "always_fails: tests.helpers.contracts.AlwaysFailsContract", 230 | "fields: tests.helpers.contracts.FieldsContract", 231 | "forbidden: tests.helpers.contracts.ForbiddenImportContract", 232 | ] 233 | session_options["contract_types"] = contract_types # type: ignore 234 | 235 | reader = FakeUserOptionReader( 236 | UserOptions(session_options=session_options, contracts_options=contracts_options) 237 | ) 238 | settings.configure( 239 | USER_OPTION_READERS=[reader], GRAPH_BUILDER=FakeGraphBuilder(), PRINTER=FakePrinter() 240 | ) 241 | if graph is None: 242 | graph = self._build_default_graph() 243 | 244 | settings.GRAPH_BUILDER.inject_graph(graph) 245 | 246 | def _build_default_graph(self): 247 | graph = ImportGraph() 248 | 249 | # Add 26 modules. 250 | for letter in string.ascii_lowercase: 251 | graph.add_module(f"mypackage.{letter}") 252 | 253 | # Add 10 imports in total. 254 | for imported in ("d", "e", "f"): 255 | for importer in ("a", "b", "c"): 256 | graph.add_import( 257 | importer=f"mypackage.{importer}", imported=f"mypackage.{imported}" 258 | ) # 3 * 3 = 9 imports. 259 | graph.add_import(importer="mypackage.d", imported="mypackage.f") # 1 extra import. 260 | return graph 261 | 262 | 263 | class TestMultipleRootPackages: 264 | def test_builder_is_called_with_root_packages(self): 265 | builder = FakeGraphBuilder() 266 | root_package_names = ["mypackageone", "mypackagetwo"] 267 | settings.configure(GRAPH_BUILDER=builder, PRINTER=FakePrinter()) 268 | 269 | create_report( 270 | UserOptions( 271 | session_options={"root_packages": root_package_names}, contracts_options=[] 272 | ) 273 | ) 274 | 275 | assert builder.build_arguments["root_package_names"] == root_package_names 276 | 277 | 278 | class TestGraphCopying: 279 | def test_graph_can_be_mutated_without_affecting_other_contracts(self): 280 | # The MutationCheckContract checks that there are a certain number of modules and imports 281 | # in the graph, then adds one more module and one more import. We can check two such 282 | # contracts and the second one will fail, if the graph gets mutated by other contracts. 283 | session_options = { 284 | "root_package": "mypackage", 285 | "contract_types": ["mutation_check: tests.helpers.contracts.MutationCheckContract"], 286 | } 287 | 288 | reader = FakeUserOptionReader( 289 | UserOptions( 290 | session_options=session_options, 291 | contracts_options=[ 292 | { 293 | "type": "mutation_check", 294 | "name": "Contract one", 295 | "number_of_modules": "5", 296 | "number_of_imports": "2", 297 | }, 298 | { 299 | "type": "mutation_check", 300 | "name": "Contract two", 301 | "number_of_modules": "5", 302 | "number_of_imports": "2", 303 | }, 304 | ], 305 | ) 306 | ) 307 | settings.configure( 308 | USER_OPTION_READERS=[reader], GRAPH_BUILDER=FakeGraphBuilder(), PRINTER=FakePrinter() 309 | ) 310 | 311 | graph = ImportGraph() 312 | 313 | # Create a graph with five modules and two imports. 314 | for module in ("one", "two", "three", "four", "five"): 315 | graph.add_module(module) 316 | graph.add_import(importer="one", imported="two") 317 | graph.add_import(importer="one", imported="three") 318 | 319 | settings.GRAPH_BUILDER.inject_graph(graph) 320 | 321 | result = lint_imports(is_debug_mode=True) 322 | 323 | assert result == SUCCESS 324 | -------------------------------------------------------------------------------- /tests/unit/contracts/test_independence.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from grimp.adaptors.graph import ImportGraph # type: ignore 3 | from importlinter.application.app_config import settings 4 | from importlinter.contracts.independence import IndependenceContract 5 | from importlinter.domain.contract import ContractCheck 6 | 7 | from tests.adapters.printing import FakePrinter 8 | 9 | 10 | class TestIndependenceContract: 11 | def _build_default_graph(self): 12 | graph = ImportGraph() 13 | for module in ( 14 | "mypackage", 15 | "mypackage.blue", 16 | "mypackage.blue.alpha", 17 | "mypackage.blue.beta", 18 | "mypackage.blue.beta.foo", 19 | "mypackage.green", 20 | "mypackage.yellow", 21 | "mypackage.yellow.gamma", 22 | "mypackage.yellow.delta", 23 | "mypackage.other", 24 | ): 25 | graph.add_module(module) 26 | return graph 27 | 28 | def _check_default_contract(self, graph): 29 | contract = IndependenceContract( 30 | name="Independence contract", 31 | session_options={"root_packages": ["mypackage"]}, 32 | contract_options={ 33 | "modules": ("mypackage.blue", "mypackage.green", "mypackage.yellow") 34 | }, 35 | ) 36 | return contract.check(graph=graph) 37 | 38 | def test_when_modules_are_independent(self): 39 | graph = self._build_default_graph() 40 | graph.add_import( 41 | importer="mypackage.blue", 42 | imported="mypackage.other", 43 | line_number=10, 44 | line_contents="-", 45 | ) 46 | graph.add_import( 47 | importer="mypackage.green", 48 | imported="mypackage.other", 49 | line_number=11, 50 | line_contents="-", 51 | ) 52 | 53 | contract_check = self._check_default_contract(graph) 54 | 55 | assert contract_check.kept 56 | 57 | def test_when_root_imports_root_directly(self): 58 | graph = self._build_default_graph() 59 | graph.add_import( 60 | importer="mypackage.blue", 61 | imported="mypackage.green", 62 | line_number=10, 63 | line_contents="-", 64 | ) 65 | 66 | contract_check = self._check_default_contract(graph) 67 | 68 | assert not contract_check.kept 69 | 70 | expected_metadata = { 71 | "invalid_chains": [ 72 | { 73 | "upstream_module": "mypackage.green", 74 | "downstream_module": "mypackage.blue", 75 | "chains": [ 76 | [ 77 | { 78 | "importer": "mypackage.blue", 79 | "imported": "mypackage.green", 80 | "line_numbers": (10,), 81 | } 82 | ] 83 | ], 84 | } 85 | ] 86 | } 87 | assert expected_metadata == contract_check.metadata 88 | 89 | def test_when_root_imports_root_indirectly(self): 90 | graph = self._build_default_graph() 91 | graph.add_import( 92 | importer="mypackage.blue", 93 | imported="mypackage.other", 94 | line_number=10, 95 | line_contents="-", 96 | ) 97 | graph.add_import( 98 | importer="mypackage.other", 99 | imported="mypackage.green", 100 | line_number=11, 101 | line_contents="-", 102 | ) 103 | 104 | contract_check = self._check_default_contract(graph) 105 | 106 | assert not contract_check.kept 107 | 108 | expected_metadata = { 109 | "invalid_chains": [ 110 | { 111 | "upstream_module": "mypackage.green", 112 | "downstream_module": "mypackage.blue", 113 | "chains": [ 114 | [ 115 | { 116 | "importer": "mypackage.blue", 117 | "imported": "mypackage.other", 118 | "line_numbers": (10,), 119 | }, 120 | { 121 | "importer": "mypackage.other", 122 | "imported": "mypackage.green", 123 | "line_numbers": (11,), 124 | }, 125 | ] 126 | ], 127 | } 128 | ] 129 | } 130 | assert expected_metadata == contract_check.metadata 131 | 132 | def test_chains_via_other_independent_modules(self): 133 | # In this case, all chains are included, even though they are repeated elsewhere in the 134 | # contract check (we may want to change this). 135 | graph = self._build_default_graph() 136 | graph.add_import( 137 | importer="mypackage.blue", 138 | imported="mypackage.green", 139 | line_number=10, 140 | line_contents="-", 141 | ) 142 | graph.add_import( 143 | importer="mypackage.yellow", 144 | imported="mypackage.blue", 145 | line_number=11, 146 | line_contents="-", 147 | ) 148 | 149 | contract_check = self._check_default_contract(graph) 150 | 151 | assert not contract_check.kept 152 | 153 | expected_metadata = { 154 | "invalid_chains": [ 155 | { 156 | "upstream_module": "mypackage.green", 157 | "downstream_module": "mypackage.blue", 158 | "chains": [ 159 | [ 160 | { 161 | "importer": "mypackage.blue", 162 | "imported": "mypackage.green", 163 | "line_numbers": (10,), 164 | } 165 | ] 166 | ], 167 | }, 168 | { 169 | "upstream_module": "mypackage.blue", 170 | "downstream_module": "mypackage.yellow", 171 | "chains": [ 172 | [ 173 | { 174 | "importer": "mypackage.yellow", 175 | "imported": "mypackage.blue", 176 | "line_numbers": (11,), 177 | } 178 | ] 179 | ], 180 | }, 181 | { 182 | "upstream_module": "mypackage.green", 183 | "downstream_module": "mypackage.yellow", 184 | "chains": [ 185 | [ 186 | { 187 | "importer": "mypackage.yellow", 188 | "imported": "mypackage.blue", 189 | "line_numbers": (11,), 190 | }, 191 | { 192 | "importer": "mypackage.blue", 193 | "imported": "mypackage.green", 194 | "line_numbers": (10,), 195 | }, 196 | ] 197 | ], 198 | }, 199 | ] 200 | } 201 | assert expected_metadata == contract_check.metadata 202 | 203 | def test_when_child_imports_child(self): 204 | graph = self._build_default_graph() 205 | graph.add_import( 206 | importer="mypackage.blue.alpha", 207 | imported="mypackage.yellow.gamma", 208 | line_number=5, 209 | line_contents="-", 210 | ) 211 | 212 | contract_check = self._check_default_contract(graph) 213 | 214 | assert not contract_check.kept 215 | 216 | expected_metadata = { 217 | "invalid_chains": [ 218 | { 219 | "upstream_module": "mypackage.yellow", 220 | "downstream_module": "mypackage.blue", 221 | "chains": [ 222 | [ 223 | { 224 | "importer": "mypackage.blue.alpha", 225 | "imported": "mypackage.yellow.gamma", 226 | "line_numbers": (5,), 227 | } 228 | ] 229 | ], 230 | } 231 | ] 232 | } 233 | assert expected_metadata == contract_check.metadata 234 | 235 | def test_when_grandchild_imports_root(self): 236 | graph = self._build_default_graph() 237 | graph.add_import( 238 | importer="mypackage.blue.beta.foo", 239 | imported="mypackage.green", 240 | line_number=8, 241 | line_contents="-", 242 | ) 243 | 244 | contract_check = self._check_default_contract(graph) 245 | 246 | assert not contract_check.kept 247 | 248 | expected_metadata = { 249 | "invalid_chains": [ 250 | { 251 | "upstream_module": "mypackage.green", 252 | "downstream_module": "mypackage.blue", 253 | "chains": [ 254 | [ 255 | { 256 | "importer": "mypackage.blue.beta.foo", 257 | "imported": "mypackage.green", 258 | "line_numbers": (8,), 259 | } 260 | ] 261 | ], 262 | } 263 | ] 264 | } 265 | assert expected_metadata == contract_check.metadata 266 | 267 | 268 | @pytest.mark.parametrize( 269 | "ignore_imports, is_kept", 270 | ( 271 | (["mypackage.a -> mypackage.irrelevant"], False), 272 | (["mypackage.a -> mypackage.indirect"], True), 273 | (["mypackage.indirect -> mypackage.b"], True), 274 | ), 275 | ) 276 | def test_ignore_imports(ignore_imports, is_kept): 277 | graph = ImportGraph() 278 | graph.add_module("mypackage") 279 | graph.add_import( 280 | importer="mypackage.a", imported="mypackage.irrelevant", line_number=1, line_contents="-" 281 | ) 282 | graph.add_import( 283 | importer="mypackage.a", imported="mypackage.indirect", line_number=1, line_contents="-" 284 | ) 285 | graph.add_import( 286 | importer="mypackage.indirect", imported="mypackage.b", line_number=1, line_contents="-" 287 | ) 288 | contract = IndependenceContract( 289 | name="Independence contract", 290 | session_options={"root_packages": ["mypackage"]}, 291 | contract_options={ 292 | "modules": ("mypackage.a", "mypackage.b"), 293 | "ignore_imports": ignore_imports, 294 | }, 295 | ) 296 | 297 | contract_check = contract.check(graph=graph) 298 | 299 | assert is_kept == contract_check.kept 300 | 301 | 302 | def test_render_broken_contract(): 303 | settings.configure(PRINTER=FakePrinter()) 304 | contract = IndependenceContract( 305 | name="Independence contract", 306 | session_options={"root_packages": ["mypackage"]}, 307 | contract_options={"modules": ["mypackage.blue", "mypackage.green", "mypackage.yellow"]}, 308 | ) 309 | check = ContractCheck( 310 | kept=False, 311 | metadata={ 312 | "invalid_chains": [ 313 | { 314 | "upstream_module": "mypackage.yellow", 315 | "downstream_module": "mypackage.blue", 316 | "chains": [ 317 | [ 318 | { 319 | "importer": "mypackage.blue.foo", 320 | "imported": "mypackage.utils.red", 321 | "line_numbers": (16, 102), 322 | }, 323 | { 324 | "importer": "mypackage.utils.red", 325 | "imported": "mypackage.utils.brown", 326 | "line_numbers": (1,), 327 | }, 328 | { 329 | "importer": "mypackage.utils.brown", 330 | "imported": "mypackage.yellow.bar", 331 | "line_numbers": (3,), 332 | }, 333 | ], 334 | [ 335 | { 336 | "importer": "mypackage.blue.bar", 337 | "imported": "mypackage.yellow.baz", 338 | "line_numbers": (5,), 339 | } 340 | ], 341 | ], 342 | }, 343 | { 344 | "upstream_module": "mypackage.green", 345 | "downstream_module": "mypackage.yellow", 346 | "chains": [ 347 | [ 348 | { 349 | "importer": "mypackage.yellow.foo", 350 | "imported": "mypackage.green.bar", 351 | "line_numbers": (15,), 352 | } 353 | ] 354 | ], 355 | }, 356 | ] 357 | }, 358 | ) 359 | 360 | contract.render_broken_contract(check) 361 | 362 | settings.PRINTER.pop_and_assert( 363 | """ 364 | mypackage.blue is not allowed to import mypackage.yellow: 365 | 366 | - mypackage.blue.foo -> mypackage.utils.red (l.16, l.102) 367 | mypackage.utils.red -> mypackage.utils.brown (l.1) 368 | mypackage.utils.brown -> mypackage.yellow.bar (l.3) 369 | 370 | - mypackage.blue.bar -> mypackage.yellow.baz (l.5) 371 | 372 | 373 | mypackage.yellow is not allowed to import mypackage.green: 374 | 375 | - mypackage.yellow.foo -> mypackage.green.bar (l.15) 376 | 377 | 378 | """ 379 | ) 380 | 381 | 382 | def test_missing_module(): 383 | graph = ImportGraph() 384 | for module in ("mypackage", "mypackage.foo"): 385 | graph.add_module(module) 386 | 387 | contract = IndependenceContract( 388 | name="Independence contract", 389 | session_options={"root_packages": ["mypackage"]}, 390 | contract_options={"modules": ["mypackage.foo", "mypackage.bar"]}, 391 | ) 392 | 393 | with pytest.raises(ValueError, match=("Module 'mypackage.bar' does not exist.")): 394 | contract.check(graph=graph) 395 | 396 | 397 | def test_ignore_imports_tolerates_duplicates(): 398 | graph = ImportGraph() 399 | graph.add_module("mypackage") 400 | graph.add_import( 401 | importer="mypackage.a", imported="mypackage.b", line_number=1, line_contents="-" 402 | ) 403 | graph.add_import( 404 | importer="mypackage.a", imported="mypackage.c", line_number=2, line_contents="-" 405 | ) 406 | contract = IndependenceContract( 407 | name="Independence contract", 408 | session_options={"root_packages": ["mypackage"]}, 409 | contract_options={ 410 | "modules": ("mypackage.a", "mypackage.b"), 411 | "ignore_imports": [ 412 | "mypackage.a -> mypackage.b", 413 | "mypackage.a -> mypackage.c", 414 | "mypackage.a -> mypackage.b", 415 | ], 416 | }, 417 | ) 418 | 419 | contract_check = contract.check(graph=graph) 420 | 421 | assert contract_check.kept 422 | -------------------------------------------------------------------------------- /src/importlinter/contracts/layers.py: -------------------------------------------------------------------------------- 1 | import copy 2 | from typing import Any, Dict, Iterator, List, Optional, Tuple, Union 3 | 4 | from importlinter.application import output 5 | from importlinter.domain import fields, helpers 6 | from importlinter.domain.contract import Contract, ContractCheck 7 | from importlinter.domain.imports import Module 8 | from importlinter.domain.ports.graph import ImportGraph 9 | 10 | 11 | class Layer: 12 | def __init__(self, name: str, is_optional: bool = False) -> None: 13 | self.name = name 14 | self.is_optional = is_optional 15 | 16 | 17 | class LayerField(fields.Field): 18 | def parse(self, raw_data: Union[str, List]) -> Layer: 19 | raw_string = fields.StringField().parse(raw_data) 20 | if raw_string.startswith("(") and raw_string.endswith(")"): 21 | layer_name = raw_string[1:-1] 22 | is_optional = True 23 | else: 24 | layer_name = raw_string 25 | is_optional = False 26 | return Layer(name=layer_name, is_optional=is_optional) 27 | 28 | 29 | class LayersContract(Contract): 30 | """ 31 | Defines a 'layered architecture' where there is a unidirectional dependency flow. 32 | 33 | Specifically, higher layers may depend on lower layers, but not the other way around. 34 | To allow for a repeated pattern of layers across a project, you may also define a set of 35 | 'containers', which are treated as the parent package of the layers. 36 | 37 | Layers are required by default: if a layer is listed in the contract, the contract will be 38 | broken if the layer doesn’t exist. You can make a layer optional by wrapping it in parentheses. 39 | 40 | Configuration options: 41 | 42 | - layers: An ordered list of layers. Each layer is the name of a module relative 43 | to its parent package. The order is from higher to lower level layers. 44 | - containers: A list of the parent Modules of the layers (optional). 45 | - ignore_imports: A set of DirectImports. These imports will be ignored: if the import 46 | would cause a contract to be broken, adding it to the set will cause 47 | the contract be kept instead. (Optional.) 48 | """ 49 | 50 | type_name = "layers" 51 | 52 | layers = fields.ListField(subfield=LayerField()) 53 | containers = fields.ListField(subfield=fields.StringField(), required=False) 54 | ignore_imports = fields.SetField(subfield=fields.DirectImportField(), required=False) 55 | 56 | def check(self, graph: ImportGraph) -> ContractCheck: 57 | is_kept = True 58 | invalid_chains = [] 59 | 60 | direct_imports_to_ignore = self.ignore_imports if self.ignore_imports else [] 61 | helpers.pop_imports(graph, direct_imports_to_ignore) # type: ignore 62 | 63 | if self.containers: 64 | self._validate_containers(graph) 65 | else: 66 | self._check_all_containerless_layers_exist(graph) 67 | 68 | for ( 69 | higher_layer_package, 70 | lower_layer_package, 71 | container, 72 | ) in self._generate_module_permutations(graph): 73 | layer_chain_data = self._build_layer_chain_data( 74 | higher_layer_package=higher_layer_package, 75 | lower_layer_package=lower_layer_package, 76 | container=container, 77 | graph=graph, 78 | ) 79 | 80 | if layer_chain_data["chains"]: 81 | is_kept = False 82 | invalid_chains.append(layer_chain_data) 83 | 84 | return ContractCheck(kept=is_kept, metadata={"invalid_chains": invalid_chains}) 85 | 86 | def render_broken_contract(self, check: ContractCheck) -> None: 87 | for chains_data in check.metadata["invalid_chains"]: 88 | higher_layer, lower_layer = (chains_data["higher_layer"], chains_data["lower_layer"]) 89 | output.print(f"{lower_layer} is not allowed to import {higher_layer}:") 90 | output.new_line() 91 | 92 | for chain_data in chains_data["chains"]: 93 | self._render_chain_data(chain_data) 94 | output.new_line() 95 | 96 | output.new_line() 97 | 98 | def _render_chain_data(self, chain_data: Dict) -> None: 99 | main_chain = chain_data["chain"] 100 | self._render_direct_import( 101 | main_chain[0], extra_firsts=chain_data["extra_firsts"], first_line=True 102 | ) 103 | 104 | for direct_import in main_chain[1:-1]: 105 | self._render_direct_import(direct_import) 106 | 107 | if len(main_chain) > 1: 108 | self._render_direct_import(main_chain[-1], extra_lasts=chain_data["extra_lasts"]) 109 | 110 | def _render_direct_import( 111 | self, 112 | direct_import, 113 | first_line: bool = False, 114 | extra_firsts: Optional[List] = None, 115 | extra_lasts: Optional[List] = None, 116 | ) -> None: 117 | import_strings = [] 118 | if extra_firsts: 119 | for position, source in enumerate([direct_import] + extra_firsts[:-1]): 120 | prefix = "& " if position > 0 else "" 121 | importer = source["importer"] 122 | line_numbers = ", ".join(f"l.{n}" for n in source["line_numbers"]) 123 | import_strings.append(f"{prefix}{importer} ({line_numbers})") 124 | importer, imported = extra_firsts[-1]["importer"], extra_firsts[-1]["imported"] 125 | line_numbers = ", ".join(f"l.{n}" for n in extra_firsts[-1]["line_numbers"]) 126 | import_strings.append(f"& {importer} -> {imported} ({line_numbers})") 127 | else: 128 | importer, imported = direct_import["importer"], direct_import["imported"] 129 | line_numbers = ", ".join(f"l.{n}" for n in direct_import["line_numbers"]) 130 | import_strings.append(f"{importer} -> {imported} ({line_numbers})") 131 | 132 | if extra_lasts: 133 | indent_string = (len(direct_import["importer"]) + 4) * " " 134 | for destination in extra_lasts: 135 | imported = destination["imported"] 136 | line_numbers = ", ".join(f"l.{n}" for n in destination["line_numbers"]) 137 | import_strings.append(f"{indent_string}& {imported} ({line_numbers})") 138 | 139 | for position, import_string in enumerate(import_strings): 140 | if first_line and position == 0: 141 | output.print_error(f"- {import_string}", bold=False) 142 | else: 143 | output.print_error(f" {import_string}", bold=False) 144 | 145 | def _validate_containers(self, graph: ImportGraph) -> None: 146 | root_package_names = self.session_options["root_packages"] 147 | for container in self.containers: # type: ignore 148 | if Module(container).root_package_name not in root_package_names: 149 | if len(root_package_names) == 1: 150 | root_package_name = root_package_names[0] 151 | error_message = ( 152 | f"Invalid container '{container}': a container must either be a " 153 | f"subpackage of {root_package_name}, or {root_package_name} itself." 154 | ) 155 | else: 156 | packages_string = ", ".join(root_package_names) 157 | error_message = ( 158 | f"Invalid container '{container}': a container must either be a root " 159 | f"package, or a subpackage of one of them. " 160 | f"(The root packages are: {packages_string}.)" 161 | ) 162 | raise ValueError(error_message) 163 | self._check_all_layers_exist_for_container(container, graph) 164 | 165 | def _check_all_layers_exist_for_container(self, container: str, graph: ImportGraph) -> None: 166 | for layer in self.layers: # type: ignore 167 | if layer.is_optional: 168 | continue 169 | layer_module_name = ".".join([container, layer.name]) 170 | if layer_module_name not in graph.modules: 171 | raise ValueError( 172 | f"Missing layer in container '{container}': " 173 | f"module {layer_module_name} does not exist." 174 | ) 175 | 176 | def _check_all_containerless_layers_exist(self, graph: ImportGraph) -> None: 177 | for layer in self.layers: # type: ignore 178 | if layer.is_optional: 179 | continue 180 | if layer.name not in graph.modules: 181 | raise ValueError( 182 | f"Missing layer '{layer.name}': module {layer.name} does not exist." 183 | ) 184 | 185 | def _generate_module_permutations( 186 | self, graph: ImportGraph 187 | ) -> Iterator[Tuple[Module, Module, Optional[str]]]: 188 | """ 189 | Return all possible combinations of higher level and lower level modules, in pairs. 190 | 191 | Each pair of modules consists of immediate children of two different layers. The first 192 | module is in a layer higher than the layer of the second module. This means the first 193 | module is allowed to import the second, but not the other way around. 194 | 195 | Returns: 196 | module_in_higher_layer, module_in_lower_layer, container 197 | """ 198 | # If there are no containers, we still want to run the loop once. 199 | quasi_containers = self.containers or [None] # type: ignore 200 | 201 | for container in quasi_containers: # type: ignore 202 | for index, higher_layer in enumerate(self.layers): # type: ignore 203 | higher_layer_module = self._module_from_layer(higher_layer, container) 204 | 205 | if higher_layer_module.name not in graph.modules: 206 | continue 207 | 208 | for lower_layer in self.layers[index + 1 :]: # type: ignore 209 | 210 | lower_layer_module = self._module_from_layer(lower_layer, container) 211 | 212 | if lower_layer_module.name not in graph.modules: 213 | continue 214 | 215 | yield higher_layer_module, lower_layer_module, container 216 | 217 | def _module_from_layer(self, layer: Layer, container: Optional[str] = None) -> Module: 218 | if container: 219 | name = ".".join([container, layer.name]) 220 | else: 221 | name = layer.name 222 | return Module(name) 223 | 224 | def _build_layer_chain_data( 225 | self, 226 | higher_layer_package: Module, 227 | lower_layer_package: Module, 228 | container: Optional[str], 229 | graph: ImportGraph, 230 | ) -> Dict[str, Any]: 231 | """ 232 | Build a dictionary of illegal chains between two layers, in the form: 233 | 234 | higher_layer (str): Higher layer package name. 235 | lower_layer (str): Lower layer package name. 236 | chains (list): List of lists. 237 | """ 238 | layer_chain_data = { 239 | "higher_layer": higher_layer_package.name, 240 | "lower_layer": lower_layer_package.name, 241 | "chains": [], 242 | } 243 | assert isinstance(layer_chain_data["chains"], list) # For type checker. 244 | 245 | temp_graph = copy.deepcopy(graph) 246 | self._remove_other_layers( 247 | temp_graph, 248 | container=container, 249 | layers_to_preserve=(higher_layer_package, lower_layer_package), 250 | ) 251 | # Assemble direct imports between the layers, then remove them. 252 | import_details_between_layers = self._pop_direct_imports( 253 | higher_layer_package=higher_layer_package, 254 | lower_layer_package=lower_layer_package, 255 | graph=temp_graph, 256 | ) 257 | collapsed_direct_chains = [] 258 | for import_details_list in import_details_between_layers: 259 | line_numbers = tuple(j["line_number"] for j in import_details_list) 260 | collapsed_direct_chains.append( 261 | { 262 | "chain": [ 263 | { 264 | "importer": import_details_list[0]["importer"], 265 | "imported": import_details_list[0]["imported"], 266 | "line_numbers": line_numbers, 267 | } 268 | ], 269 | "extra_firsts": [], 270 | "extra_lasts": [], 271 | } 272 | ) 273 | 274 | layer_chain_data = { 275 | "higher_layer": higher_layer_package.name, 276 | "lower_layer": lower_layer_package.name, 277 | "chains": collapsed_direct_chains, # type: ignore 278 | } 279 | 280 | indirect_chain_data = self._get_indirect_collapsed_chains( 281 | temp_graph, importer_package=lower_layer_package, imported_package=higher_layer_package 282 | ) 283 | layer_chain_data["chains"].extend(indirect_chain_data) # type: ignore 284 | 285 | return layer_chain_data 286 | 287 | @classmethod 288 | def _get_indirect_collapsed_chains(cls, graph, importer_package, imported_package): 289 | """ 290 | Squashes the two packages. 291 | Gets a list of paths between them, called segments. 292 | Add the heads and tails to the segments. 293 | Return a list of detailed chains in the following format: 294 | 295 | [ 296 | { 297 | "chain": , 298 | "extra_firsts": [ 299 | , 300 | ... 301 | ], 302 | "extra_lasts": [ 303 | , 304 | , 305 | ... 306 | ], 307 | } 308 | ] 309 | """ 310 | temp_graph = copy.deepcopy(graph) 311 | 312 | temp_graph.squash_module(importer_package.name) 313 | temp_graph.squash_module(imported_package.name) 314 | 315 | segments = cls._find_segments( 316 | temp_graph, importer=importer_package, imported=imported_package 317 | ) 318 | return cls._segments_to_collapsed_chains( 319 | graph, segments, importer=importer_package, imported=imported_package 320 | ) 321 | 322 | @classmethod 323 | def _find_segments(cls, graph, importer: Module, imported: Module): 324 | """ 325 | Return list of headless and tailless detailed chains. 326 | """ 327 | segments = [] 328 | for chain in cls._pop_shortest_chains( 329 | graph, importer=importer.name, imported=imported.name 330 | ): 331 | if len(chain) == 2: 332 | raise ValueError("Direct chain found - these should have been removed.") 333 | detailed_chain = [] 334 | for importer, imported in [(chain[i], chain[i + 1]) for i in range(len(chain) - 1)]: 335 | import_details = graph.get_import_details(importer=importer, imported=imported) 336 | line_numbers = tuple(set(j["line_number"] for j in import_details)) 337 | detailed_chain.append( 338 | {"importer": importer, "imported": imported, "line_numbers": line_numbers} 339 | ) 340 | segments.append(detailed_chain) 341 | return segments 342 | 343 | @classmethod 344 | def _pop_shortest_chains(cls, graph, importer, imported): 345 | chain = True 346 | while chain: 347 | chain = graph.find_shortest_chain(importer, imported) 348 | if chain: 349 | # Remove chain of imports from graph. 350 | for index in range(len(chain) - 1): 351 | graph.remove_import(importer=chain[index], imported=chain[index + 1]) 352 | yield chain 353 | 354 | @classmethod 355 | def _segments_to_collapsed_chains(cls, graph, segments, importer: Module, imported: Module): 356 | collapsed_chains = [] 357 | for segment in segments: 358 | head_imports = [] 359 | imported_module = segment[0]["imported"] 360 | candidate_modules = sorted(graph.find_modules_that_directly_import(imported_module)) 361 | for module in [ 362 | m 363 | for m in candidate_modules 364 | if Module(m) == importer or Module(m).is_descendant_of(importer) 365 | ]: 366 | import_details_list = graph.get_import_details( 367 | importer=module, imported=imported_module 368 | ) 369 | line_numbers = tuple(set(j["line_number"] for j in import_details_list)) 370 | head_imports.append( 371 | {"importer": module, "imported": imported_module, "line_numbers": line_numbers} 372 | ) 373 | 374 | tail_imports = [] 375 | importer_module = segment[-1]["importer"] 376 | candidate_modules = sorted(graph.find_modules_directly_imported_by(importer_module)) 377 | for module in [ 378 | m 379 | for m in candidate_modules 380 | if Module(m) == imported or Module(m).is_descendant_of(imported) 381 | ]: 382 | import_details_list = graph.get_import_details( 383 | importer=importer_module, imported=module 384 | ) 385 | line_numbers = tuple(set(j["line_number"] for j in import_details_list)) 386 | tail_imports.append( 387 | {"importer": importer_module, "imported": module, "line_numbers": line_numbers} 388 | ) 389 | 390 | collapsed_chains.append( 391 | { 392 | "chain": [head_imports[0]] + segment[1:-1] + [tail_imports[0]], 393 | "extra_firsts": head_imports[1:], 394 | "extra_lasts": tail_imports[1:], 395 | } 396 | ) 397 | 398 | return collapsed_chains 399 | 400 | def _remove_other_layers(self, graph, container, layers_to_preserve): 401 | for index, layer in enumerate(self.layers): # type: ignore 402 | candidate_layer = self._module_from_layer(layer, container) 403 | if candidate_layer.name in graph.modules and candidate_layer not in layers_to_preserve: 404 | self._remove_layer(graph, layer_package=candidate_layer) 405 | 406 | def _remove_layer(self, graph, layer_package): 407 | for module in graph.find_descendants(layer_package.name): 408 | graph.remove_module(module) 409 | graph.remove_module(layer_package.name) 410 | 411 | @classmethod 412 | def _pop_direct_imports(cls, higher_layer_package, lower_layer_package, graph): 413 | import_details_list = [] 414 | lower_layer_modules = {lower_layer_package.name} | graph.find_descendants( 415 | lower_layer_package.name 416 | ) 417 | for lower_layer_module in lower_layer_modules: 418 | imported_modules = graph.find_modules_directly_imported_by(lower_layer_module) 419 | for imported_module in imported_modules: 420 | if Module(imported_module) == higher_layer_package or Module( 421 | imported_module 422 | ).is_descendant_of(higher_layer_package): 423 | import_details = graph.get_import_details( 424 | importer=lower_layer_module, imported=imported_module 425 | ) 426 | if not import_details: 427 | # get_import_details may not return any imports (for example if an import 428 | # has been added without metadata. If nothing is returned, we still want 429 | # to add some details about the import to the list. 430 | import_details = [ 431 | { 432 | "importer": lower_layer_module, 433 | "imported": imported_module, 434 | "line_number": "?", 435 | "line_contents": "", 436 | } 437 | ] 438 | import_details_list.append(import_details) 439 | graph.remove_import(importer=lower_layer_module, imported=imported_module) 440 | return import_details_list 441 | --------------------------------------------------------------------------------