├── .editorconfig ├── .github └── ISSUE_TEMPLATE.md ├── .gitignore ├── .travis.yml ├── AUTHORS.rst ├── CONTRIBUTING.rst ├── HISTORY.rst ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.rst ├── codecov.yml ├── docs ├── Makefile ├── authors.rst ├── concepts.rst ├── conf.py ├── contributing.rst ├── history.rst ├── img │ ├── complex-graph.png │ ├── layers.png │ ├── modular-layers.png │ └── simple-graph.png ├── index.rst ├── installation.rst ├── make.bat ├── migrating-to-import-linter.rst └── usage.rst ├── layers.yml ├── requirements_dev.txt ├── setup.cfg ├── setup.py ├── src └── layer_linter │ ├── __init__.py │ ├── __main__.py │ ├── cmdline.py │ ├── contract.py │ ├── dependencies │ ├── __init__.py │ ├── analysis.py │ ├── graph.py │ ├── path.py │ └── scanner.py │ ├── module.py │ └── report.py ├── tests ├── __init__.py ├── assets │ ├── analyzerpackage │ │ └── analyzerpackage │ │ │ ├── __init__.py │ │ │ ├── notsupplied │ │ │ ├── README.txt │ │ │ ├── __init__.py │ │ │ └── alpha.py │ │ │ ├── one │ │ │ ├── __init__.py │ │ │ ├── alpha.py │ │ │ ├── beta.py │ │ │ └── gamma.py │ │ │ ├── two │ │ │ ├── __init__.py │ │ │ ├── alpha.py │ │ │ ├── beta.py │ │ │ └── gamma.py │ │ │ └── utils.py │ ├── dependenciespackage │ │ └── dependenciespackage │ │ │ ├── __init__.py │ │ │ ├── four.py │ │ │ ├── one.py │ │ │ ├── subpackage │ │ │ ├── .hidden │ │ │ │ ├── __init__.py │ │ │ │ └── hidden.py │ │ │ ├── __init__.py │ │ │ ├── is.py │ │ │ ├── migrations │ │ │ │ ├── __init__.py │ │ │ │ ├── migration_1.py │ │ │ │ └── migration_2.py │ │ │ ├── one.py │ │ │ ├── subsubpackage │ │ │ │ ├── __init__.py │ │ │ │ ├── one.py │ │ │ │ ├── three.py │ │ │ │ └── two.py │ │ │ ├── three.py │ │ │ └── two.py │ │ │ ├── three.py │ │ │ └── two.py │ ├── differentimporttypes │ │ └── differentimporttypes │ │ │ ├── __init__.py │ │ │ ├── five │ │ │ ├── __init__.py │ │ │ └── alpha.py │ │ │ ├── four │ │ │ ├── __init__.py │ │ │ └── alpha.py │ │ │ ├── one │ │ │ ├── __init__.py │ │ │ ├── alpha.py │ │ │ ├── beta.py │ │ │ ├── delta.py │ │ │ ├── epsilon.py │ │ │ ├── gamma │ │ │ │ ├── __init__.py │ │ │ │ └── foo.py │ │ │ └── importer.py │ │ │ ├── three.py │ │ │ └── two │ │ │ ├── __init__.py │ │ │ └── alpha.py │ ├── failurepackage │ │ ├── failurepackage │ │ │ ├── __init__.py │ │ │ ├── one │ │ │ │ ├── __init__.py │ │ │ │ ├── alpha.py │ │ │ │ ├── beta.py │ │ │ │ └── gamma.py │ │ │ ├── three │ │ │ │ ├── __init__.py │ │ │ │ ├── alpha.py │ │ │ │ ├── beta.py │ │ │ │ └── gamma.py │ │ │ └── two │ │ │ │ ├── __init__.py │ │ │ │ ├── alpha.py │ │ │ │ ├── beta.py │ │ │ │ └── gamma.py │ │ └── layers.yml │ ├── initfileimports │ │ └── initfileimports │ │ │ ├── __init__.py │ │ │ ├── alpha.py │ │ │ ├── one │ │ │ ├── __init__.py │ │ │ └── alpha.py │ │ │ └── two │ │ │ └── __init__.py │ ├── scannersuccess │ │ └── scannersuccess │ │ │ ├── __init__.py │ │ │ ├── four.py │ │ │ ├── in │ │ │ ├── __init__.py │ │ │ ├── class.py │ │ │ └── hyphenated-name.py │ │ │ ├── notapackage │ │ │ ├── README.txt │ │ │ └── orphan │ │ │ │ ├── __init__.py │ │ │ │ └── foo.py │ │ │ ├── one │ │ │ ├── __init__.py │ │ │ ├── alpha.py │ │ │ ├── beta.py │ │ │ ├── delta │ │ │ │ ├── __init__.py │ │ │ │ ├── green.py │ │ │ │ └── red_blue.py │ │ │ └── gamma.py │ │ │ └── two │ │ │ ├── __init__.py │ │ │ ├── alpha.py │ │ │ ├── beta.py │ │ │ └── gamma.py │ ├── singlecontractfile │ │ ├── layers.yml │ │ ├── layers_with_missing_container.yml │ │ └── singlecontractfile │ │ │ ├── __init__.py │ │ │ ├── bar │ │ │ └── __init__.py │ │ │ └── foo │ │ │ └── __init__.py │ └── successpackage │ │ ├── layers.yml │ │ ├── layers_alternative.yml │ │ ├── layers_with_missing_container.yml │ │ └── successpackage │ │ ├── __init__.py │ │ ├── one │ │ ├── __init__.py │ │ ├── alpha.py │ │ ├── beta.py │ │ ├── delta.py │ │ └── gamma.py │ │ ├── three │ │ ├── __init__.py │ │ ├── alpha.py │ │ ├── beta.py │ │ ├── delta.py │ │ └── gamma.py │ │ ├── two │ │ ├── __init__.py │ │ ├── alpha.py │ │ ├── beta.py │ │ └── gamma.py │ │ └── utils.py ├── functional │ ├── __init__.py │ ├── dependencies │ │ ├── __init__.py │ │ ├── test_analysis.py │ │ ├── test_graph.py │ │ └── test_scanner.py │ ├── test_get_contracts.py │ └── test_main.py └── unit │ ├── __init__.py │ ├── dependencies │ ├── __init__.py │ ├── test_graph.py │ └── test_path.py │ ├── test_cmdline.py │ ├── test_contract.py │ └── test_report.py └── tox.ini /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | charset = utf-8 11 | end_of_line = lf 12 | 13 | [*.bat] 14 | indent_style = tab 15 | end_of_line = crlf 16 | 17 | [LICENSE] 18 | insert_final_newline = false 19 | 20 | [Makefile] 21 | indent_style = tab 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | * Layer Linter version: 2 | * Python version: 3 | * Operating System: 4 | 5 | ### Description 6 | 7 | Describe what you were trying to get done. 8 | Tell us what happened, what went wrong, and what you expected to happen. 9 | 10 | ### What I Did 11 | 12 | ``` 13 | Paste the command(s) you ran and the output. 14 | If there was a crash, please include the traceback here. 15 | ``` 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | 58 | # Flask stuff: 59 | instance/ 60 | .webassets-cache 61 | 62 | # Scrapy stuff: 63 | .scrapy 64 | 65 | # Sphinx documentation 66 | docs/_build/ 67 | 68 | # PyBuilder 69 | target/ 70 | 71 | # Jupyter Notebook 72 | .ipynb_checkpoints 73 | 74 | # pyenv 75 | .python-version 76 | 77 | # celery beat schedule file 78 | celerybeat-schedule 79 | 80 | # SageMath parsed files 81 | *.sage.py 82 | 83 | # dotenv 84 | .env 85 | 86 | # virtualenv 87 | .venv 88 | venv/ 89 | ENV/ 90 | 91 | # Spyder project settings 92 | .spyderproject 93 | .spyproject 94 | 95 | # Rope project settings 96 | .ropeproject 97 | 98 | # mkdocs documentation 99 | /site 100 | 101 | # mypy 102 | .mypy_cache/ 103 | 104 | # IDEs 105 | .idea/ 106 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | matrix: 3 | include: 4 | - name: "Python 3.7" 5 | python: "3.7" 6 | env: TOXENV=py37 7 | dist: xenial 8 | sudo: required 9 | - name: "Python 3.6" 10 | python: "3.6" 11 | env: TOXENV=py36 12 | - name: "Flake8" 13 | python: "3.6" 14 | env: TOXENV=flake8 15 | - name: "Layer lint" 16 | python: "3.6" 17 | env: TOXENV=layer_lint 18 | - name: "Mypy" 19 | python: "3.6" 20 | env: TOXENV=mypy 21 | - name: "RST linting" 22 | python: "3.6" 23 | env: TOXENV=rst 24 | install: pip install -U tox-travis tox-py-backwards 25 | script: tox 26 | deploy: 27 | provider: pypi 28 | distributions: sdist bdist_wheel 29 | user: seddonym 30 | password: 31 | secure: ovfnLrym9NcLxZ/d2amk905rU03DfZ5bKR6RiTO2ydbV+4qSxhT4/PcX3EU1zOyZxEDPPx8mTPpXiCveaAs6ozxlzlsnalrLAAjV1BdNCfYBdByeMQGkmsVuXosIS0HBgF2TSRQuhuo5SnYDhefCttajKaszRX1dVUK0WBJjXeILJTuE6ayP38X3M4sVj5mNUKt2MpxFQsBgYWdzfwor8opk6kIdUV21Fwwj3W88/zoePsNn0qniEi36+xneZ9LgblMA/geMPzVBv2g7aeFn+licbCeyU/6DhZsp59q8HJbMdupCBM3occv27of7ifhbL/aLIMoS+I8AUtm9VJFENRJ9LHr2yoi2DtRpUvrLOHq/w9XWdyqhVF126yIfiDTHyOaBHcXQ/3E7CVG3KUCTdIgUiABxzNVTD4Nf1wwiEaz6YR77ji2ePARwlgnspPD0qH3bPOgS2d6ymZrUsLo3vo/sP+7ljaw0Po1XfJ8reO6WpB5KhX5U/+rFs3/lnjBUPZj2xkCSucbiOBEniRt2yrYHTpoxTE6mb1ChJY5TNmy/hwuUfSIWKE6FurEDrxSwkySSPfRjmQZKfnnmRgmpBehzRq6SeMaYmsSKgeeypyAgiUpNSvSJTbEgakEVptSpRo26x6aML3eAEvkcRBinKF0LXM5Riaefojv6zPH53F8= 32 | on: 33 | tags: true 34 | repo: seddonym/layer_linter 35 | python: 3.6 36 | 37 | after_success: 38 | - pip install codecov 39 | - codecov 40 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Credits 3 | ======= 4 | 5 | Development Lead 6 | ---------------- 7 | 8 | * David Seddon 9 | 10 | 11 | Contributors 12 | ------------ 13 | 14 | * Thiago Colares https://github.com/colares 15 | * James Cooke https://github.com/jamescooke 16 | * Simon Biggs https://github.com/SimonBiggs 17 | 18 | Other credits 19 | ------------- 20 | 21 | This package was created with Cookiecutter_ and the `audreyr/cookiecutter-pypackage`_ project template. 22 | 23 | .. _documentation: https://layer-linter.readthedocs.io 24 | .. _Cookiecutter: https://github.com/audreyr/cookiecutter 25 | .. _`audreyr/cookiecutter-pypackage`: https://github.com/audreyr/cookiecutter-pypackage 26 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: shell 2 | 3 | ============ 4 | Contributing 5 | ============ 6 | 7 | This package has now been superseded by `Import Linter`_, so we are not considering new features here. If you 8 | wish to contribute, please get involved with that package. 9 | 10 | .. _Import Linter: https://github.com/seddonym/import-linter 11 | -------------------------------------------------------------------------------- /HISTORY.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | History 3 | ======= 4 | 5 | 0.1.0 (2018-06-20) 6 | ------------------ 7 | 8 | * First release on PyPI. 9 | 10 | 0.2.0 (2018-06-23) 11 | ------------------ 12 | 13 | * Look for ``layers.yml`` in current working directory. 14 | 15 | 0.3.0 (2018-06-24) 16 | ------------------ 17 | 18 | * Renamed command to ``layer-lint``. 19 | * Changed order of layers in ``layers.yml`` to be listed high level to low level. 20 | 21 | 0.4.0 (2018-07-22) 22 | ------------------ 23 | 24 | * Made dependency analysis more efficient and robust. 25 | * Improved report formatting. 26 | * Removed illegal dependencies that were implied by other, more succinct illegal dependencies. 27 | * Added ``--debug`` command line argument. 28 | 29 | 0.5.0 (2018-08-01) 30 | ------------------ 31 | 32 | * Added count of analysed files and dependencies to report. 33 | * Fixed issues from running command in a different directory to the package. 34 | * Increased speed of analysis. 35 | * Changed ``--config_directory`` command line argument to ``--config-directory``. 36 | 37 | 0.6.0 (2018-08-07) 38 | ------------------ 39 | 40 | * Added ability to whitelist paths. 41 | 42 | 0.6.1 (2018-08-07) 43 | ------------------ 44 | 45 | * Added current working directory to path. 46 | 47 | 0.6.2 (2018-08-17) 48 | ------------------ 49 | 50 | * Don't analyse children of directories that aren't Python packages. 51 | * Prevented installing incompatible version of Pydeps (1.6). 52 | 53 | 0.7.0 (2018-09-04) 54 | ------------------ 55 | 56 | * Completed rewrite of static analysis used to build dependency graph. 57 | * Added quiet and verbose reporting. 58 | * Added type annotation and mypy. 59 | * Built earlier versions of Python using pybackwards. 60 | * Corrected docs to refer to ``layers.yml`` instead of ``layers.yaml``. 61 | 62 | 0.7.1 (2018-09-04) 63 | ------------------ 64 | 65 | * Fixed packaging bug with 0.7.0. 66 | 67 | 0.7.2 (2018-09-05) 68 | ------------------ 69 | 70 | * Fixed bug with not checking all submodules of layer. 71 | 72 | 0.7.3 (2018-09-07) 73 | ------------------ 74 | 75 | * Dropped support for Python 3.4 and 3.5 and adjust packaging. 76 | 77 | 0.7.4 (2018-09-20) 78 | ------------------ 79 | 80 | * Tweaked command line error handling. 81 | * Improved README and `Core Concepts` documentation. 82 | 83 | 0.8.0 (2018-09-29) 84 | ------------------ 85 | 86 | * Replace ``--config-directory`` parameter with ``--config`` parameter, which takes a file name instead. 87 | 88 | 0.9.0 (2018-10-13) 89 | ------------------ 90 | 91 | * Moved to beta version. 92 | * Improved documentation. 93 | * Better handling of invalid package names passed to command line. 94 | 95 | 0.10.0 (2018-10-14) 96 | ------------------- 97 | 98 | * Renamed 'packages' to 'containers' in contracts. 99 | 100 | 0.10.1 (2018-10-14) 101 | ------------------- 102 | 103 | * Improved handling of invalid containers. 104 | 105 | 0.10.2 (2018-10-17) 106 | ------------------- 107 | 108 | * Error if a layer is missing. 109 | 110 | 0.10.3 (2018-11-2) 111 | ------------------ 112 | 113 | * Fixed RST rendering on PyPI. 114 | 115 | 0.11.0 (2018-11-5) 116 | ------------------ 117 | 118 | * Support defining optional layers. 119 | 120 | 0.11.1 (2019-1-16) 121 | ------------------ 122 | 123 | * Updated dependencies, especially switching to a version of PyYAML to 124 | address https://nvd.nist.gov/vuln/detail/CVE-2017-18342. 125 | 126 | 0.12.0 (2019-1-16) 127 | ------------------ 128 | 129 | * Fix parsing of relative imports within __init__.py files. 130 | 131 | 0.12.1 (2019-2-2) 132 | ----------------- 133 | 134 | * Add support for Click 7.x. 135 | 136 | 0.12.2 (2019-3-20) 137 | ------------------ 138 | 139 | * Fix bug with Windows file paths. 140 | 141 | 142 | 0.12.3 (2019-6-8) 143 | ----------------- 144 | 145 | * Deprecate Layer Linter in favour of Import Linter. 146 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | 3 | BSD License 4 | 5 | Copyright (c) 2018, David Seddon 6 | All rights reserved. 7 | 8 | Redistribution and use in source and binary forms, with or without modification, 9 | are permitted provided that the following conditions are met: 10 | 11 | * Redistributions of source code must retain the above copyright notice, this 12 | list of conditions and the following disclaimer. 13 | 14 | * Redistributions in binary form must reproduce the above copyright notice, this 15 | list of conditions and the following disclaimer in the documentation and/or 16 | other materials provided with the distribution. 17 | 18 | * Neither the name of the copyright holder nor the names of its 19 | contributors may be used to endorse or promote products derived from this 20 | software without specific prior written permission. 21 | 22 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 23 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 24 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. 25 | IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, 26 | INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 27 | BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 28 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY 29 | OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE 30 | OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED 31 | OF THE POSSIBILITY OF SUCH DAMAGE. 32 | 33 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS.rst 2 | include CONTRIBUTING.rst 3 | include HISTORY.rst 4 | include LICENSE 5 | include README.rst 6 | 7 | recursive-include tests * 8 | recursive-exclude * __pycache__ 9 | recursive-exclude * *.py[co] 10 | 11 | recursive-include docs *.rst conf.py Makefile make.bat *.jpg *.png *.gif 12 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean clean-test clean-pyc clean-build docs help 2 | .DEFAULT_GOAL := help 3 | 4 | define BROWSER_PYSCRIPT 5 | import os, webbrowser, sys 6 | 7 | try: 8 | from urllib import pathname2url 9 | except: 10 | from urllib.request import pathname2url 11 | 12 | webbrowser.open("file://" + pathname2url(os.path.abspath(sys.argv[1]))) 13 | endef 14 | export BROWSER_PYSCRIPT 15 | 16 | define PRINT_HELP_PYSCRIPT 17 | import re, sys 18 | 19 | for line in sys.stdin: 20 | match = re.match(r'^([a-zA-Z_-]+):.*?## (.*)$$', line) 21 | if match: 22 | target, help = match.groups() 23 | print("%-20s %s" % (target, help)) 24 | endef 25 | export PRINT_HELP_PYSCRIPT 26 | 27 | BROWSER := python -c "$$BROWSER_PYSCRIPT" 28 | 29 | help: 30 | @python -c "$$PRINT_HELP_PYSCRIPT" < $(MAKEFILE_LIST) 31 | 32 | clean: clean-build clean-pyc clean-test ## remove all build, test, coverage and Python artifacts 33 | 34 | clean-build: ## remove build artifacts 35 | rm -fr build/ 36 | rm -fr dist/ 37 | rm -fr .eggs/ 38 | find . -name '*.egg-info' -exec rm -fr {} + 39 | find . -name '*.egg' -exec rm -f {} + 40 | 41 | clean-pyc: ## remove Python file artifacts 42 | find . -name '*.pyc' -exec rm -f {} + 43 | find . -name '*.pyo' -exec rm -f {} + 44 | find . -name '*~' -exec rm -f {} + 45 | find . -name '__pycache__' -exec rm -fr {} + 46 | 47 | clean-test: ## remove test and coverage artifacts 48 | rm -fr .tox/ 49 | rm -f .coverage 50 | rm -fr htmlcov/ 51 | rm -fr .pytest_cache 52 | 53 | lint: ## check style with flake8 54 | flake8 layer_linter tests 55 | 56 | test: ## run tests quickly with the default Python 57 | py.test 58 | 59 | test-all: ## run tests on every Python version with tox 60 | tox 61 | 62 | coverage: ## check code coverage quickly with the default Python 63 | coverage run --source layer_linter -m pytest 64 | coverage report -m 65 | coverage html 66 | $(BROWSER) htmlcov/index.html 67 | 68 | docs: ## generate Sphinx HTML documentation, including API docs 69 | rm -f docs/layer_linter.rst 70 | rm -f docs/modules.rst 71 | sphinx-apidoc -o docs/ layer_linter 72 | $(MAKE) -C docs clean 73 | $(MAKE) -C docs html 74 | $(BROWSER) docs/_build/html/index.html 75 | 76 | servedocs: docs ## compile the docs watching for changes 77 | watchmedo shell-command -p '*.rst' -c '$(MAKE) -C docs html' -R -D . 78 | 79 | release: dist ## package and upload a release 80 | twine upload dist/* 81 | 82 | dist: clean ## builds source and wheel package 83 | python setup.py sdist 84 | python setup.py bdist_wheel 85 | ls -l dist 86 | 87 | install: clean ## install the package to the active Python's site-packages 88 | python setup.py install 89 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Layer Linter 3 | ============ 4 | 5 | 6 | .. image:: https://img.shields.io/pypi/status/layer-linter 7 | 8 | .. image:: https://img.shields.io/pypi/v/layer_linter.svg 9 | :target: https://pypi.python.org/pypi/layer_linter 10 | 11 | .. image:: https://img.shields.io/pypi/pyversions/layer-linter.svg 12 | :alt: Python versions 13 | :target: http://pypi.python.org/pypi/layer-linter/ 14 | 15 | .. image:: https://api.travis-ci.org/seddonym/layer_linter.svg?branch=master 16 | :target: https://travis-ci.org/seddonym/layer_linter 17 | 18 | .. image:: https://codecov.io/gh/seddonym/layer_linter/branch/master/graph/badge.svg 19 | :target: https://codecov.io/gh/seddonym/layer_linter 20 | 21 | .. image:: https://readthedocs.org/projects/layer-linter/badge/?version=latest 22 | :target: https://layer-linter.readthedocs.io/en/latest/?badge=latest 23 | :alt: Documentation Status 24 | 25 | 26 | **Layer Linter has been deprecated in favour of Import Linter.** 27 | 28 | `Import Linter`_ does everything Layer Linter does, but with more features and a slightly different API. 29 | If you're already using Layer Linter, migrating to Import Linter is simple: there is a guide here_. 30 | 31 | Outline 32 | ------- 33 | 34 | Layer Linter checks that your project follows a custom-defined layered architecture, based on 35 | its internal dependencies (i.e. the imports between its modules). 36 | 37 | * Free software: BSD license 38 | * Documentation: https://layer-linter.readthedocs.io. 39 | 40 | .. _Import Linter: https://github.com/seddonym/import-linter 41 | .. _here: https://layer-linter.readthedocs.io/en/latest/migrating-to-import-linter.html 42 | 43 | Overview 44 | -------- 45 | 46 | Layer Linter is a command line tool to check that you are following a self-imposed 47 | architecture within your Python project. It does this by analysing the internal 48 | imports between all the modules in your code base, and compares this 49 | against a set of simple rules that you provide in a ``layers.yml`` file. 50 | 51 | For example, you can use it to check that no modules inside ``myproject.foo`` 52 | import from any modules inside ``myproject.bar``, even indirectly. 53 | 54 | This is particularly useful if you are working on a complex codebase within a team, 55 | when you want to enforce a particular architectural style. In this case you can add 56 | Layer Linter to your deployment pipeline, so that any code that does not follow 57 | the architecture will fail tests. 58 | 59 | Quick start 60 | ----------- 61 | 62 | Install Layer Linter:: 63 | 64 | pip install layer-linter 65 | 66 | Decide on the dependency flows you wish to check. In this example, we have 67 | organised our project into three subpackages, ``myproject.high``, ``myproject.medium`` 68 | and ``myproject.low``. These subpackages are known as *layers*. Note: layers must 69 | have the same parent package (i.e. all be in the same directory). This parent is known as a *container*. 70 | 71 | Create a ``layers.yml`` in the root of your project. For example:: 72 | 73 | My Layers Contract: 74 | containers: 75 | - myproject 76 | layers: 77 | - high 78 | - medium 79 | - low 80 | 81 | (This contract tells Layer Linter that the order of the layers runs from ``low`` at the bottom 82 | to ``high`` at the top. Layers higher up can import ones lower down, but not the other way around.) 83 | 84 | Note that the container is an absolute name of a Python package, while the layers are relative to the container. 85 | 86 | Now, from your project root, run:: 87 | 88 | layer-lint myproject 89 | 90 | If your code violates the contract, you will see an error message something like this:: 91 | 92 | ============ 93 | Layer Linter 94 | ============ 95 | 96 | --------- 97 | Contracts 98 | --------- 99 | 100 | Analyzed 23 files, 44 dependencies. 101 | ----------------------------------- 102 | 103 | My layer contract BROKEN 104 | 105 | Contracts: 0 kept, 1 broken. 106 | 107 | ---------------- 108 | Broken contracts 109 | ---------------- 110 | 111 | 112 | My layer contract 113 | ----------------- 114 | 115 | 116 | 1. myproject.low.x imports myproject.high.y: 117 | 118 | myproject.low.x <- 119 | myproject.utils <- 120 | myproject.high.y 121 | 122 | For more details, see `Usage`_. 123 | 124 | .. _Usage: https://layer-linter.readthedocs.io/en/latest/usage.html 125 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | target: 70 6 | patch: no 7 | changes: no 8 | 9 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = python -msphinx 7 | SPHINXPROJ = layer_linter 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) 21 | -------------------------------------------------------------------------------- /docs/authors.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../AUTHORS.rst 2 | -------------------------------------------------------------------------------- /docs/concepts.rst: -------------------------------------------------------------------------------- 1 | ============= 2 | Core concepts 3 | ============= 4 | 5 | The Dependency Graph 6 | -------------------- 7 | 8 | At the heart of Layer Linter is a graph of internal dependencies within 9 | a Python code base. This is a `graph in a mathematical sense`_: a collection 10 | of items with relationships between them. In this case, the items are 11 | Python modules, and the relationships are imports between them. 12 | 13 | .. _graph in a mathematical sense: https://en.wikipedia.org/wiki/Graph_(discrete_mathematics) 14 | 15 | For example, a project named ``pets`` with two modules, where ``pets.dogs`` imports ``pets.cats``, would have a graph 16 | like this: 17 | 18 | .. image:: img/simple-graph.png 19 | :align: center 20 | 21 | Note the direction of the arrow, which we'll use throughout: the arrow points from the imported 22 | module into the importing module. 23 | 24 | If the project was larger, it would have a more complex graph: 25 | 26 | .. image:: img/complex-graph.png 27 | :align: center 28 | 29 | When you run Layer Linter, it statically analyses all of your 30 | code to produce a graph like this. (Note: these are just visual representations of the underlying data structure; 31 | Layer Linter has no visual output.) 32 | 33 | Layers 34 | ------ 35 | 36 | Layers are a concept used in software architecture. 37 | They describe an application organized into distinct sections, or *layers*. 38 | 39 | In such an architecture, lower layers should be ignorant of higher ones. This means 40 | that code in a higher layer can use utilities provided in a lower layer, 41 | but not the other way around. In other words, there is a dependency flow from 42 | low to high. 43 | 44 | **Layers in Python** 45 | 46 | In Python, you can think of a layer as a single ``.py`` file, or a package containing 47 | multiple ``.py`` files. This layer is grouped with other layers, all sharing a common parent package: 48 | in other words, a group of layers will all be in the same directory, at the same level. 49 | Layer Linter calls this common parent a *container*. 50 | 51 | Within a single group of layers, any file within a higher up layer may import from any file lower down, 52 | but not the other way around - even indirectly. 53 | 54 | .. image:: img/layers.png 55 | :align: center 56 | 57 | The above example shows a single group consisting of three layers. Their container is the top level package, ``pets``. 58 | According to the constraints imposed by layers, ``pets.cats.purring`` may import ``pets.rabbits`` but not 59 | ``pets.dogs.walkies``. ``pets.dogs.walkies`` may import any other module, as it is in the highest layer. 60 | 61 | (For further reading on Layers, see 62 | `the Wikipedia page on Multitier Architecture`_). 63 | 64 | .. _`the Wikipedia page on Multitier Architecture`: https://en.wikipedia.org/wiki/Multitier_architecture 65 | 66 | 67 | Contracts 68 | --------- 69 | 70 | *Contracts* are how you describe your architecture to Layer Linter. You write them in a ``layers.yml`` file. Each 71 | Contract contains two lists, ``layers`` and ``containers``. 72 | 73 | - ``layers`` takes the form of an ordered list with the name of each layer module, *relative to its parent package*. 74 | The order is from high level layer to low level layer. 75 | - ``containers`` lists the parent modules of the layers, as *absolute names* that you could import, such as 76 | ``mypackage.foo``. If you have only one set of layers, there will be only one container: the top level package. 77 | However, you could choose to have a repeating pattern of layers across multiple subpackages; in which case, 78 | you would include each of those subpackages in the containers list. 79 | 80 | You can have as many of these contracts as you like, and you give each one a name. 81 | 82 | **Example: single container contract** 83 | 84 | The three-layered structure described earlier can be described by the following contract. Note that the layers have 85 | names relative to the single, containing package. 86 | 87 | .. code-block:: none 88 | 89 | Three-tier contract: 90 | containers: 91 | - pets 92 | layers: 93 | - dogs 94 | - cats 95 | - rabbits 96 | 97 | **Example: multiple package contract** 98 | 99 | A more complex architecture might involve the same layers repeated across multiple containers, like this: 100 | 101 | .. image:: img/modular-layers.png 102 | :align: center 103 | 104 | In this case, rather than have three contracts, one for each container, you may list all the containers in a single 105 | contract. The order of the containers is not important. 106 | 107 | .. code-block:: none 108 | 109 | Modular contract: 110 | containers: 111 | - pets.dogs 112 | - pets.cats 113 | - pets.rabbits 114 | layers: 115 | - personality 116 | - traits 117 | - physical 118 | 119 | Whitelisting paths 120 | ------------------ 121 | 122 | Sometimes, you may wish to tolerate certain dependencies that do not adhere to your contract. 123 | To do this, include them as *whitelisted paths* in your contract. 124 | 125 | Let's say you have a project that has a ``utils`` module that introduces an illegal dependency between two 126 | of your layers. The report might look something like this: 127 | 128 | .. code-block:: none 129 | 130 | ---------------- 131 | Broken contracts 132 | ---------------- 133 | 134 | 135 | My layer contract 136 | ----------------- 137 | 138 | 1. pets.cats.whiskers imports pets.dogs.walkies: 139 | 140 | pets.cats.whiskers <- 141 | pets.utils <- 142 | pets.dogs.walkies 143 | 144 | To suppress this error, you may add one component of the path to the contract like so: 145 | 146 | .. code-block:: none 147 | 148 | Three-tier contract: 149 | containers: 150 | - pets 151 | layers: 152 | - dogs 153 | - cats 154 | - rabbits 155 | whitelisted_paths: 156 | - pets.cats.whiskers <- pets.utils 157 | 158 | Running the linter again will show the contract passing. 159 | 160 | There are a few use cases: 161 | 162 | - Your project does not completely adhere to the contract, but you want to prevent it getting worse. 163 | You can whitelist any known issues, and gradually fix them. 164 | - You have an exceptional circumstance in your project that you are comfortable with, 165 | and don't wish to fix. 166 | - You want to understand how many dependencies you would need to fix before a project 167 | conforms to a particular architecture. Because Layer Linter only shows the most direct 168 | dependency violation, whitelisting paths can reveal less direct ones. 169 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # layer_linter documentation build configuration file, created by 5 | # sphinx-quickstart on Fri Jun 9 13:47:02 2017. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | # If extensions (or modules to document with autodoc) are in another 17 | # directory, add these directories to sys.path here. If the directory is 18 | # relative to the documentation root, use os.path.abspath to make it 19 | # absolute, like shown here. 20 | # 21 | import os 22 | import sys 23 | sys.path.insert(0, os.path.abspath('../src')) 24 | 25 | import layer_linter 26 | 27 | # -- General configuration --------------------------------------------- 28 | 29 | # If your documentation needs a minimal Sphinx version, state it here. 30 | # 31 | # needs_sphinx = '1.0' 32 | 33 | # Add any Sphinx extension module names here, as strings. They can be 34 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 35 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.viewcode'] 36 | 37 | # Add any paths that contain templates here, relative to this directory. 38 | templates_path = ['_templates'] 39 | 40 | # The suffix(es) of source filenames. 41 | # You can specify multiple suffix as a list of string: 42 | # 43 | # source_suffix = ['.rst', '.md'] 44 | source_suffix = '.rst' 45 | 46 | # The master toctree document. 47 | master_doc = 'index' 48 | 49 | # General information about the project. 50 | project = u'Layer Linter' 51 | copyright = u"2018, David Seddon" 52 | author = u"David Seddon" 53 | 54 | # The version info for the project you're documenting, acts as replacement 55 | # for |version| and |release|, also used in various other places throughout 56 | # the built documents. 57 | # 58 | # The short X.Y version. 59 | version = layer_linter.__version__ 60 | # The full version, including alpha/beta/rc tags. 61 | release = layer_linter.__version__ 62 | 63 | # The language for content autogenerated by Sphinx. Refer to documentation 64 | # for a list of supported languages. 65 | # 66 | # This is also used if you do content translation via gettext catalogs. 67 | # Usually you set "language" from the command line for these cases. 68 | language = None 69 | 70 | # List of patterns, relative to source directory, that match files and 71 | # directories to ignore when looking for source files. 72 | # This patterns also effect to html_static_path and html_extra_path 73 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 74 | 75 | # The name of the Pygments (syntax highlighting) style to use. 76 | pygments_style = 'sphinx' 77 | 78 | # If true, `todo` and `todoList` produce output, else they produce nothing. 79 | todo_include_todos = False 80 | 81 | 82 | # -- Options for HTML output ------------------------------------------- 83 | 84 | # The theme to use for HTML and HTML Help pages. See the documentation for 85 | # a list of builtin themes. 86 | # 87 | html_theme = 'sphinx_rtd_theme' 88 | 89 | # Theme options are theme-specific and customize the look and feel of a 90 | # theme further. For a list of options available for each theme, see the 91 | # documentation. 92 | # 93 | # html_theme_options = {} 94 | 95 | # Add any paths that contain custom static files (such as style sheets) here, 96 | # relative to this directory. They are copied after the builtin static files, 97 | # so a file named "default.css" will overwrite the builtin "default.css". 98 | html_static_path = [] 99 | 100 | 101 | # -- Options for HTMLHelp output --------------------------------------- 102 | 103 | # Output file base name for HTML help builder. 104 | htmlhelp_basename = 'layer_linterdoc' 105 | 106 | 107 | # -- Options for LaTeX output ------------------------------------------ 108 | 109 | latex_elements = { 110 | # The paper size ('letterpaper' or 'a4paper'). 111 | # 112 | # 'papersize': 'letterpaper', 113 | 114 | # The font size ('10pt', '11pt' or '12pt'). 115 | # 116 | # 'pointsize': '10pt', 117 | 118 | # Additional stuff for the LaTeX preamble. 119 | # 120 | # 'preamble': '', 121 | 122 | # Latex figure (float) alignment 123 | # 124 | # 'figure_align': 'htbp', 125 | } 126 | 127 | # Grouping the document tree into LaTeX files. List of tuples 128 | # (source start file, target name, title, author, documentclass 129 | # [howto, manual, or own class]). 130 | latex_documents = [ 131 | (master_doc, 'layer_linter.tex', 132 | u'Layer Linter Documentation', 133 | u'David Seddon', 'manual'), 134 | ] 135 | 136 | 137 | # -- Options for manual page output ------------------------------------ 138 | 139 | # One entry per manual page. List of tuples 140 | # (source start file, name, description, authors, manual section). 141 | man_pages = [ 142 | (master_doc, 'layer_linter', 143 | u'Layer Linter Documentation', 144 | [author], 1) 145 | ] 146 | 147 | 148 | # -- Options for Texinfo output ---------------------------------------- 149 | 150 | # Grouping the document tree into Texinfo files. List of tuples 151 | # (source start file, target name, title, author, 152 | # dir menu entry, description, category) 153 | texinfo_documents = [ 154 | (master_doc, 'layer_linter', 155 | u'Layer Linter Documentation', 156 | author, 157 | 'layer_linter', 158 | 'One line description of project.', 159 | 'Miscellaneous'), 160 | ] 161 | 162 | 163 | 164 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /docs/history.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../HISTORY.rst 2 | -------------------------------------------------------------------------------- /docs/img/complex-graph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seddonym/layer_linter/801c0a30d814968bc9041dd29fdeccdccfff8cb7/docs/img/complex-graph.png -------------------------------------------------------------------------------- /docs/img/layers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seddonym/layer_linter/801c0a30d814968bc9041dd29fdeccdccfff8cb7/docs/img/layers.png -------------------------------------------------------------------------------- /docs/img/modular-layers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seddonym/layer_linter/801c0a30d814968bc9041dd29fdeccdccfff8cb7/docs/img/modular-layers.png -------------------------------------------------------------------------------- /docs/img/simple-graph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seddonym/layer_linter/801c0a30d814968bc9041dd29fdeccdccfff8cb7/docs/img/simple-graph.png -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | 3 | .. toctree:: 4 | :hidden: 5 | :maxdepth: 2 6 | :caption: Contents: 7 | 8 | installation 9 | usage 10 | concepts 11 | contributing 12 | authors 13 | history 14 | migrating-to-import-linter 15 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: shell 2 | 3 | ============ 4 | Installation 5 | ============ 6 | 7 | Requirements 8 | ------------ 9 | 10 | Layer Linter currently only supports Python 3.6 - 3.7. 11 | 12 | Stable release 13 | -------------- 14 | 15 | To install Layer Linter, run this command in your terminal: 16 | 17 | .. code-block:: console 18 | 19 | $ pip install layer-linter 20 | 21 | This is the preferred method to install Layer Linter, as it will always install the most recent stable release. 22 | 23 | If you don't have `pip`_ installed, this `Python installation guide`_ can guide 24 | you through the process. 25 | 26 | .. _pip: https://pip.pypa.io 27 | .. _Python installation guide: http://docs.python-guide.org/en/latest/starting/installation/ 28 | 29 | 30 | Development version 31 | ------------------- 32 | 33 | The sources for Layer Linter can be downloaded from the `Github repo`_. 34 | 35 | You can either clone the public repository: 36 | 37 | .. code-block:: console 38 | 39 | $ git clone git://github.com/seddonym/layer_linter 40 | 41 | Or download the `tarball`_: 42 | 43 | .. code-block:: console 44 | 45 | $ curl -OL https://github.com/seddonym/layer_linter/tarball/master 46 | 47 | Once you have a copy of the source, you can install it with: 48 | 49 | .. code-block:: console 50 | 51 | $ python setup.py install 52 | 53 | 54 | .. _Github repo: https://github.com/seddonym/layer_linter 55 | .. _tarball: https://github.com/seddonym/layer_linter/tarball/master 56 | -------------------------------------------------------------------------------- /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=python -msphinx 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | set SPHINXPROJ=layer_linter 13 | 14 | if "%1" == "" goto help 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The Sphinx module was not found. Make sure you have Sphinx installed, 20 | echo.then set the SPHINXBUILD environment variable to point to the full 21 | echo.path of the 'sphinx-build' executable. Alternatively you may add the 22 | echo.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 | -------------------------------------------------------------------------------- /docs/migrating-to-import-linter.rst: -------------------------------------------------------------------------------- 1 | ========================== 2 | Migrating to Import Linter 3 | ========================== 4 | 5 | If you would like to migrate from Layer Linter to `Import Linter`_, you can easily migrate your setup. 6 | 7 | .. _Import Linter: https://github.com/seddonym/import-linter 8 | 9 | Step One - Install 10 | ------------------ 11 | 12 | .. code-block:: text 13 | 14 | pip install import-linter 15 | 16 | Step Two - Configure 17 | -------------------- 18 | 19 | Import Linter uses INI instead of YAML to define its contracts. Create an ``.importlinter`` file in the same directory 20 | as your ``layers.yml``. This is where your configuration will live. 21 | 22 | Converting the format is simple. 23 | 24 | Example ``layers.yml``: 25 | 26 | .. code-block:: yaml 27 | 28 | Contract one: 29 | containers: 30 | - mypackage 31 | layers: 32 | - api 33 | - utils 34 | 35 | Contract two: 36 | # This is a comment. 37 | containers: 38 | - mypackage.foo 39 | - mypackage.bar 40 | - mypackage.baz 41 | layers: 42 | - top 43 | - middle 44 | - bottom 45 | whitelisted_paths: 46 | - mypackage.foo.bottom.alpha <- mypackage.foo.middle.beta 47 | 48 | 49 | Equivalent ``.importlinter``: 50 | 51 | .. code-block:: ini 52 | 53 | [importlinter] 54 | root_package = mypackage 55 | 56 | 57 | [importlinter:contract:1] 58 | name = Contract one 59 | type = layers 60 | containers= 61 | mypackage 62 | layers= 63 | api 64 | utils 65 | 66 | 67 | [importlinter:contract:2] 68 | # This is a comment. 69 | name = Contract two 70 | type = layers 71 | containers= 72 | mypackage.foo 73 | mypackage.bar 74 | mypackage.baz 75 | layers= 76 | top 77 | middle 78 | bottom 79 | ignore_imports= 80 | mypackage.foo.bottom.alpha -> mypackage.foo.middle.beta 81 | 82 | 83 | Things to note: 84 | 85 | - Import Linter requires the root package to be configured in the file, rather than passed to the command line. 86 | - Each contract requires a ``type`` - this is because Import Linter supports other contract types. 87 | - Each contract needs an arbitrary unique identifier in the INI section (in this case, ``1`` and ``2``). 88 | - 'Whitelisted paths' has have been renamed to 'ignore imports'. The notation is similar except the arrow is reversed; 89 | the importing package is still listed first, the imported package second. 90 | 91 | Step Three - Run 92 | ---------------- 93 | 94 | To lint your package, run: 95 | 96 | .. code-block:: text 97 | 98 | lint-imports 99 | 100 | Or, if your configuration file is in a different directory: 101 | 102 | .. code-block:: text 103 | 104 | lint-imports --config=path/to/.importlinter 105 | 106 | 107 | Key differences between the packages 108 | ------------------------------------ 109 | 110 | - You may notice slight differences in the imports Import Linter picks up on. The main example is that it does not 111 | ignore modules in ``migrations`` subpackages, while Layer Linter does. 112 | - Import Linter allows you to use other contract types and even define your own. 113 | - Import Linter allows you to analyse imports of external packages too (though these don't make sense in the context 114 | of a layers contract). 115 | 116 | Further reading can be found in the `Import Linter documentation`_. 117 | 118 | .. _Import Linter documentation: https://import-linter.readthedocs.io 119 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | ===== 2 | Usage 3 | ===== 4 | 5 | Before use, you will probably want to read :doc:`concepts`. 6 | 7 | Defining your contracts 8 | ----------------------- 9 | 10 | Your layers contracts are defined in a YAML file named ``layers.yml``. This 11 | may exist anywhere, but a good place is in your project root. 12 | 13 | The file contains one or more contracts, in the following format: 14 | 15 | .. code-block:: none 16 | 17 | Contract name: 18 | containers: 19 | - mypackage.container 20 | ... 21 | layers: 22 | - layerone 23 | - layertwo 24 | ... 25 | whitelisted_paths: 26 | - mypackage.container.layertwo.module <- mypackage.container.layerone.module 27 | ... 28 | 29 | 1. **Contract name**: A string to describe your contract. 30 | 2. **Containers**: Absolute names of any Python package that contains the layers as 31 | immediate children. One or more containers are allowed in this list. 32 | 3. **Layers**: Names of the Python module *relative* to each container listed in 33 | ``containers``. Modules lower down the list must not import modules higher up. 34 | (Remember, a Python module can either be a ``.py`` file or a directory with 35 | an ``__init__.py`` file inside.) 36 | 4. **Whitelisted paths** (optional): If you wish certain import paths not to 37 | break in the contract, you can optionally whitelist them. The modules should be listed as 38 | absolute names, with the importing module first, and the imported module second. 39 | 40 | For some examples, see :doc:`concepts`. 41 | 42 | Additional syntax 43 | ----------------- 44 | 45 | **Optional layers** 46 | 47 | You may specify certain layers as optional, by enclosing the name in parentheses. 48 | 49 | By default, Layer Linter will error if it cannot find the module for a particular layer. 50 | Take the following contract: 51 | 52 | .. code-block:: none 53 | 54 | My contract: 55 | containers: 56 | - mypackage.foo 57 | - mypackage.bar 58 | layers: 59 | - one 60 | - two 61 | 62 | If the module ``mypackage.foo.two`` is missing, the contract will be broken. If you want 63 | the contract to pass despite this, you can enclose the layer name in parentheses: 64 | 65 | .. code-block:: none 66 | 67 | My contract: 68 | containers: 69 | - mypackage.foo 70 | - mypackage.bar 71 | layers: 72 | - one 73 | - (two) 74 | 75 | Layer ``two`` is now optional, which means the contract will pass even though ``mypackage.bar.two`` 76 | is missing. 77 | 78 | Running the linter 79 | ------------------ 80 | 81 | Layer Linter provides a single command: ``layer-lint``. 82 | 83 | Running this will check that your project adheres to the contracts in your ``layers.yml``. 84 | 85 | - Positional arguments: 86 | 87 | - ``package_name``: The name of the top-level Python package to validate (required). 88 | 89 | - Optional arguments: 90 | 91 | - ``--config``: The YAML file describing your layer contract(s). If not 92 | supplied, Layer Linter will look for a file called ``layers.yml`` in the current directory. 93 | - ``--quiet``: Do not output anything if the contracts are all adhered to. 94 | - ``--verbose`` (or ``-v``): Output a more verbose report. 95 | - ``--debug``: Output debug messages when running the linter. No parameters required. 96 | 97 | Default usage:; 98 | 99 | .. code-block:: none 100 | 101 | layer-lint myproject 102 | 103 | Using a different filename or location instead of ``layers.yml``: 104 | 105 | .. code-block:: none 106 | 107 | layer-lint myproject --config path/to/alternative.yml 108 | -------------------------------------------------------------------------------- /layers.yml: -------------------------------------------------------------------------------- 1 | Layer Linter architecture: 2 | containers: 3 | - layer_linter 4 | layers: 5 | - cmdline 6 | - report 7 | - contract 8 | - dependencies 9 | - module 10 | 11 | Dependencies subpackage: 12 | containers: 13 | - layer_linter.dependencies 14 | layers: 15 | - graph 16 | - analysis 17 | - scanner 18 | - path 19 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | pip==18.1 2 | bumpversion==0.5.3 3 | wheel==0.32.1 4 | watchdog==0.9.0 5 | flake8==3.5.0 6 | tox==3.5.2 7 | coverage==4.5.1 8 | Sphinx==1.8.1 9 | sphinx_rtd_theme==0.4.2 10 | twine==1.12.1 11 | pytest==3.9.1 12 | pytest-runner==4.2 13 | pytest-cov==2.6.0 14 | codecov==2.0.16 15 | mypy==0.641 16 | setuptools==40.4.3 17 | tox-pyenv==1.1.0 18 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.12.3 3 | commit = True 4 | tag = True 5 | 6 | [bumpversion:file:setup.py] 7 | search = version='{current_version}' 8 | replace = version='{new_version}' 9 | 10 | [bumpversion:file:src/layer_linter/__init__.py] 11 | search = __version__ = '{current_version}' 12 | replace = __version__ = '{new_version}' 13 | 14 | [bdist_wheel] 15 | python-tag = py36,py37 16 | 17 | [flake8] 18 | exclude = docs 19 | 20 | [aliases] 21 | test = pytest 22 | 23 | [tool:pytest] 24 | collect_ignore = ['setup.py'] 25 | 26 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """The setup script.""" 5 | 6 | from setuptools import setup, find_packages 7 | 8 | with open('README.rst') as readme_file: 9 | readme = readme_file.read() 10 | 11 | with open('HISTORY.rst') as history_file: 12 | history = history_file.read() 13 | 14 | requirements = [ 15 | 'networkx~=2.2', 16 | 'PyYAML~=4.2b1', 17 | 'click>=6.7,<8', 18 | ] 19 | 20 | setup( 21 | author="David Seddon", 22 | author_email='david@seddonym.me', 23 | classifiers=[ 24 | 'Development Status :: 7 - Inactive', 25 | 'Intended Audience :: Developers', 26 | 'License :: OSI Approved :: BSD License', 27 | 'Natural Language :: English', 28 | 'Programming Language :: Python :: 3', 29 | 'Programming Language :: Python :: 3.6', 30 | 'Programming Language :: Python :: 3.7', 31 | ], 32 | description="Layer Linter checks that your project follows a custom-defined layered architecture.", 33 | install_requires=requirements, 34 | license="BSD license", 35 | long_description=readme + '\n\n' + history, 36 | include_package_data=True, 37 | keywords='layer-linter layer-lint', 38 | name='layer-linter', 39 | packages=find_packages(where="src"), 40 | package_dir={"": "src"}, 41 | url='https://github.com/seddonym/layer_linter', 42 | version='0.12.3', 43 | zip_safe=False, 44 | entry_points={ 45 | 'console_scripts': [ 46 | 'layer-lint = layer_linter.cmdline:main', 47 | ], 48 | }, 49 | ) 50 | -------------------------------------------------------------------------------- /src/layer_linter/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Top-level package for Layer Linter.""" 4 | 5 | __author__ = """David Seddon""" 6 | __email__ = 'david@seddonym.me' 7 | __version__ = '0.12.3' 8 | -------------------------------------------------------------------------------- /src/layer_linter/__main__.py: -------------------------------------------------------------------------------- 1 | from .cmdline import main 2 | 3 | 4 | if __name__ == '__main__': 5 | main() 6 | -------------------------------------------------------------------------------- /src/layer_linter/cmdline.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | import argparse 3 | import os 4 | import sys 5 | import logging 6 | import importlib.util 7 | 8 | from .module import SafeFilenameModule 9 | from .dependencies import DependencyGraph 10 | from .contract import get_contracts, Contract, ContractParseError 11 | from .report import ( 12 | get_report_class, ConsolePrinter, VERBOSITY_QUIET, VERBOSITY_NORMAL, VERBOSITY_HIGH) 13 | 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | EXIT_STATUS_SUCCESS = 0 18 | EXIT_STATUS_ERROR = 1 19 | 20 | def create_parser(): 21 | parser = argparse.ArgumentParser( 22 | description='Checks that your project follows a custom-defined ' 23 | 'layered architecture, based on a layers.yml file.' 24 | ) 25 | 26 | parser.add_argument( 27 | 'package_name', 28 | help='The name of the Python package to validate.' 29 | ) 30 | 31 | parser.add_argument( 32 | '--config', 33 | required=False, 34 | help="The YAML file describing your contract(s). If not supplied, Layer Linter will " 35 | "look for a file called 'layers.yml' inside the current directory.", 36 | 37 | ) 38 | 39 | parser.add_argument( 40 | '-v', 41 | '--verbose', 42 | # Using a count allows us to increase verbosity levels using -vv, -vvv etc., should they 43 | # be necessary in future. 44 | action='count', 45 | default=0, 46 | dest='verbosity_count', 47 | help="Increase verbosity." 48 | ) 49 | 50 | parser.add_argument( 51 | '--quiet', 52 | required=False, 53 | action='store_true', 54 | dest='is_quiet', 55 | help="Do not output anything on success." 56 | ) 57 | 58 | parser.add_argument( 59 | '--debug', 60 | required=False, 61 | action="store_true", 62 | dest='is_debug', 63 | help="Whether to display debug information.", 64 | ) 65 | 66 | return parser 67 | 68 | 69 | def main(): 70 | parser = create_parser() 71 | args = parser.parse_args() 72 | return _main( 73 | package_name=args.package_name, 74 | config_filename=args.config, 75 | is_debug=args.is_debug, 76 | verbosity_count=args.verbosity_count, 77 | is_quiet=args.is_quiet) 78 | 79 | 80 | def _main(package_name, config_filename=None, is_debug=False, 81 | verbosity_count=0, is_quiet=False): 82 | 83 | if is_debug: 84 | logging.basicConfig(level=logging.DEBUG) 85 | 86 | try: 87 | package = _get_package(package_name) 88 | except ValueError as e: 89 | _print_package_name_error_and_help(str(e)) 90 | return EXIT_STATUS_ERROR 91 | 92 | try: 93 | contracts = _get_contracts(config_filename, package_name) 94 | except Exception as e: 95 | ConsolePrinter.print_error(str(e)) 96 | return EXIT_STATUS_ERROR 97 | 98 | graph = DependencyGraph(package=package) 99 | 100 | try: 101 | verbosity = _normalise_verbosity(verbosity_count, is_quiet) 102 | except Exception as e: 103 | ConsolePrinter.print_error(str(e)) 104 | return EXIT_STATUS_ERROR 105 | 106 | report_class = get_report_class(verbosity) 107 | report = report_class(graph) 108 | 109 | for contract in contracts: 110 | try: 111 | contract.check_dependencies(graph) 112 | except Exception as e: 113 | ConsolePrinter.print_error(str(e)) 114 | return EXIT_STATUS_ERROR 115 | report.add_contract(contract) 116 | 117 | report.output() 118 | 119 | if report.has_broken_contracts: 120 | return EXIT_STATUS_ERROR 121 | else: 122 | return EXIT_STATUS_SUCCESS 123 | 124 | 125 | def _normalise_verbosity(verbosity_count: int, is_quiet: bool) -> int: 126 | """ 127 | Validate verbosity, and parse quiet mode into a verbosity level. 128 | 129 | Args: 130 | verbosity_count (int): the number of 'v's passed as command line arguments. For example, 131 | -vv would be 2. 132 | is_quiet (bool): whether the '--quiet' flag was passed. 133 | 134 | Returns: 135 | Verbosity level (int): either VERBOSITY_QUIET, VERBOSITY_NORMAL or VERBOSITY_HIGH. 136 | """ 137 | VERBOSITY_BY_COUNT = (VERBOSITY_NORMAL, VERBOSITY_HIGH) 138 | 139 | if is_quiet: 140 | if verbosity_count > 0: 141 | raise RuntimeError( 142 | "Invalid parameters: quiet and verbose called together. Choose one or the other.") 143 | return VERBOSITY_QUIET 144 | 145 | try: 146 | return VERBOSITY_BY_COUNT[verbosity_count] 147 | except IndexError: 148 | raise RuntimeError( 149 | "That level of verbosity is not supported. " 150 | "Maximum verbosity is -{}.".format('v' * (len(VERBOSITY_BY_COUNT) - 1))) 151 | 152 | 153 | def _get_package(package_name: str) -> SafeFilenameModule: 154 | """ 155 | Get the package as a SafeFilenameModule. 156 | 157 | Raises ValueError, with appropriate user-facing message, if the package name is 158 | not valid. 159 | """ 160 | if '.' in package_name: 161 | raise ValueError("Package name must be the root name, no '.' allowed.") 162 | if ('/' in package_name) or ('\\' in package_name): 163 | raise ValueError("The package name should not be a directory, it should be the name of " 164 | "the importable Python package.") 165 | 166 | # Add current directory to the path, as this doesn't happen automatically. 167 | sys.path.insert(0, os.getcwd()) 168 | 169 | # Attempt to locate the package file. 170 | package_filename = importlib.util.find_spec(package_name) 171 | if not package_filename: 172 | logger.debug("sys.path: {}".format(sys.path)) 173 | raise ValueError("Could not find package '{}' in your Python path.".format(package_name)) 174 | assert package_filename.origin # For type checker. 175 | return SafeFilenameModule(name=package_name, filename=package_filename.origin) 176 | 177 | 178 | def _get_contracts(config_filename: str, package_name: str) -> List[Contract]: 179 | # Parse contracts file. 180 | if config_filename is None: 181 | config_filename = os.path.join(os.getcwd(), 'layers.yml') 182 | try: 183 | return get_contracts(filename=config_filename, package_name=package_name) 184 | except FileNotFoundError as e: 185 | raise RuntimeError("{}: {}".format(e.strerror, e.filename)) 186 | except ContractParseError as e: 187 | raise RuntimeError('Error parsing contract: {}'.format(e)) 188 | 189 | 190 | def _print_package_name_error_and_help(error_text): 191 | ConsolePrinter.print_heading('Invalid package name', 192 | ConsolePrinter.HEADING_LEVEL_TWO, 193 | ConsolePrinter.ERROR) 194 | ConsolePrinter.print_error(error_text) 195 | ConsolePrinter.new_line() 196 | ConsolePrinter.print_heading('Tip', ConsolePrinter.HEADING_LEVEL_THREE, 197 | ConsolePrinter.ERROR) 198 | ConsolePrinter.print_error('Your package should either be in the current working directory, ') 199 | ConsolePrinter.print_error('or installed (e.g. in your virtual environment).') 200 | ConsolePrinter.new_line() 201 | ConsolePrinter.print_error('If your package has a setup.py, the easiest way to install it is ') 202 | ConsolePrinter.print_error('to run the following command, which installs it, while keeping ') 203 | ConsolePrinter.print_error('it editable:') 204 | ConsolePrinter.new_line() 205 | ConsolePrinter.indent_cursor() 206 | ConsolePrinter.print_error('pip install -e path/to/setup.py') 207 | ConsolePrinter.new_line() 208 | ConsolePrinter.print_error("Alternatively, you may run Layer Linter from the directory that ") 209 | ConsolePrinter.print_error("contains your package directory. If your layers.yml is located ") 210 | ConsolePrinter.print_error("elsewhere, you may specify its file path using the config flag. ") 211 | ConsolePrinter.print_error("For example:") 212 | ConsolePrinter.new_line() 213 | ConsolePrinter.indent_cursor() 214 | ConsolePrinter.print_error( 215 | 'layer-lint mypackage --config path/to/layers.yml') 216 | ConsolePrinter.new_line() 217 | -------------------------------------------------------------------------------- /src/layer_linter/contract.py: -------------------------------------------------------------------------------- 1 | from typing import List, Dict, Iterable, Optional 2 | import re 3 | import yaml 4 | import importlib 5 | import logging 6 | from copy import copy 7 | 8 | from .dependencies import DependencyGraph, ImportPath 9 | from .module import Module 10 | 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | class ContractParseError(IOError): 16 | pass 17 | 18 | 19 | class Layer: 20 | """ 21 | A relatively named module or subpackage. When combined with a container, 22 | it refers to a specific module. For example, Layer('one') would refer 23 | to Module('foo.one') when combined with the containing Module('foo'). 24 | 25 | If a Layer is marked as optional, then a Contract won't complain if 26 | it can't find it within a particular container. 27 | """ 28 | def __init__(self, name: str, is_optional=False) -> None: 29 | self.name = name 30 | self.is_optional = is_optional 31 | 32 | def __str__(self) -> str: 33 | return self.name 34 | 35 | def __repr__(self) -> str: 36 | return '<{}: {}>'.format(self.__class__.__name__, self) 37 | 38 | 39 | class Contract: 40 | def __init__(self, name: str, containers: List[Module], layers: List[Layer], 41 | whitelisted_paths: Optional[List[ImportPath]] = None) -> None: 42 | self.name = name 43 | self.containers = containers 44 | self.layers = layers 45 | self.whitelisted_paths = whitelisted_paths if whitelisted_paths else [] 46 | 47 | def check_dependencies(self, dependencies: DependencyGraph) -> None: 48 | self._check_all_layers_exist_for_all_containers(dependencies) 49 | 50 | self.illegal_dependencies: List[List[str]] = [] 51 | 52 | logger.debug('Checking dependencies for contract {}...'.format(self)) 53 | 54 | for container in self.containers: 55 | for layer in reversed(self.layers): 56 | self._check_layer_does_not_import_downstream(layer, container, dependencies) 57 | 58 | def _check_all_layers_exist_for_all_containers(self, dependencies: DependencyGraph) -> None: 59 | """ 60 | Raise a ValueError if we couldn't find any Python files for each layer in all containers. 61 | """ 62 | for container in self.containers: 63 | self._check_all_layers_exist_for_container(container, dependencies) 64 | 65 | def _check_all_layers_exist_for_container(self, 66 | container: Module, 67 | dependencies: DependencyGraph) -> None: 68 | """ 69 | Raise a ValueError if we couldn't find any Python files for each non-optional layer. 70 | """ 71 | for layer in self.layers: 72 | if layer.is_optional: 73 | continue 74 | layer_module = self._get_layer_module(layer, container) 75 | if layer_module not in dependencies: 76 | raise ValueError(f"Missing layer in container '{container}': " 77 | f"module {layer_module} does not exist.") 78 | 79 | def _check_layer_does_not_import_downstream(self, layer: Layer, container: Module, 80 | dependencies: DependencyGraph) -> None: 81 | 82 | logger.debug("Layer '{}' in container '{}'.".format(layer, container)) 83 | 84 | modules_in_this_layer = self._get_modules_in_layer(layer, container, dependencies) 85 | modules_in_downstream_layers = self._get_modules_in_downstream_layers( 86 | layer, container, dependencies) 87 | logger.debug('Modules in this layer: {}'.format(modules_in_this_layer)) 88 | logger.debug('Modules in downstream layer: {}'.format(modules_in_downstream_layers)) 89 | 90 | for upstream_module in modules_in_this_layer: 91 | for downstream_module in modules_in_downstream_layers: 92 | logger.debug('Upstream {}, downstream {}.'.format(upstream_module, 93 | downstream_module)) 94 | path = dependencies.find_path( 95 | upstream=downstream_module, 96 | downstream=upstream_module, 97 | ignore_paths=self.whitelisted_paths, 98 | ) 99 | logger.debug('Path is {}.'.format(path)) 100 | if path and not self._path_is_via_another_layer(path, layer, container): 101 | logger.debug('Illegal dependency found: {}'.format(path)) 102 | self._update_illegal_dependencies(path) 103 | 104 | def _get_modules_in_layer( 105 | self, layer: Layer, container: Module, dependencies: DependencyGraph 106 | ) -> List[Module]: 107 | """ 108 | Args: 109 | layer: The Layer object. 110 | container: absolute name of the package containing the layer (string). 111 | dependencies: the DependencyGraph object. 112 | Returns: 113 | List of modules names within that layer, including the layer module itself. 114 | Includes grandchildren and deeper. 115 | """ 116 | layer_module = self._get_layer_module(layer, container) 117 | modules = [layer_module] 118 | modules.extend( 119 | dependencies.get_descendants(layer_module) 120 | ) 121 | return modules 122 | 123 | def _get_layer_module(self, layer: Layer, container: Module) -> Module: 124 | return Module("{}.{}".format(container, layer.name)) 125 | 126 | def _get_modules_in_downstream_layers( 127 | self, layer: Layer, container: Module, dependencies: DependencyGraph 128 | ) -> List[Module]: 129 | modules = [] 130 | for downstream_layer in self._get_layers_downstream_of(layer): 131 | modules.extend( 132 | self._get_modules_in_layer(layer=downstream_layer, container=container, 133 | dependencies=dependencies) 134 | ) 135 | return modules 136 | 137 | def _path_is_via_another_layer(self, path, current_layer, container): 138 | other_layers = list(copy(self.layers)) 139 | other_layers.remove(current_layer) 140 | 141 | layer_modules = ['{}.{}'.format(container, layer.name) for layer in other_layers] 142 | modules_via = path[1:-1] 143 | 144 | return any(path_module in layer_modules for path_module in modules_via) 145 | 146 | def _update_illegal_dependencies(self, path): 147 | # Don't duplicate path. So if the path is already present in another dependency, 148 | # don't add it. If another dependency is present in this path, replace it with this one. 149 | new_path_set = set(path) 150 | logger.debug('Updating illegal dependencies with {}.'.format(path)) 151 | illegal_dependencies_copy = self.illegal_dependencies[:] 152 | paths_to_remove = [] 153 | add_path = True 154 | for existing_path in illegal_dependencies_copy: 155 | existing_path_set = set(existing_path) 156 | logger.debug('Comparing new path with {}...'.format(existing_path)) 157 | if new_path_set.issubset(existing_path_set): 158 | # Remove the existing_path, as the new path will be more succinct. 159 | logger.debug('Removing existing.') 160 | paths_to_remove.append(existing_path) 161 | add_path = True 162 | elif existing_path_set.issubset(new_path_set): 163 | # Don't add the new path, it's implied more succinctly with the existing path. 164 | logger.debug('Skipping new path.') 165 | add_path = False 166 | 167 | logger.debug('Paths to remove: {}'.format(paths_to_remove)) 168 | self.illegal_dependencies = [ 169 | i for i in self.illegal_dependencies if i not in paths_to_remove 170 | ] 171 | if add_path: 172 | self.illegal_dependencies.append(path) 173 | 174 | @property 175 | def is_kept(self) -> bool: 176 | try: 177 | return len(self.illegal_dependencies) == 0 178 | except AttributeError: 179 | raise RuntimeError( 180 | 'Cannot check whether contract is kept ' 181 | 'until check_dependencies is called.' 182 | ) 183 | 184 | def _get_layers_downstream_of(self, layer: Layer) -> Iterable[Layer]: 185 | return reversed(self.layers[:self.layers.index(layer)]) 186 | 187 | def __str__(self) -> str: 188 | return self.name 189 | 190 | def __repr__(self) -> str: 191 | return '<{}: {}>'.format(self.__class__.__name__, self) 192 | 193 | 194 | PARENTHESES_REGEX = re.compile(r'^\(.*\)$') 195 | 196 | 197 | def contract_from_yaml(key: str, data: Dict, package_name: str) -> Contract: 198 | layers: List[Layer] = [] 199 | if 'layers' not in data: 200 | raise ContractParseError(f"'{key}' is missing a list of layers.") 201 | for layer_name in data['layers']: 202 | if PARENTHESES_REGEX.match(layer_name): 203 | layer = Layer(layer_name[1:-1], is_optional=True) 204 | else: 205 | layer = Layer(layer_name) 206 | layers.append(layer) 207 | 208 | containers: List[Module] = [] 209 | if 'containers' not in data: 210 | error_message = f"'{key}' is missing a list of containers." 211 | # Help users who are using the older format. 212 | if 'packages' in data: 213 | error_message += " (Tip: try renaming 'packages' to 'containers'.)" 214 | raise ContractParseError(error_message) 215 | 216 | for container_name in data['containers']: 217 | _validate_container_name(container_name, package_name) 218 | containers.append(Module(container_name)) 219 | 220 | whitelisted_paths: List[ImportPath] = [] 221 | for whitelist_data in data.get('whitelisted_paths', []): 222 | try: 223 | importer, imported = map(Module, whitelist_data.split(' <- ')) 224 | except ValueError: 225 | raise ValueError('Whitelisted paths must be in the format ' 226 | '"importer.module <- imported.module".') 227 | 228 | whitelisted_paths.append(ImportPath(importer, imported)) 229 | 230 | return Contract( 231 | name=key, 232 | containers=containers, 233 | layers=layers, 234 | whitelisted_paths=whitelisted_paths, 235 | ) 236 | 237 | 238 | def get_contracts(filename: str, package_name: str) -> List[Contract]: 239 | """Read in any contracts from the given filename. 240 | """ 241 | contracts = [] 242 | 243 | with open(filename, 'r') as file: 244 | try: 245 | data_from_yaml = yaml.load(file) 246 | except Exception as e: 247 | logger.debug(e) 248 | raise ContractParseError('Could not parse {}.'.format(filename)) 249 | for key, data in data_from_yaml.items(): 250 | contracts.append(contract_from_yaml(key, data, package_name)) 251 | 252 | return contracts 253 | 254 | 255 | def _validate_container_name(container_name, package_name): 256 | """Raise a ValueError if the suppled container name is not a valid container for the supplied 257 | package name. 258 | """ 259 | if not container_name.startswith(package_name): 260 | raise ValueError( 261 | f"Invalid container '{container_name}': containers must be either a " 262 | f"subpackage of '{package_name}', or '{package_name}' itself." 263 | ) 264 | 265 | # Check that the container actually exists. 266 | if importlib.util.find_spec(container_name) is None: 267 | raise ValueError( 268 | f"Invalid container '{container_name}': no such package." 269 | ) 270 | -------------------------------------------------------------------------------- /src/layer_linter/dependencies/__init__.py: -------------------------------------------------------------------------------- 1 | from .graph import DependencyGraph # noqa: F401 2 | from .path import ImportPath # noqa: F401 3 | -------------------------------------------------------------------------------- /src/layer_linter/dependencies/analysis.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | import ast 3 | 4 | import logging 5 | 6 | from ..module import Module, SafeFilenameModule 7 | from .path import ImportPath 8 | 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | class DependencyAnalyzer: 14 | """ 15 | Analyzes a set of Python modules for imports between them. 16 | 17 | Args: 18 | modules: list of all SafeFilenameModules that make up the package. 19 | package: the Python package that contains all the modules. 20 | 21 | Usage: 22 | analyzer = DependencyAnalyzer(modules) 23 | import_paths = analyzer.determine_import_paths() 24 | """ 25 | def __init__(self, modules: List[SafeFilenameModule], package: Module) -> None: 26 | self.modules = modules 27 | self.package = package 28 | 29 | def determine_import_paths(self) -> List[ImportPath]: 30 | """ 31 | Return a list of the ImportPaths for all the modules. 32 | """ 33 | import_paths: List[ImportPath] = [] 34 | for module in self.modules: 35 | import_paths.extend( 36 | self._determine_import_paths_for_module(module) 37 | ) 38 | return import_paths 39 | 40 | def _determine_import_paths_for_module(self, module: SafeFilenameModule) -> List[ImportPath]: 41 | """ 42 | Return a list of all the ImportPaths for which a given Module is the importer. 43 | """ 44 | import_paths: List[ImportPath] = [] 45 | imported_modules = self._get_imported_modules(module) 46 | for imported_module in imported_modules: 47 | import_paths.append( 48 | ImportPath( 49 | importer=module, 50 | imported=imported_module 51 | ) 52 | ) 53 | return import_paths 54 | 55 | def _get_imported_modules(self, module: SafeFilenameModule) -> List[Module]: 56 | """ 57 | Statically analyses the given module and returns a list of Modules that it imports. 58 | 59 | Note: this method only analyses the module in question and will not load any other code, 60 | so it relies on self.modules to deduce which modules it imports. (This is because you 61 | can't know whether "from foo.bar import baz" is importing a module called `baz`, 62 | or a function `baz` from the module `bar`.) 63 | """ 64 | imported_modules = [] 65 | 66 | with open(module.filename) as file: 67 | module_contents = file.read() 68 | 69 | ast_tree = ast.parse(module_contents) 70 | for node in ast.walk(ast_tree): 71 | if isinstance(node, ast.ImportFrom): 72 | # Parsing something in the form 'from x import ...'. 73 | assert isinstance(node.level, int) 74 | if node.level == 0: 75 | # Absolute import. 76 | # Let the type checker know we expect node.module to be set here. 77 | assert isinstance(node.module, str) 78 | if not node.module.startswith(self.package.name): 79 | # Don't include imports of modules outside this package. 80 | continue 81 | module_base = node.module 82 | elif node.level >= 1: 83 | # Relative import. The level corresponds to how high up the tree it goes; 84 | # for example 'from ... import foo' would be level 3. 85 | importing_module_components = module.name.split('.') 86 | # TODO: handle level that is too high. 87 | # Trim the base module by the number of levels. 88 | if module.filename.endswith('__init__.py'): 89 | # If the scanned module an __init__.py file, we don't want 90 | # to go up an extra level. 91 | number_of_levels_to_trim_by = node.level - 1 92 | else: 93 | number_of_levels_to_trim_by = node.level 94 | if number_of_levels_to_trim_by: 95 | module_base = '.'.join( 96 | importing_module_components[:-number_of_levels_to_trim_by] 97 | ) 98 | else: 99 | module_base = '.'.join(importing_module_components) 100 | if node.module: 101 | module_base = '.'.join([module_base, node.module]) 102 | 103 | # node.names corresponds to 'a', 'b' and 'c' in 'from x import a, b, c'. 104 | for alias in node.names: 105 | full_module_name = '.'.join([module_base, alias.name]) 106 | imported_modules.append(Module(full_module_name)) 107 | 108 | elif isinstance(node, ast.Import): 109 | # Parsing a line in the form 'import x'. 110 | for alias in node.names: 111 | if not alias.name.startswith(self.package.name): 112 | # Don't include imports of modules outside this package. 113 | continue 114 | imported_modules.append(Module(alias.name)) 115 | else: 116 | # Not an import statement; move on. 117 | continue 118 | 119 | imported_modules = self._trim_each_to_known_modules(imported_modules) 120 | return imported_modules 121 | 122 | def _trim_each_to_known_modules(self, imported_modules: List[Module]) -> List[Module]: 123 | known_modules = [] 124 | for imported_module in imported_modules: 125 | if imported_module in self.modules: 126 | known_modules.append(imported_module) 127 | else: 128 | # The module isn't in the known modules. This is because it's something *within* 129 | # a module (e.g. a function): the result of something like 'from .subpackage 130 | # import my_function'. So we trim the components back to the module. 131 | components = imported_module.name.split('.')[:-1] 132 | trimmed_module = Module('.'.join(components)) 133 | if trimmed_module in self.modules: 134 | known_modules.append(trimmed_module) 135 | else: 136 | # TODO: we may want to warn the user about this. 137 | logger.debug('{} not found in modules.'.format(trimmed_module)) 138 | return known_modules 139 | -------------------------------------------------------------------------------- /src/layer_linter/dependencies/graph.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import List, Optional, Any 3 | import networkx # type: ignore 4 | from networkx.algorithms import shortest_path # type: ignore 5 | 6 | from ..module import Module, SafeFilenameModule 7 | from .path import ImportPath 8 | from .scanner import PackageScanner 9 | from .analysis import DependencyAnalyzer 10 | 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | class DependencyGraph: 16 | """ 17 | A graph of the internal dependencies in a Python package. 18 | 19 | Usage: 20 | graph = DependencyGraph( 21 | SafeFilenameModule('mypackage', '/path/to/mypackage/__init__.py') 22 | ) 23 | 24 | path = graph.find_path( 25 | downstream=Module('mypackage.foo.one'), 26 | upstream=Module('mypackage.bar.two'), 27 | ignore_paths=[ 28 | ImportPath( 29 | importer=Module('mypackage.foo'), 30 | imported=Module('mypackage.baz.three'), 31 | ), 32 | ] 33 | ) 34 | 35 | descendants = graph.get_descendants(Module('mypackage.foo')) 36 | """ 37 | def __init__(self, package: SafeFilenameModule) -> None: 38 | scanner = PackageScanner(package) 39 | self.modules = scanner.scan_for_modules() 40 | 41 | self._networkx_graph = networkx.DiGraph() 42 | self.dependency_count = 0 43 | 44 | analyzer = DependencyAnalyzer(modules=self.modules, package=package) 45 | for import_path in analyzer.determine_import_paths(): 46 | self._add_path_to_networkx_graph(import_path) 47 | self.dependency_count += 1 48 | 49 | self.module_count = len(self.modules) 50 | 51 | def get_modules_directly_imported_by(self, importer: Module) -> List[Module]: 52 | """ 53 | Returns all the modules directly imported by the importer. 54 | """ 55 | if importer in self._networkx_graph: 56 | return self._networkx_graph.successors(importer) 57 | else: 58 | # Nodes that do not import anything are not present in the networkx graph. 59 | return [] 60 | 61 | def find_path(self, 62 | downstream: Module, upstream: Module, 63 | ignore_paths: Optional[List[ImportPath]] = None) -> List[Module]: 64 | """ 65 | Find a list of module names showing the dependency path between the downstream and the 66 | upstream module, or None if there is no dependency. 67 | 68 | For example, given an upstream module a and a downstream module d: 69 | 70 | - [d, a] will be returned if d directly imports a. 71 | - [d, c, b, a] will be returned if d imports c, which imports b, which imports a. 72 | - None will be returned if d does not import a (even indirectly). 73 | """ 74 | logger.debug("Finding path from '{}' up to '{}'.".format(downstream, upstream)) 75 | ignore_paths = ignore_paths if ignore_paths else [] 76 | 77 | removed_paths = self._remove_paths_from_networkx_graph(ignore_paths) 78 | 79 | try: 80 | path = shortest_path(self._networkx_graph, downstream, upstream) 81 | except (networkx.NetworkXNoPath, networkx.exception.NodeNotFound): 82 | # Either there is no path, or one of the modules doesn't even exist. 83 | path = None 84 | else: 85 | path = tuple(path) 86 | 87 | self._restore_paths_to_networkx_graph(removed_paths) 88 | 89 | return path 90 | 91 | def get_descendants(self, module: Module) -> List[Module]: 92 | """ 93 | Returns: 94 | List of modules that are within the supplied module. 95 | """ 96 | descendants: List[Module] = [] 97 | for candidate in self.modules: 98 | if candidate.name.startswith('{}.'.format(module.name)): 99 | descendants.append(candidate) 100 | return descendants 101 | 102 | def _add_path_to_networkx_graph(self, import_path: ImportPath) -> None: 103 | self._networkx_graph.add_edge(import_path.importer, import_path.imported) 104 | 105 | def _remove_path_from_networkx_graph(self, import_path: ImportPath) -> None: 106 | self._networkx_graph.remove_edge(import_path.importer, import_path.imported) 107 | 108 | def _import_path_is_in_networkx_graph(self, import_path: ImportPath) -> bool: 109 | return self._networkx_graph.has_successor( 110 | import_path.importer, import_path.imported 111 | ) 112 | 113 | def _remove_paths_from_networkx_graph( 114 | self, import_paths: List[ImportPath] 115 | ) -> List[ImportPath]: 116 | """ 117 | Given a list of ImportPaths, remove any that exist from the graph. 118 | 119 | Returns: 120 | List of removed ImportPaths. 121 | """ 122 | removed_paths = [] 123 | for import_path in import_paths: 124 | if self._import_path_is_in_networkx_graph(import_path): 125 | self._remove_path_from_networkx_graph(import_path) 126 | removed_paths.append(import_path) 127 | return removed_paths 128 | 129 | def _restore_paths_to_networkx_graph(self, import_paths: List[ImportPath]) -> None: 130 | for import_path in import_paths: 131 | self._add_path_to_networkx_graph(import_path) 132 | 133 | def __contains__(self, item: Any) -> bool: 134 | """ 135 | Returns whether or not the given item is a Module in the dependency graph. 136 | 137 | Usage: 138 | if module in graph: 139 | ... 140 | """ 141 | return item in self.modules 142 | -------------------------------------------------------------------------------- /src/layer_linter/dependencies/path.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from ..module import Module 4 | 5 | 6 | class ImportPath: 7 | """ 8 | A direct dependency path between two modules. For example, 9 | if foo imports bar.baz, then the ImportPath is: 10 | ImportPath( 11 | importer=Module('foo'), 12 | imported=Module('bar.baz'), 13 | ) 14 | """ 15 | def __init__(self, importer: Module, imported: Module) -> None: 16 | self.importer = importer 17 | self.imported = imported 18 | 19 | def __str__(self) -> str: 20 | return "{} <- {}".format(self.importer, self.imported) 21 | 22 | def __repr__(self) -> str: 23 | return '<{}: {}>'.format(self.__class__.__name__, self) 24 | 25 | def __eq__(self, other: Any) -> bool: 26 | if isinstance(other, ImportPath): 27 | return (self.importer, self.imported) == (other.importer, other.imported) 28 | else: 29 | return False 30 | 31 | def __hash__(self) -> int: 32 | return hash(str(self)) 33 | -------------------------------------------------------------------------------- /src/layer_linter/dependencies/scanner.py: -------------------------------------------------------------------------------- 1 | from typing import List, Iterator 2 | import os 3 | import logging 4 | 5 | from ..module import SafeFilenameModule 6 | 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | class PackageScanner: 12 | """ 13 | Scans a package for all the Python modules within it. 14 | 15 | Usage: 16 | package = SafeFilenameModule('mypackage', '/path/to/mypackage/__init__.py') 17 | scanner = PackageScanner(package) 18 | modules = scanner.scan_for_modules() 19 | """ 20 | def __init__(self, package: SafeFilenameModule) -> None: 21 | self.package = package 22 | 23 | def scan_for_modules(self) -> List[SafeFilenameModule]: 24 | """ 25 | Returns: 26 | List of module names (list(str, ...)). 27 | """ 28 | package_directory = os.path.dirname(self.package.filename) 29 | modules: List[SafeFilenameModule] = [] 30 | 31 | for module_filename in self._get_python_files_inside_package(package_directory): 32 | module_name = self._module_name_from_filename(module_filename, package_directory) 33 | modules.append( 34 | SafeFilenameModule(module_name, module_filename) 35 | ) 36 | 37 | return modules 38 | 39 | def _get_python_files_inside_package(self, directory: str) -> Iterator[str]: 40 | """ 41 | Get a list of Python files within the supplied package directory. 42 | Return: 43 | Generator of Python file names. 44 | """ 45 | for root, dirs, files in os.walk(directory): 46 | # Don't include directories that aren't Python packages, 47 | # nor their subdirectories. 48 | if '__init__.py' not in files: 49 | for d in list(dirs): 50 | dirs.remove(d) 51 | continue 52 | 53 | # Don't include hidden directories. 54 | dirs_to_remove = [d for d in dirs if self._should_ignore_dir(d)] 55 | for d in dirs_to_remove: 56 | dirs.remove(d) 57 | 58 | for filename in files: 59 | if self._is_python_file(filename): 60 | yield os.path.join(root, filename) 61 | 62 | def _should_ignore_dir(self, directory: str) -> bool: 63 | # TODO: make this configurable. 64 | # Skip adding directories that are hidden, or look like Django migrations. 65 | return directory.startswith('.') or directory == 'migrations' 66 | 67 | def _is_python_file(self, filename: str) -> bool: 68 | """ 69 | Given a filename, return whether it's a Python file. 70 | 71 | Args: 72 | filename (str): the filename, excluding the path. 73 | Returns: 74 | bool: whether it's a Python file. 75 | """ 76 | return not filename.startswith('.') and filename.endswith('.py') 77 | 78 | def _module_name_from_filename(self, filename_and_path: str, package_directory: str) -> str: 79 | """ 80 | Args: 81 | filename_and_path (string) - the full name of the Python file. 82 | package_directory (string) - the full path of the top level Python package directory. 83 | Returns: 84 | Absolute module name for importing (string). 85 | """ 86 | container_directory, package_name = os.path.split(package_directory) 87 | internal_filename_and_path = filename_and_path[len(package_directory):] 88 | internal_filename_and_path_without_extension = internal_filename_and_path[1:-3] 89 | components = [package_name] + internal_filename_and_path_without_extension.split(os.sep) 90 | if components[-1] == '__init__': 91 | components.pop() 92 | return '.'.join(components) 93 | -------------------------------------------------------------------------------- /src/layer_linter/module.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | 4 | class Module: 5 | """ 6 | A Python module. 7 | """ 8 | def __init__(self, name: str) -> None: 9 | """ 10 | Args: 11 | name: The fully qualified name of a Python module, e.g. 'package.foo.bar'. 12 | """ 13 | self.name = name 14 | 15 | def __str__(self) -> str: 16 | return self.name 17 | 18 | def __repr__(self) -> str: 19 | return "<{}: {}>".format(self.__class__.__name__, self) 20 | 21 | def __eq__(self, other: Any) -> bool: 22 | # Only other Module instances with the same name are equal to each other. 23 | if isinstance(other, Module): 24 | return self.name == other.name 25 | else: 26 | return False 27 | 28 | def __hash__(self) -> int: 29 | return hash(str(self)) 30 | 31 | 32 | class SafeFilenameModule(Module): 33 | """ 34 | A Python module whose filename can be known safely, without importing the code. 35 | """ 36 | def __init__(self, name: str, filename: str) -> None: 37 | """ 38 | Args: 39 | name: The fully qualified name of a Python module, e.g. 'package.foo.bar'. 40 | filename: The full filename and path to the Python file, 41 | e.g. '/path/to/package/one.py'. 42 | """ 43 | self.filename = filename 44 | super().__init__(name) 45 | -------------------------------------------------------------------------------- /src/layer_linter/report.py: -------------------------------------------------------------------------------- 1 | from typing import List, Type 2 | 3 | import click 4 | 5 | from .dependencies import DependencyGraph 6 | from .contract import Contract 7 | 8 | VERBOSITY_QUIET = 0 9 | VERBOSITY_NORMAL = 1 10 | VERBOSITY_HIGH = 2 11 | 12 | 13 | def get_report_class(verbosity: int) -> Type['BaseReport']: 14 | return { 15 | VERBOSITY_QUIET: QuietReport, 16 | VERBOSITY_NORMAL: NormalReport, 17 | VERBOSITY_HIGH: VerboseReport, 18 | }[verbosity] 19 | 20 | 21 | class BaseReport: 22 | def __init__(self, graph: DependencyGraph) -> None: 23 | self.graph = graph 24 | self.kept_contracts: List[Contract] = [] 25 | self.broken_contracts: List[Contract] = [] 26 | self.has_broken_contracts = False 27 | 28 | def add_contract(self, contract: Contract) -> None: 29 | if contract.is_kept: 30 | self.kept_contracts.append(contract) 31 | else: 32 | self.broken_contracts.append(contract) 33 | self.has_broken_contracts = True 34 | 35 | def output(self) -> None: 36 | self._output_title() 37 | self._output_contracts_analysis() 38 | if self.broken_contracts: 39 | self._output_broken_contracts_details() 40 | 41 | def _output_title(self): 42 | ConsolePrinter.print_heading('Layer Linter', ConsolePrinter.HEADING_LEVEL_ONE) 43 | 44 | def _output_contracts_analysis(self): 45 | ConsolePrinter.print_heading('Contracts', ConsolePrinter.HEADING_LEVEL_TWO) 46 | 47 | ConsolePrinter.print_heading( 48 | 'Analyzed {} files, {} dependencies.'.format( 49 | self.graph.module_count, 50 | self.graph.dependency_count, 51 | ), ConsolePrinter.HEADING_LEVEL_THREE) 52 | 53 | for contract in self.kept_contracts: 54 | self._output_contract_analysis(contract) 55 | 56 | for contract in self.broken_contracts: 57 | self._output_contract_analysis(contract) 58 | 59 | self._output_contracts_summary() 60 | 61 | def _output_contract_analysis(self, contract): 62 | ConsolePrinter.print_contract_one_liner(contract) 63 | 64 | def _output_contracts_summary(self): 65 | # Print summary line. 66 | ConsolePrinter.new_line() 67 | print_callback = ConsolePrinter.print_error if self.broken_contracts else \ 68 | ConsolePrinter.print_success 69 | print_callback('Contracts: {} kept, {} broken.'.format( 70 | len(self.kept_contracts), 71 | len(self.broken_contracts) 72 | )) 73 | ConsolePrinter.new_line() 74 | 75 | def _output_broken_contracts_details(self): 76 | ConsolePrinter.print_heading('Broken contracts', 77 | ConsolePrinter.HEADING_LEVEL_TWO, 78 | style=ConsolePrinter.ERROR) 79 | ConsolePrinter.new_line() 80 | 81 | for broken_contract in self.broken_contracts: 82 | ConsolePrinter.print_heading(str(broken_contract), ConsolePrinter.HEADING_LEVEL_THREE, 83 | style=ConsolePrinter.ERROR) 84 | 85 | ConsolePrinter.new_line() 86 | 87 | for index, illegal_dependency in enumerate(broken_contract.illegal_dependencies): 88 | first = illegal_dependency[0] 89 | last = illegal_dependency[-1] 90 | 91 | ConsolePrinter.print_error('{}. {} imports {}:'.format(index + 1, first, last)) 92 | ConsolePrinter.new_line() 93 | 94 | for dep_index, item in enumerate(illegal_dependency): 95 | error_text = str(item) 96 | if dep_index < len(illegal_dependency) - 1: 97 | error_text += ' <-' 98 | ConsolePrinter.indent_cursor() 99 | ConsolePrinter.print_error(error_text, bold=False) 100 | ConsolePrinter.new_line() 101 | 102 | 103 | class NormalReport(BaseReport): 104 | pass 105 | 106 | 107 | class QuietReport(NormalReport): 108 | """ 109 | Report that only reports when there is a broken contract. 110 | """ 111 | def output(self) -> None: 112 | if self.broken_contracts: 113 | super().output() 114 | 115 | 116 | class VerboseReport(BaseReport): 117 | def output(self): 118 | self._output_title() 119 | self._output_dependencies() 120 | self._output_contracts_analysis() 121 | if self.broken_contracts: 122 | self._output_broken_contracts_details() 123 | 124 | def _output_dependencies(self): 125 | ConsolePrinter.print_heading('Dependencies', ConsolePrinter.HEADING_LEVEL_TWO) 126 | 127 | for importer in self.graph.modules: 128 | ConsolePrinter.print('{} imports:'.format(importer), bold=True) 129 | has_at_least_one_successor = False 130 | for imported in self.graph.get_modules_directly_imported_by(importer=importer): 131 | has_at_least_one_successor = True 132 | ConsolePrinter.indent_cursor() 133 | ConsolePrinter.print('- {}'.format(imported)) 134 | if not has_at_least_one_successor: 135 | ConsolePrinter.indent_cursor() 136 | ConsolePrinter.print('- (nothing)') 137 | ConsolePrinter.new_line() 138 | 139 | 140 | class ConsolePrinter: 141 | ERROR = 'error' 142 | SUCCESS = 'success' 143 | COLORS = { 144 | ERROR: 'red', 145 | SUCCESS: 'green', 146 | } 147 | 148 | HEADING_LEVEL_ONE = 1 149 | HEADING_LEVEL_TWO = 2 150 | HEADING_LEVEL_THREE = 3 151 | 152 | HEADING_MAP = { 153 | HEADING_LEVEL_ONE: ('=', True), 154 | HEADING_LEVEL_TWO: ('-', True), 155 | HEADING_LEVEL_THREE: ('-', False), 156 | } 157 | 158 | INDENT_SIZE = 4 159 | 160 | @classmethod 161 | def print_heading(cls, text, level, style=None): 162 | """ 163 | Prints the supplied text to the console, formatted as a heading. 164 | 165 | Args: 166 | text (str): the text to format as a heading. 167 | level (int): the level of heading to display (one of the keys of HEADING_MAP). 168 | style (str, optional): ERROR or SUCCESS style to apply (default None). 169 | Usage: 170 | 171 | ConsolePrinter.print_heading('Foo', ConsolePrinter.HEADING_LEVEL_ONE) 172 | """ 173 | # Setup styling variables. 174 | is_bold = True 175 | color = cls.COLORS[style] if style else None 176 | line_char, show_line_above = cls.HEADING_MAP[level] 177 | heading_line = line_char * len(text) 178 | 179 | # Print lines. 180 | if show_line_above: 181 | click.secho(heading_line, bold=is_bold, fg=color) 182 | click.secho(text, bold=is_bold, fg=color) 183 | click.secho(heading_line, bold=is_bold, fg=color) 184 | click.echo() 185 | 186 | @classmethod 187 | def print(cls, text, bold=False): 188 | """ 189 | Prints a line to the console. 190 | """ 191 | click.secho(text, bold=bold) 192 | 193 | @classmethod 194 | def print_success(cls, text, bold=True): 195 | """ 196 | Prints a line to the console, formatted as a success. 197 | """ 198 | click.secho(text, fg=cls.COLORS[cls.SUCCESS], bold=bold) 199 | 200 | @classmethod 201 | def print_error(cls, text, bold=True): 202 | """ 203 | Prints a line to the console, formatted as an error. 204 | """ 205 | click.secho(text, fg=cls.COLORS[cls.ERROR], bold=bold) 206 | 207 | @classmethod 208 | def indent_cursor(cls): 209 | """ 210 | Indents the cursor ready to print a line. 211 | """ 212 | click.echo(' ' * cls.INDENT_SIZE, nl=False) 213 | 214 | @classmethod 215 | def new_line(cls): 216 | click.echo() 217 | 218 | @classmethod 219 | def print_contract_one_liner(cls, contract: Contract) -> None: 220 | is_kept = contract.is_kept 221 | click.secho('{} '.format(contract), nl=False) 222 | if contract.whitelisted_paths: 223 | click.secho('({} whitelisted paths) '.format(len(contract.whitelisted_paths)), 224 | nl=False) 225 | status_map = { 226 | True: ('KEPT', cls.SUCCESS), 227 | False: ('BROKEN', cls.ERROR), 228 | } 229 | color = cls.COLORS[status_map[is_kept][1]] 230 | click.secho(status_map[is_kept][0], fg=color, bold=True) 231 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seddonym/layer_linter/801c0a30d814968bc9041dd29fdeccdccfff8cb7/tests/__init__.py -------------------------------------------------------------------------------- /tests/assets/analyzerpackage/analyzerpackage/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seddonym/layer_linter/801c0a30d814968bc9041dd29fdeccdccfff8cb7/tests/assets/analyzerpackage/analyzerpackage/__init__.py -------------------------------------------------------------------------------- /tests/assets/analyzerpackage/analyzerpackage/notsupplied/README.txt: -------------------------------------------------------------------------------- 1 | The notsupplied package should not be analyzed, as we don't pass it to 2 | the analyzer in the functional test. 3 | -------------------------------------------------------------------------------- /tests/assets/analyzerpackage/analyzerpackage/notsupplied/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seddonym/layer_linter/801c0a30d814968bc9041dd29fdeccdccfff8cb7/tests/assets/analyzerpackage/analyzerpackage/notsupplied/__init__.py -------------------------------------------------------------------------------- /tests/assets/analyzerpackage/analyzerpackage/notsupplied/alpha.py: -------------------------------------------------------------------------------- 1 | from ..two.alpha import BAR 2 | 3 | FOO = BAR 4 | -------------------------------------------------------------------------------- /tests/assets/analyzerpackage/analyzerpackage/one/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seddonym/layer_linter/801c0a30d814968bc9041dd29fdeccdccfff8cb7/tests/assets/analyzerpackage/analyzerpackage/one/__init__.py -------------------------------------------------------------------------------- /tests/assets/analyzerpackage/analyzerpackage/one/alpha.py: -------------------------------------------------------------------------------- 1 | import sys # Standard library import. 2 | import pytest # Third party library import. 3 | 4 | BAR = 'bar' 5 | -------------------------------------------------------------------------------- /tests/assets/analyzerpackage/analyzerpackage/one/beta.py: -------------------------------------------------------------------------------- 1 | from analyzerpackage.one import alpha 2 | 3 | def foo(): 4 | return alpha.BAR 5 | -------------------------------------------------------------------------------- /tests/assets/analyzerpackage/analyzerpackage/one/gamma.py: -------------------------------------------------------------------------------- 1 | from .beta import foo 2 | -------------------------------------------------------------------------------- /tests/assets/analyzerpackage/analyzerpackage/two/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seddonym/layer_linter/801c0a30d814968bc9041dd29fdeccdccfff8cb7/tests/assets/analyzerpackage/analyzerpackage/two/__init__.py -------------------------------------------------------------------------------- /tests/assets/analyzerpackage/analyzerpackage/two/alpha.py: -------------------------------------------------------------------------------- 1 | from ..one import alpha 2 | 3 | BAR = alpha.BAR 4 | -------------------------------------------------------------------------------- /tests/assets/analyzerpackage/analyzerpackage/two/beta.py: -------------------------------------------------------------------------------- 1 | from analyzerpackage.one import alpha 2 | 3 | def foo(): 4 | return alpha.BAR 5 | -------------------------------------------------------------------------------- /tests/assets/analyzerpackage/analyzerpackage/two/gamma.py: -------------------------------------------------------------------------------- 1 | from .beta import foo 2 | from .. import utils 3 | -------------------------------------------------------------------------------- /tests/assets/analyzerpackage/analyzerpackage/utils.py: -------------------------------------------------------------------------------- 1 | from . import one 2 | 3 | 4 | def foo(): 5 | from .two import alpha 6 | return 1 7 | -------------------------------------------------------------------------------- /tests/assets/dependenciespackage/dependenciespackage/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seddonym/layer_linter/801c0a30d814968bc9041dd29fdeccdccfff8cb7/tests/assets/dependenciespackage/dependenciespackage/__init__.py -------------------------------------------------------------------------------- /tests/assets/dependenciespackage/dependenciespackage/four.py: -------------------------------------------------------------------------------- 1 | from dependenciespackage.three import THREE_CONSTANT 2 | # Use an external dependency too; this should be ignored. 3 | import pytest 4 | -------------------------------------------------------------------------------- /tests/assets/dependenciespackage/dependenciespackage/one.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seddonym/layer_linter/801c0a30d814968bc9041dd29fdeccdccfff8cb7/tests/assets/dependenciespackage/dependenciespackage/one.py -------------------------------------------------------------------------------- /tests/assets/dependenciespackage/dependenciespackage/subpackage/.hidden/__init__.py: -------------------------------------------------------------------------------- 1 | # This is a hidden file that shouldn't be picked up. 2 | -------------------------------------------------------------------------------- /tests/assets/dependenciespackage/dependenciespackage/subpackage/.hidden/hidden.py: -------------------------------------------------------------------------------- 1 | from . import three 2 | -------------------------------------------------------------------------------- /tests/assets/dependenciespackage/dependenciespackage/subpackage/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seddonym/layer_linter/801c0a30d814968bc9041dd29fdeccdccfff8cb7/tests/assets/dependenciespackage/dependenciespackage/subpackage/__init__.py -------------------------------------------------------------------------------- /tests/assets/dependenciespackage/dependenciespackage/subpackage/is.py: -------------------------------------------------------------------------------- 1 | # This module has a reserved keyword. To keep things simple, we're going to treat it as a normal 2 | # module, but it's included here to ensure that it doesn't break things. 3 | from . import three 4 | -------------------------------------------------------------------------------- /tests/assets/dependenciespackage/dependenciespackage/subpackage/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seddonym/layer_linter/801c0a30d814968bc9041dd29fdeccdccfff8cb7/tests/assets/dependenciespackage/dependenciespackage/subpackage/migrations/__init__.py -------------------------------------------------------------------------------- /tests/assets/dependenciespackage/dependenciespackage/subpackage/migrations/migration_1.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seddonym/layer_linter/801c0a30d814968bc9041dd29fdeccdccfff8cb7/tests/assets/dependenciespackage/dependenciespackage/subpackage/migrations/migration_1.py -------------------------------------------------------------------------------- /tests/assets/dependenciespackage/dependenciespackage/subpackage/migrations/migration_2.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seddonym/layer_linter/801c0a30d814968bc9041dd29fdeccdccfff8cb7/tests/assets/dependenciespackage/dependenciespackage/subpackage/migrations/migration_2.py -------------------------------------------------------------------------------- /tests/assets/dependenciespackage/dependenciespackage/subpackage/one.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /tests/assets/dependenciespackage/dependenciespackage/subpackage/subsubpackage/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seddonym/layer_linter/801c0a30d814968bc9041dd29fdeccdccfff8cb7/tests/assets/dependenciespackage/dependenciespackage/subpackage/subsubpackage/__init__.py -------------------------------------------------------------------------------- /tests/assets/dependenciespackage/dependenciespackage/subpackage/subsubpackage/one.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seddonym/layer_linter/801c0a30d814968bc9041dd29fdeccdccfff8cb7/tests/assets/dependenciespackage/dependenciespackage/subpackage/subsubpackage/one.py -------------------------------------------------------------------------------- /tests/assets/dependenciespackage/dependenciespackage/subpackage/subsubpackage/three.py: -------------------------------------------------------------------------------- 1 | from . import two 2 | -------------------------------------------------------------------------------- /tests/assets/dependenciespackage/dependenciespackage/subpackage/subsubpackage/two.py: -------------------------------------------------------------------------------- 1 | from . import one 2 | -------------------------------------------------------------------------------- /tests/assets/dependenciespackage/dependenciespackage/subpackage/three.py: -------------------------------------------------------------------------------- 1 | from . import two 2 | -------------------------------------------------------------------------------- /tests/assets/dependenciespackage/dependenciespackage/subpackage/two.py: -------------------------------------------------------------------------------- 1 | from . import one 2 | -------------------------------------------------------------------------------- /tests/assets/dependenciespackage/dependenciespackage/three.py: -------------------------------------------------------------------------------- 1 | from dependenciespackage import two 2 | 3 | THREE_CONSTANT = 1 4 | -------------------------------------------------------------------------------- /tests/assets/dependenciespackage/dependenciespackage/two.py: -------------------------------------------------------------------------------- 1 | from . import one # Relative import 2 | -------------------------------------------------------------------------------- /tests/assets/differentimporttypes/differentimporttypes/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seddonym/layer_linter/801c0a30d814968bc9041dd29fdeccdccfff8cb7/tests/assets/differentimporttypes/differentimporttypes/__init__.py -------------------------------------------------------------------------------- /tests/assets/differentimporttypes/differentimporttypes/five/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seddonym/layer_linter/801c0a30d814968bc9041dd29fdeccdccfff8cb7/tests/assets/differentimporttypes/differentimporttypes/five/__init__.py -------------------------------------------------------------------------------- /tests/assets/differentimporttypes/differentimporttypes/five/alpha.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seddonym/layer_linter/801c0a30d814968bc9041dd29fdeccdccfff8cb7/tests/assets/differentimporttypes/differentimporttypes/five/alpha.py -------------------------------------------------------------------------------- /tests/assets/differentimporttypes/differentimporttypes/four/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seddonym/layer_linter/801c0a30d814968bc9041dd29fdeccdccfff8cb7/tests/assets/differentimporttypes/differentimporttypes/four/__init__.py -------------------------------------------------------------------------------- /tests/assets/differentimporttypes/differentimporttypes/four/alpha.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seddonym/layer_linter/801c0a30d814968bc9041dd29fdeccdccfff8cb7/tests/assets/differentimporttypes/differentimporttypes/four/alpha.py -------------------------------------------------------------------------------- /tests/assets/differentimporttypes/differentimporttypes/one/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seddonym/layer_linter/801c0a30d814968bc9041dd29fdeccdccfff8cb7/tests/assets/differentimporttypes/differentimporttypes/one/__init__.py -------------------------------------------------------------------------------- /tests/assets/differentimporttypes/differentimporttypes/one/alpha.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seddonym/layer_linter/801c0a30d814968bc9041dd29fdeccdccfff8cb7/tests/assets/differentimporttypes/differentimporttypes/one/alpha.py -------------------------------------------------------------------------------- /tests/assets/differentimporttypes/differentimporttypes/one/beta.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seddonym/layer_linter/801c0a30d814968bc9041dd29fdeccdccfff8cb7/tests/assets/differentimporttypes/differentimporttypes/one/beta.py -------------------------------------------------------------------------------- /tests/assets/differentimporttypes/differentimporttypes/one/delta.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seddonym/layer_linter/801c0a30d814968bc9041dd29fdeccdccfff8cb7/tests/assets/differentimporttypes/differentimporttypes/one/delta.py -------------------------------------------------------------------------------- /tests/assets/differentimporttypes/differentimporttypes/one/epsilon.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seddonym/layer_linter/801c0a30d814968bc9041dd29fdeccdccfff8cb7/tests/assets/differentimporttypes/differentimporttypes/one/epsilon.py -------------------------------------------------------------------------------- /tests/assets/differentimporttypes/differentimporttypes/one/gamma/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seddonym/layer_linter/801c0a30d814968bc9041dd29fdeccdccfff8cb7/tests/assets/differentimporttypes/differentimporttypes/one/gamma/__init__.py -------------------------------------------------------------------------------- /tests/assets/differentimporttypes/differentimporttypes/one/gamma/foo.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seddonym/layer_linter/801c0a30d814968bc9041dd29fdeccdccfff8cb7/tests/assets/differentimporttypes/differentimporttypes/one/gamma/foo.py -------------------------------------------------------------------------------- /tests/assets/differentimporttypes/differentimporttypes/one/importer.py: -------------------------------------------------------------------------------- 1 | 1 / 0 # Check that Runtime errors don't prevent this from being analyzed. 2 | 3 | from differentimporttypes.one import alpha # Import from: absolute import of module. 4 | from differentimporttypes import one # Import from: absolute import of package. 5 | 6 | from . import beta # Import from: relative, same level, module. 7 | from . import gamma # Import from: relative, same level, package 8 | 9 | from .gamma import foo # Import from: relative, down a level, module. 10 | 11 | from ..two import alpha # Import from: relative, up a level, module. 12 | 13 | from .delta import some_function # Import from: a function. NOT SUPPORTED YET. 14 | 15 | def wrapper(): 16 | from . import epsilon # Import from: inside a function. 17 | 18 | 19 | import differentimporttypes.three # Import: absolute import of package. 20 | import differentimporttypes.four.alpha # Import: absolute import of module. 21 | 22 | # Check that imports from other packages aren't included. 23 | import pytest 24 | from pytest import mark 25 | -------------------------------------------------------------------------------- /tests/assets/differentimporttypes/differentimporttypes/three.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seddonym/layer_linter/801c0a30d814968bc9041dd29fdeccdccfff8cb7/tests/assets/differentimporttypes/differentimporttypes/three.py -------------------------------------------------------------------------------- /tests/assets/differentimporttypes/differentimporttypes/two/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seddonym/layer_linter/801c0a30d814968bc9041dd29fdeccdccfff8cb7/tests/assets/differentimporttypes/differentimporttypes/two/__init__.py -------------------------------------------------------------------------------- /tests/assets/differentimporttypes/differentimporttypes/two/alpha.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seddonym/layer_linter/801c0a30d814968bc9041dd29fdeccdccfff8cb7/tests/assets/differentimporttypes/differentimporttypes/two/alpha.py -------------------------------------------------------------------------------- /tests/assets/failurepackage/failurepackage/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seddonym/layer_linter/801c0a30d814968bc9041dd29fdeccdccfff8cb7/tests/assets/failurepackage/failurepackage/__init__.py -------------------------------------------------------------------------------- /tests/assets/failurepackage/failurepackage/one/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seddonym/layer_linter/801c0a30d814968bc9041dd29fdeccdccfff8cb7/tests/assets/failurepackage/failurepackage/one/__init__.py -------------------------------------------------------------------------------- /tests/assets/failurepackage/failurepackage/one/alpha.py: -------------------------------------------------------------------------------- 1 | BAR = 'bar' 2 | -------------------------------------------------------------------------------- /tests/assets/failurepackage/failurepackage/one/beta.py: -------------------------------------------------------------------------------- 1 | from failurepackage.one import alpha 2 | 3 | def foo(): 4 | from failurepackage.three.gamma import BAZ 5 | return alpha.BAR + BAZ 6 | -------------------------------------------------------------------------------- /tests/assets/failurepackage/failurepackage/one/gamma.py: -------------------------------------------------------------------------------- 1 | from .beta import foo 2 | -------------------------------------------------------------------------------- /tests/assets/failurepackage/failurepackage/three/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seddonym/layer_linter/801c0a30d814968bc9041dd29fdeccdccfff8cb7/tests/assets/failurepackage/failurepackage/three/__init__.py -------------------------------------------------------------------------------- /tests/assets/failurepackage/failurepackage/three/alpha.py: -------------------------------------------------------------------------------- 1 | from ..two.alpha import FOOBAR 2 | 3 | BAR = FOOBAR 4 | -------------------------------------------------------------------------------- /tests/assets/failurepackage/failurepackage/three/beta.py: -------------------------------------------------------------------------------- 1 | from failurepackage.one import alpha 2 | 3 | def foo(): 4 | return alpha.BAR 5 | -------------------------------------------------------------------------------- /tests/assets/failurepackage/failurepackage/three/gamma.py: -------------------------------------------------------------------------------- 1 | from .beta import foo 2 | 3 | BAZ = 'baz' 4 | -------------------------------------------------------------------------------- /tests/assets/failurepackage/failurepackage/two/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seddonym/layer_linter/801c0a30d814968bc9041dd29fdeccdccfff8cb7/tests/assets/failurepackage/failurepackage/two/__init__.py -------------------------------------------------------------------------------- /tests/assets/failurepackage/failurepackage/two/alpha.py: -------------------------------------------------------------------------------- 1 | from ..one import alpha 2 | 3 | BAR = alpha.BAR 4 | -------------------------------------------------------------------------------- /tests/assets/failurepackage/failurepackage/two/beta.py: -------------------------------------------------------------------------------- 1 | from .gamma import foo 2 | -------------------------------------------------------------------------------- /tests/assets/failurepackage/failurepackage/two/gamma.py: -------------------------------------------------------------------------------- 1 | from failurepackage.two import alpha 2 | 3 | def foo(): 4 | return alpha.BAR 5 | -------------------------------------------------------------------------------- /tests/assets/failurepackage/layers.yml: -------------------------------------------------------------------------------- 1 | High level: 2 | containers: 3 | - failurepackage 4 | layers: 5 | - three 6 | - two 7 | - one 8 | 9 | Modular: 10 | containers: 11 | - failurepackage.one 12 | - failurepackage.two 13 | - failurepackage.three 14 | layers: 15 | - gamma 16 | - beta 17 | - alpha 18 | -------------------------------------------------------------------------------- /tests/assets/initfileimports/initfileimports/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seddonym/layer_linter/801c0a30d814968bc9041dd29fdeccdccfff8cb7/tests/assets/initfileimports/initfileimports/__init__.py -------------------------------------------------------------------------------- /tests/assets/initfileimports/initfileimports/alpha.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seddonym/layer_linter/801c0a30d814968bc9041dd29fdeccdccfff8cb7/tests/assets/initfileimports/initfileimports/alpha.py -------------------------------------------------------------------------------- /tests/assets/initfileimports/initfileimports/one/__init__.py: -------------------------------------------------------------------------------- 1 | from . import alpha 2 | -------------------------------------------------------------------------------- /tests/assets/initfileimports/initfileimports/one/alpha.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seddonym/layer_linter/801c0a30d814968bc9041dd29fdeccdccfff8cb7/tests/assets/initfileimports/initfileimports/one/alpha.py -------------------------------------------------------------------------------- /tests/assets/initfileimports/initfileimports/two/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seddonym/layer_linter/801c0a30d814968bc9041dd29fdeccdccfff8cb7/tests/assets/initfileimports/initfileimports/two/__init__.py -------------------------------------------------------------------------------- /tests/assets/scannersuccess/scannersuccess/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seddonym/layer_linter/801c0a30d814968bc9041dd29fdeccdccfff8cb7/tests/assets/scannersuccess/scannersuccess/__init__.py -------------------------------------------------------------------------------- /tests/assets/scannersuccess/scannersuccess/four.py: -------------------------------------------------------------------------------- 1 | def foo(): 2 | from .three import alpha 3 | return 1 4 | -------------------------------------------------------------------------------- /tests/assets/scannersuccess/scannersuccess/in/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seddonym/layer_linter/801c0a30d814968bc9041dd29fdeccdccfff8cb7/tests/assets/scannersuccess/scannersuccess/in/__init__.py -------------------------------------------------------------------------------- /tests/assets/scannersuccess/scannersuccess/in/class.py: -------------------------------------------------------------------------------- 1 | # 'Class' is a reserved keyword so can't be a module name. 2 | -------------------------------------------------------------------------------- /tests/assets/scannersuccess/scannersuccess/in/hyphenated-name.py: -------------------------------------------------------------------------------- 1 | # Hyphens are not valid module names. 2 | -------------------------------------------------------------------------------- /tests/assets/scannersuccess/scannersuccess/notapackage/README.txt: -------------------------------------------------------------------------------- 1 | This folder is not a package, so anything below it should not be scanned. 2 | -------------------------------------------------------------------------------- /tests/assets/scannersuccess/scannersuccess/notapackage/orphan/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seddonym/layer_linter/801c0a30d814968bc9041dd29fdeccdccfff8cb7/tests/assets/scannersuccess/scannersuccess/notapackage/orphan/__init__.py -------------------------------------------------------------------------------- /tests/assets/scannersuccess/scannersuccess/notapackage/orphan/foo.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seddonym/layer_linter/801c0a30d814968bc9041dd29fdeccdccfff8cb7/tests/assets/scannersuccess/scannersuccess/notapackage/orphan/foo.py -------------------------------------------------------------------------------- /tests/assets/scannersuccess/scannersuccess/one/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seddonym/layer_linter/801c0a30d814968bc9041dd29fdeccdccfff8cb7/tests/assets/scannersuccess/scannersuccess/one/__init__.py -------------------------------------------------------------------------------- /tests/assets/scannersuccess/scannersuccess/one/alpha.py: -------------------------------------------------------------------------------- 1 | BAR = 'bar' 2 | -------------------------------------------------------------------------------- /tests/assets/scannersuccess/scannersuccess/one/beta.py: -------------------------------------------------------------------------------- 1 | from successpackage.one import alpha 2 | 3 | def foo(): 4 | return alpha.BAR 5 | -------------------------------------------------------------------------------- /tests/assets/scannersuccess/scannersuccess/one/delta/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seddonym/layer_linter/801c0a30d814968bc9041dd29fdeccdccfff8cb7/tests/assets/scannersuccess/scannersuccess/one/delta/__init__.py -------------------------------------------------------------------------------- /tests/assets/scannersuccess/scannersuccess/one/delta/green.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seddonym/layer_linter/801c0a30d814968bc9041dd29fdeccdccfff8cb7/tests/assets/scannersuccess/scannersuccess/one/delta/green.py -------------------------------------------------------------------------------- /tests/assets/scannersuccess/scannersuccess/one/delta/red_blue.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seddonym/layer_linter/801c0a30d814968bc9041dd29fdeccdccfff8cb7/tests/assets/scannersuccess/scannersuccess/one/delta/red_blue.py -------------------------------------------------------------------------------- /tests/assets/scannersuccess/scannersuccess/one/gamma.py: -------------------------------------------------------------------------------- 1 | from .beta import foo 2 | -------------------------------------------------------------------------------- /tests/assets/scannersuccess/scannersuccess/two/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seddonym/layer_linter/801c0a30d814968bc9041dd29fdeccdccfff8cb7/tests/assets/scannersuccess/scannersuccess/two/__init__.py -------------------------------------------------------------------------------- /tests/assets/scannersuccess/scannersuccess/two/alpha.py: -------------------------------------------------------------------------------- 1 | from ..one import alpha 2 | 3 | BAR = alpha.BAR 4 | -------------------------------------------------------------------------------- /tests/assets/scannersuccess/scannersuccess/two/beta.py: -------------------------------------------------------------------------------- 1 | from successpackage.one import alpha 2 | 3 | def foo(): 4 | return alpha.BAR 5 | -------------------------------------------------------------------------------- /tests/assets/scannersuccess/scannersuccess/two/gamma.py: -------------------------------------------------------------------------------- 1 | from .beta import foo 2 | from .. import four 3 | -------------------------------------------------------------------------------- /tests/assets/singlecontractfile/layers.yml: -------------------------------------------------------------------------------- 1 | Contract A: 2 | containers: 3 | - singlecontractfile.foo 4 | - singlecontractfile.bar 5 | layers: 6 | - one 7 | - two 8 | 9 | Contract B: 10 | containers: 11 | - singlecontractfile 12 | layers: 13 | - one 14 | - two 15 | - three 16 | whitelisted_paths: 17 | - baz.utils <- baz.three.green 18 | - baz.three.blue <- baz.two 19 | -------------------------------------------------------------------------------- /tests/assets/singlecontractfile/layers_with_missing_container.yml: -------------------------------------------------------------------------------- 1 | Contract A: 2 | containers: 3 | - singlecontractfile.foo 4 | - singlecontractfile.missing 5 | - singlecontractfile.bar 6 | layers: 7 | - one 8 | - two 9 | -------------------------------------------------------------------------------- /tests/assets/singlecontractfile/singlecontractfile/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seddonym/layer_linter/801c0a30d814968bc9041dd29fdeccdccfff8cb7/tests/assets/singlecontractfile/singlecontractfile/__init__.py -------------------------------------------------------------------------------- /tests/assets/singlecontractfile/singlecontractfile/bar/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seddonym/layer_linter/801c0a30d814968bc9041dd29fdeccdccfff8cb7/tests/assets/singlecontractfile/singlecontractfile/bar/__init__.py -------------------------------------------------------------------------------- /tests/assets/singlecontractfile/singlecontractfile/foo/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seddonym/layer_linter/801c0a30d814968bc9041dd29fdeccdccfff8cb7/tests/assets/singlecontractfile/singlecontractfile/foo/__init__.py -------------------------------------------------------------------------------- /tests/assets/successpackage/layers.yml: -------------------------------------------------------------------------------- 1 | High level: 2 | containers: 3 | - successpackage 4 | layers: 5 | - three 6 | - two 7 | - one 8 | whitelisted_paths: 9 | - successpackage.utils <- successpackage.three.alpha 10 | 11 | Modular: 12 | containers: 13 | - successpackage.one 14 | - successpackage.two 15 | - successpackage.three 16 | layers: 17 | - (delta) 18 | - gamma 19 | - beta 20 | - alpha 21 | -------------------------------------------------------------------------------- /tests/assets/successpackage/layers_alternative.yml: -------------------------------------------------------------------------------- 1 | High level: 2 | containers: 3 | - dependenciespackage 4 | layers: 5 | - four 6 | - three 7 | - two 8 | - one 9 | -------------------------------------------------------------------------------- /tests/assets/successpackage/layers_with_missing_container.yml: -------------------------------------------------------------------------------- 1 | Modular: 2 | containers: 3 | - successpackage.one 4 | - successpackage.two 5 | - successpackage.missing 6 | - successpackage.three 7 | layers: 8 | - gamma 9 | - beta 10 | - alpha 11 | -------------------------------------------------------------------------------- /tests/assets/successpackage/successpackage/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seddonym/layer_linter/801c0a30d814968bc9041dd29fdeccdccfff8cb7/tests/assets/successpackage/successpackage/__init__.py -------------------------------------------------------------------------------- /tests/assets/successpackage/successpackage/one/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seddonym/layer_linter/801c0a30d814968bc9041dd29fdeccdccfff8cb7/tests/assets/successpackage/successpackage/one/__init__.py -------------------------------------------------------------------------------- /tests/assets/successpackage/successpackage/one/alpha.py: -------------------------------------------------------------------------------- 1 | BAR = 'bar' 2 | -------------------------------------------------------------------------------- /tests/assets/successpackage/successpackage/one/beta.py: -------------------------------------------------------------------------------- 1 | from successpackage.one import alpha 2 | 3 | def foo(): 4 | return alpha.BAR 5 | -------------------------------------------------------------------------------- /tests/assets/successpackage/successpackage/one/delta.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seddonym/layer_linter/801c0a30d814968bc9041dd29fdeccdccfff8cb7/tests/assets/successpackage/successpackage/one/delta.py -------------------------------------------------------------------------------- /tests/assets/successpackage/successpackage/one/gamma.py: -------------------------------------------------------------------------------- 1 | from .beta import foo 2 | -------------------------------------------------------------------------------- /tests/assets/successpackage/successpackage/three/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seddonym/layer_linter/801c0a30d814968bc9041dd29fdeccdccfff8cb7/tests/assets/successpackage/successpackage/three/__init__.py -------------------------------------------------------------------------------- /tests/assets/successpackage/successpackage/three/alpha.py: -------------------------------------------------------------------------------- 1 | from ..two.alpha import FOOBAR 2 | 3 | BAR = FOOBAR 4 | -------------------------------------------------------------------------------- /tests/assets/successpackage/successpackage/three/beta.py: -------------------------------------------------------------------------------- 1 | from successpackage.one import alpha 2 | 3 | def foo(): 4 | return alpha.BAR 5 | -------------------------------------------------------------------------------- /tests/assets/successpackage/successpackage/three/delta.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seddonym/layer_linter/801c0a30d814968bc9041dd29fdeccdccfff8cb7/tests/assets/successpackage/successpackage/three/delta.py -------------------------------------------------------------------------------- /tests/assets/successpackage/successpackage/three/gamma.py: -------------------------------------------------------------------------------- 1 | from .beta import foo 2 | -------------------------------------------------------------------------------- /tests/assets/successpackage/successpackage/two/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seddonym/layer_linter/801c0a30d814968bc9041dd29fdeccdccfff8cb7/tests/assets/successpackage/successpackage/two/__init__.py -------------------------------------------------------------------------------- /tests/assets/successpackage/successpackage/two/alpha.py: -------------------------------------------------------------------------------- 1 | from ..one import alpha 2 | 3 | BAR = alpha.BAR 4 | -------------------------------------------------------------------------------- /tests/assets/successpackage/successpackage/two/beta.py: -------------------------------------------------------------------------------- 1 | from successpackage.one import alpha 2 | 3 | def foo(): 4 | return alpha.BAR 5 | -------------------------------------------------------------------------------- /tests/assets/successpackage/successpackage/two/gamma.py: -------------------------------------------------------------------------------- 1 | from .beta import foo 2 | from .. import utils 3 | -------------------------------------------------------------------------------- /tests/assets/successpackage/successpackage/utils.py: -------------------------------------------------------------------------------- 1 | def foo(): 2 | from .three import alpha 3 | return 1 4 | -------------------------------------------------------------------------------- /tests/functional/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seddonym/layer_linter/801c0a30d814968bc9041dd29fdeccdccfff8cb7/tests/functional/__init__.py -------------------------------------------------------------------------------- /tests/functional/dependencies/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seddonym/layer_linter/801c0a30d814968bc9041dd29fdeccdccfff8cb7/tests/functional/dependencies/__init__.py -------------------------------------------------------------------------------- /tests/functional/dependencies/test_analysis.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple, List 2 | import os 3 | 4 | from layer_linter.dependencies.analysis import DependencyAnalyzer 5 | from layer_linter.dependencies.path import ImportPath 6 | from layer_linter.module import Module, SafeFilenameModule 7 | 8 | 9 | class TestDependencyAnalyzer: 10 | def test_success(self): 11 | package = Module('analyzerpackage') 12 | modules = self._build_modules( 13 | package_name=package.name, 14 | tuples=( 15 | ('analyzerpackage', '__init__.py'), 16 | ('analyzerpackage.utils', 'utils.py'), 17 | ('analyzerpackage.one', 'one/__init__.py'), 18 | ('analyzerpackage.one.alpha', 'one/alpha.py'), 19 | ('analyzerpackage.one.beta', 'one/beta.py'), 20 | ('analyzerpackage.one.gamma', 'one/gamma.py'), 21 | ('analyzerpackage.two', 'two/__init__.py'), 22 | ('analyzerpackage.two.alpha', 'two/alpha.py'), 23 | ('analyzerpackage.two.beta', 'two/beta.py'), 24 | ('analyzerpackage.two.gamma', 'two/gamma.py'), 25 | ), 26 | ) 27 | 28 | analyzer = DependencyAnalyzer(modules, package) 29 | import_paths = analyzer.determine_import_paths() 30 | 31 | # N.B. We expect only the leaf imports, it's just noise to have parent packages 32 | # in the graph too. However, if there is a separate parent import, that should be included. 33 | expected_import_paths = self._build_import_paths( 34 | tuples=( 35 | ('analyzerpackage.utils', 'analyzerpackage.one'), 36 | ('analyzerpackage.utils', 'analyzerpackage.two.alpha'), 37 | ('analyzerpackage.one.beta', 'analyzerpackage.one.alpha'), 38 | ('analyzerpackage.one.gamma', 'analyzerpackage.one.beta'), 39 | ('analyzerpackage.two.alpha', 'analyzerpackage.one.alpha'), 40 | ('analyzerpackage.two.beta', 'analyzerpackage.one.alpha'), 41 | ('analyzerpackage.two.gamma', 'analyzerpackage.two.beta'), 42 | ('analyzerpackage.two.gamma', 'analyzerpackage.utils'), 43 | ) 44 | ) 45 | 46 | assert set(import_paths) == set(expected_import_paths) 47 | 48 | def test_all_different_import_types(self): 49 | """ 50 | Test a single file with lots of different import types. Note that all of the other 51 | modules are empty files. 52 | """ 53 | package = Module('differentimporttypes') 54 | modules = self._build_modules( 55 | package_name=package.name, 56 | tuples=( 57 | # The first module is the one with the code we'll analyze. 58 | ('differentimporttypes.one.importer', 'one/importer.py'), 59 | ('differentimporttypes', '__init__.py'), 60 | ('differentimporttypes.one', 'one/__init__.py'), 61 | ('differentimporttypes.one.alpha', 'one/alpha.py'), 62 | ('differentimporttypes.one.beta', 'one/beta.py'), 63 | ('differentimporttypes.one.gamma', 'one/gamma/__init__.py'), 64 | ('differentimporttypes.one.gamma.foo', 'one/gamma/foo.py'), 65 | ('differentimporttypes.one.delta', 'one/delta.py'), 66 | ('differentimporttypes.one.epsilon', 'one/epsilon.py'), 67 | 68 | ('differentimporttypes.two', 'two/__init__.py'), 69 | ('differentimporttypes.two.alpha', 'two/alpha.py'), 70 | ('differentimporttypes.three', 'three.py'), 71 | ('differentimporttypes.four', 'four/__init__.py'), 72 | ('differentimporttypes.four.alpha', 'four/alpha.py'), 73 | # Some other modules, not imported. 74 | ('differentimporttypes.five', 'five/__init__.py'), 75 | ('differentimporttypes.five.alpha', 'five/alpha.py'), 76 | ), 77 | ) 78 | 79 | analyzer = DependencyAnalyzer(modules, package) 80 | import_paths = analyzer.determine_import_paths() 81 | 82 | expected_import_paths = self._build_import_paths( 83 | tuples=( 84 | ('differentimporttypes.one.importer', 'differentimporttypes.one'), 85 | ('differentimporttypes.one.importer', 'differentimporttypes.one.alpha'), 86 | ('differentimporttypes.one.importer', 'differentimporttypes.one.beta'), 87 | ('differentimporttypes.one.importer', 'differentimporttypes.one.gamma'), 88 | ('differentimporttypes.one.importer', 'differentimporttypes.one.gamma.foo'), 89 | ('differentimporttypes.one.importer', 'differentimporttypes.two.alpha'), 90 | ('differentimporttypes.one.importer', 'differentimporttypes.one.delta'), 91 | ('differentimporttypes.one.importer', 'differentimporttypes.one.epsilon'), 92 | ('differentimporttypes.one.importer', 'differentimporttypes.three'), 93 | ('differentimporttypes.one.importer', 'differentimporttypes.four.alpha'), 94 | ) 95 | ) 96 | 97 | assert set(import_paths) == set(expected_import_paths) 98 | 99 | def test_import_from_within_init_file(self): 100 | # Relative imports from within __init__.py files should be interpreted as at the level 101 | # of the sibling modules, not the containing package. 102 | package = Module('initfileimports') 103 | modules = self._build_modules( 104 | package_name=package.name, 105 | tuples=( 106 | ('initfileimports', '__init__.py'), 107 | # This init file has ``from . import alpha``. 108 | ('initfileimports.one', 'one/__init__.py'), 109 | ('initfileimports.one.alpha', 'one/alpha.py'), # The correct imported module. 110 | ('initfileimports.two', 'two/__init__.py'), 111 | ('initfileimports.alpha', 'alpha.py'), # It shouldn't import this one. 112 | ), 113 | ) 114 | 115 | analyzer = DependencyAnalyzer(modules, package) 116 | import_paths = analyzer.determine_import_paths() 117 | 118 | expected_import_paths = self._build_import_paths( 119 | tuples=( 120 | ('initfileimports.one', 'initfileimports.one.alpha'), 121 | ) 122 | ) 123 | 124 | assert set(import_paths) == set(expected_import_paths) 125 | 126 | def _build_modules(self, 127 | package_name: str, 128 | tuples: Tuple[Tuple[str, str]]) -> List[SafeFilenameModule]: 129 | package_path = os.path.abspath( 130 | os.path.join( 131 | os.path.dirname(__file__), 132 | '..', '..', 'assets', package_name, package_name, 133 | ) 134 | ) 135 | modules = [] 136 | for module_name, module_filename in tuples: 137 | modules.append( 138 | SafeFilenameModule( 139 | name=module_name, 140 | filename=os.path.join(package_path, module_filename), 141 | ) 142 | ) 143 | return modules 144 | 145 | def _build_import_paths(self, tuples: Tuple[Tuple[str, str]]) -> List[ImportPath]: 146 | import_paths = [] 147 | for importer, imported in tuples: 148 | import_paths.append( 149 | ImportPath(importer=Module(importer), imported=Module(imported)) 150 | ) 151 | return import_paths 152 | -------------------------------------------------------------------------------- /tests/functional/dependencies/test_graph.py: -------------------------------------------------------------------------------- 1 | from layer_linter.dependencies import DependencyGraph 2 | from layer_linter.module import Module, SafeFilenameModule 3 | import os 4 | import sys 5 | 6 | 7 | def test_dependency_graph(): 8 | dirname = os.path.dirname(__file__) 9 | path = os.path.abspath(os.path.join(dirname, '..', '..', 'assets')) 10 | 11 | sys.path.append(path) 12 | 13 | ROOT_PACKAGE = 'dependenciespackage' 14 | MODULE_ONE = Module("{}.one".format(ROOT_PACKAGE)) 15 | MODULE_TWO = Module("{}.two".format(ROOT_PACKAGE)) 16 | MODULE_THREE = Module("{}.three".format(ROOT_PACKAGE)) 17 | MODULE_FOUR = Module("{}.four".format(ROOT_PACKAGE)) 18 | 19 | SUBPACKAGE = 'subpackage' 20 | SUBMODULE_ONE = Module("{}.{}.one".format(ROOT_PACKAGE, SUBPACKAGE)) 21 | SUBMODULE_TWO = Module("{}.{}.two".format(ROOT_PACKAGE, SUBPACKAGE)) 22 | SUBMODULE_THREE = Module("{}.{}.three".format(ROOT_PACKAGE, SUBPACKAGE)) 23 | 24 | SUBSUBPACKAGE = 'subsubpackage' 25 | SUBSUBMODULE_ONE = Module("{}.{}.{}.one".format(ROOT_PACKAGE, SUBPACKAGE, SUBSUBPACKAGE)) 26 | SUBSUBMODULE_TWO = Module("{}.{}.{}.two".format(ROOT_PACKAGE, SUBPACKAGE, SUBSUBPACKAGE)) 27 | SUBSUBMODULE_THREE = Module("{}.{}.{}.three".format(ROOT_PACKAGE, SUBPACKAGE, SUBSUBPACKAGE)) 28 | 29 | root_package = __import__(ROOT_PACKAGE) 30 | graph = DependencyGraph( 31 | SafeFilenameModule(name=ROOT_PACKAGE, filename=root_package.__file__) 32 | ) 33 | 34 | assert graph.find_path( 35 | upstream=MODULE_ONE, 36 | downstream=MODULE_TWO) == (MODULE_TWO, MODULE_ONE) 37 | 38 | assert graph.find_path( 39 | upstream=MODULE_TWO, 40 | downstream=MODULE_ONE) is None 41 | 42 | assert graph.find_path( 43 | upstream=MODULE_ONE, 44 | downstream=MODULE_FOUR) == (MODULE_FOUR, MODULE_THREE, 45 | MODULE_TWO, MODULE_ONE) 46 | 47 | assert graph.find_path( 48 | upstream=SUBMODULE_ONE, 49 | downstream=SUBMODULE_THREE) == (SUBMODULE_THREE, SUBMODULE_TWO, SUBMODULE_ONE) 50 | 51 | assert graph.find_path( 52 | upstream=SUBSUBMODULE_ONE, 53 | downstream=SUBSUBMODULE_THREE) == (SUBSUBMODULE_THREE, SUBSUBMODULE_TWO, SUBSUBMODULE_ONE) 54 | 55 | # Module count should be 13 (running total in square brackets): 56 | # - dependenciespackage [1] 57 | # - one [2] 58 | # - two [3] 59 | # - three [4] 60 | # - four [5] 61 | # - .hidden [X] 62 | # - migrations [X] 63 | # - subpackage [6] 64 | # - one [7] 65 | # - two [8] 66 | # - three [9] 67 | # - is [10] (treat reserved keywords as normal modules) 68 | # - subsubpackage [11] 69 | # - one [12] 70 | # - two [13] 71 | # - three [14] 72 | assert graph.module_count == 14 73 | # Dependency count should be 7: 74 | # dependenciespackage.two <- dependenciespackage.one 75 | # dependenciespackage.three <- dependenciespackage.two 76 | # dependenciespackage.four <- dependenciespackage.three 77 | # dependenciespackage.subpackage.two <- dependenciespackage.subpackage.one 78 | # dependenciespackage.subpackage.three <- dependenciespackage.subpackage.two 79 | # dependenciespackage.subpackage.is <- dependenciespackage.subpackage.three 80 | # dependenciespackage.subpackage.subsubpackage.two 81 | # <- dependenciespackage.subpackage.subsubpackage.one 82 | # dependenciespackage.subpackage.subsubpackage.three 83 | # <- dependenciespackage.subpackage.subsubpackage.two 84 | assert graph.dependency_count == 8 85 | -------------------------------------------------------------------------------- /tests/functional/dependencies/test_scanner.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Tuple, List 3 | 4 | from layer_linter.dependencies.scanner import PackageScanner 5 | from layer_linter.module import SafeFilenameModule 6 | 7 | 8 | class TestPackageScanner: 9 | def test_success(self): 10 | package = SafeFilenameModule( 11 | name='scannersuccess', 12 | filename=os.path.join(self._get_package_directory('scannersuccess'), '__init__.py'), 13 | ) 14 | 15 | scanner = PackageScanner(package) 16 | modules = scanner.scan_for_modules() 17 | 18 | expected_modules = self._build_modules( 19 | package_name='scannersuccess', 20 | tuples=( 21 | ('scannersuccess', '__init__.py'), 22 | ('scannersuccess.one', os.path.join('one', '__init__.py')), 23 | ('scannersuccess.one.alpha', os.path.join('one', 'alpha.py')), 24 | ('scannersuccess.one.beta', os.path.join('one', 'beta.py')), 25 | ('scannersuccess.one.gamma', os.path.join('one', 'gamma.py')), 26 | ('scannersuccess.one.delta', os.path.join('one', 'delta', '__init__.py')), 27 | ('scannersuccess.one.delta.green', os.path.join('one', 'delta', 'green.py')), 28 | ('scannersuccess.one.delta.red_blue', os.path.join('one', 'delta', 'red_blue.py')), 29 | ('scannersuccess.two', os.path.join('two', '__init__.py')), 30 | ('scannersuccess.two.alpha', os.path.join('two', 'alpha.py')), 31 | ('scannersuccess.two.beta', os.path.join('two', 'beta.py')), 32 | ('scannersuccess.two.gamma', os.path.join('two', 'gamma.py')), 33 | ('scannersuccess.four', 'four.py'), 34 | # Also include invalid modules. To keep things simple, Layer Linter won't decide 35 | # what is and isn't allowed - but we don't want them to break things. 36 | ('scannersuccess.in', os.path.join('in', '__init__.py')), 37 | ('scannersuccess.in.class', os.path.join('in', 'class.py')), 38 | ('scannersuccess.in.hyphenated-name', os.path.join('in', 'hyphenated-name.py')), 39 | 40 | ), 41 | ) 42 | assert set(modules) == set(expected_modules) 43 | # Also check that the filenames are the same. 44 | sorted_modules = sorted(modules, key=lambda m: m.name) 45 | sorted_expected_modules = sorted(expected_modules, key=lambda m: m.name) 46 | for index, module in enumerate(sorted_modules): 47 | assert module.filename == sorted_expected_modules[index].filename 48 | 49 | def _get_package_directory(self, package_name: str) -> str: 50 | """ 51 | Return absolute path to the package directory. 52 | """ 53 | return os.path.abspath( 54 | os.path.join( 55 | os.path.dirname(__file__), 56 | '..', '..', 'assets', package_name, package_name, 57 | ) 58 | ) 59 | 60 | def _build_modules(self, 61 | package_name: str, 62 | tuples: Tuple[Tuple[str, str]]) -> List[SafeFilenameModule]: 63 | package_directory = self._get_package_directory(package_name) 64 | modules = [] 65 | for module_name, module_filename in tuples: 66 | modules.append( 67 | SafeFilenameModule( 68 | name=module_name, 69 | filename=os.path.join(package_directory, module_filename), 70 | ) 71 | ) 72 | return modules 73 | -------------------------------------------------------------------------------- /tests/functional/test_get_contracts.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | import pytest 5 | 6 | from layer_linter.contract import get_contracts, Layer 7 | from layer_linter.dependencies import ImportPath 8 | from layer_linter.module import Module 9 | 10 | 11 | class TestGetContracts: 12 | def test_happy_path(self): 13 | self._initialize_test() 14 | 15 | contracts = get_contracts(self.filename_and_path, package_name='singlecontractfile') 16 | 17 | assert len(contracts) == 2 18 | expected_contracts = [ 19 | { 20 | 'name': 'Contract A', 21 | 'packages': ['singlecontractfile.foo', 'singlecontractfile.bar'], 22 | 'layers': ['one', 'two'], 23 | }, 24 | { 25 | 'name': 'Contract B', 26 | 'packages': ['singlecontractfile'], 27 | 'layers': ['one', 'two', 'three'], 28 | 'whitelisted_paths': [ 29 | ('baz.utils', 'baz.three.green'), 30 | ('baz.three.blue', 'baz.two'), 31 | ], 32 | }, 33 | ] 34 | sorted_contracts = sorted(contracts, key=lambda i: i.name) 35 | for contract_index, contract in enumerate(sorted_contracts): 36 | expected_data = expected_contracts[contract_index] 37 | assert contract.name == expected_data['name'] 38 | 39 | for package_index, package in enumerate(contract.containers): 40 | expected_package_name = expected_data['packages'][package_index] 41 | assert package == Module(expected_package_name) 42 | 43 | for layer_index, layer in enumerate(contract.layers): 44 | expected_layer_data = expected_data['layers'][layer_index] 45 | assert isinstance(layer, Layer) 46 | assert layer.name == expected_layer_data 47 | 48 | for whitelisted_index, whitelisted_path in enumerate(contract.whitelisted_paths): 49 | expected_importer, expected_imported = expected_data['whitelisted_paths'][ 50 | whitelisted_index] 51 | assert isinstance(whitelisted_path, ImportPath) 52 | assert whitelisted_path.importer == Module(expected_importer) 53 | assert whitelisted_path.imported == Module(expected_imported) 54 | 55 | def test_container_does_not_exist(self): 56 | self._initialize_test('layers_with_missing_container.yml') 57 | 58 | with pytest.raises(ValueError) as e: 59 | get_contracts(self.filename_and_path, package_name='singlecontractfile') 60 | 61 | assert str(e.value) == "Invalid container 'singlecontractfile.missing': no such package." 62 | 63 | def _initialize_test(self, config_filename='layers.yml'): 64 | # Append the package directory to the path. 65 | dirname = os.path.dirname(__file__) 66 | package_dirname = os.path.join(dirname, '..', 'assets', 'singlecontractfile') 67 | sys.path.append(package_dirname) 68 | 69 | # Set the full config filename and path as an instance attribute. 70 | self.filename_and_path = os.path.join(package_dirname, config_filename) 71 | -------------------------------------------------------------------------------- /tests/functional/test_main.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | from layer_linter.cmdline import _main, EXIT_STATUS_SUCCESS, EXIT_STATUS_ERROR 5 | 6 | import pytest 7 | 8 | assets_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'assets')) 9 | 10 | 11 | @pytest.mark.parametrize( 12 | 'verbosity_count, is_quiet, should_always_fail', 13 | ( 14 | (0, False, False), # no args 15 | (0, True, False), # --quiet 16 | (1, False, False), # -v 17 | (1, True, True), # -v --quiet: should always fail. 18 | ) 19 | ) 20 | class TestMain: 21 | def test_success(self, verbosity_count, is_quiet, should_always_fail): 22 | package_name = 'successpackage' 23 | self._chdir_and_add_to_system_path(package_name) 24 | 25 | result = _main(package_name, verbosity_count=verbosity_count, is_quiet=is_quiet) 26 | 27 | if should_always_fail: 28 | assert result == EXIT_STATUS_ERROR 29 | else: 30 | assert result == EXIT_STATUS_SUCCESS 31 | 32 | def test_failure(self, verbosity_count, is_quiet, should_always_fail): 33 | package_name = 'failurepackage' 34 | self._chdir_and_add_to_system_path(package_name) 35 | 36 | result = _main(package_name, verbosity_count=verbosity_count, is_quiet=is_quiet) 37 | 38 | assert result == EXIT_STATUS_ERROR 39 | 40 | def test_specify_config_file(self, verbosity_count, is_quiet, should_always_fail): 41 | package_name = 'dependenciespackage' 42 | self._chdir_and_add_to_system_path(package_name) 43 | 44 | result = _main( 45 | package_name, 46 | config_filename='../successpackage/layers_alternative.yml', 47 | verbosity_count=verbosity_count, 48 | is_quiet=is_quiet) 49 | 50 | if should_always_fail: 51 | assert result == EXIT_STATUS_ERROR 52 | else: 53 | assert result == EXIT_STATUS_SUCCESS 54 | 55 | def test_missing_container(self, verbosity_count, is_quiet, should_always_fail): 56 | package_name = 'successpackage' 57 | self._chdir_and_add_to_system_path(package_name) 58 | 59 | result = _main(package_name, 60 | config_filename='layers_with_missing_container.yml', 61 | verbosity_count=verbosity_count, is_quiet=is_quiet) 62 | 63 | assert result == EXIT_STATUS_ERROR 64 | 65 | def _chdir_and_add_to_system_path(self, package_name): 66 | path = os.path.join(assets_path, package_name) 67 | sys.path.append(path) 68 | os.chdir(path) 69 | -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seddonym/layer_linter/801c0a30d814968bc9041dd29fdeccdccfff8cb7/tests/unit/__init__.py -------------------------------------------------------------------------------- /tests/unit/dependencies/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seddonym/layer_linter/801c0a30d814968bc9041dd29fdeccdccfff8cb7/tests/unit/dependencies/__init__.py -------------------------------------------------------------------------------- /tests/unit/dependencies/test_graph.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch, Mock 2 | 3 | from layer_linter.dependencies import graph as graph_module 4 | from layer_linter.dependencies.path import ImportPath 5 | from layer_linter.module import Module 6 | 7 | 8 | class DependencyAnalyzerStub: 9 | def __init__(self, modules, package): 10 | self.import_paths = [ 11 | ImportPath(importer=Module('foo.two'), imported=Module('foo.one')), 12 | ImportPath(importer=Module('foo.three'), imported=Module('foo.two')), 13 | ImportPath(importer=Module('foo.four'), imported=Module('foo.three')), 14 | ] 15 | 16 | def determine_import_paths(self): 17 | return self.import_paths 18 | 19 | 20 | class PackageScannerStub: 21 | def __init__(self, package): 22 | self.modules = [ 23 | Module('foo'), 24 | Module('foo.one'), 25 | Module('foo.one.alpha'), 26 | Module('foo.one.beta'), 27 | Module('foo.one.beta.green'), 28 | Module('foo.two'), 29 | ] 30 | 31 | def scan_for_modules(self): 32 | return self.modules 33 | 34 | 35 | @patch.object(graph_module, 'DependencyAnalyzer', new=DependencyAnalyzerStub) 36 | @patch.object(graph_module, 'PackageScanner', new=PackageScannerStub) 37 | class TestDependencyGraph: 38 | PACKAGE = Mock(__name__='foo') 39 | 40 | def test_find_path_direct(self): 41 | graph = graph_module.DependencyGraph(self.PACKAGE) 42 | 43 | path = graph.find_path(upstream=Module('foo.one'), downstream=Module('foo.two')) 44 | 45 | assert path == (Module('foo.two'), Module('foo.one')) 46 | 47 | def test_find_path_indirect(self): 48 | graph = graph_module.DependencyGraph(self.PACKAGE) 49 | 50 | path = graph.find_path(upstream=Module('foo.one'), downstream=Module('foo.four')) 51 | 52 | assert path == (Module('foo.four'), Module('foo.three'), Module('foo.two'), 53 | Module('foo.one'),) 54 | 55 | def test_find_path_nonexistent(self): 56 | graph = graph_module.DependencyGraph(self.PACKAGE) 57 | 58 | path = graph.find_path(upstream=Module('foo.four'), downstream=Module('foo.one')) 59 | 60 | assert path is None 61 | 62 | def test_direct_ignore_path_is_ignored(self): 63 | ignore_paths = ( 64 | ImportPath( 65 | importer=Module('foo.two'), imported=Module('foo.one'), 66 | ), 67 | ) 68 | graph = graph_module.DependencyGraph(self.PACKAGE) 69 | 70 | path = graph.find_path( 71 | upstream=Module('foo.one'), downstream=Module('foo.two'), 72 | ignore_paths=ignore_paths) 73 | 74 | assert path is None 75 | 76 | def test_indirect_ignore_path_is_ignored(self): 77 | ignore_paths = ( 78 | ImportPath( 79 | importer=Module('foo.three'), imported=Module('foo.two'), 80 | ), 81 | ) 82 | graph = graph_module.DependencyGraph(self.PACKAGE) 83 | 84 | path = graph.find_path( 85 | upstream=Module('foo.one'), downstream=Module('foo.four'), 86 | ignore_paths=ignore_paths) 87 | 88 | assert path is None 89 | 90 | def test_get_descendants_nested(self): 91 | 92 | graph = graph_module.DependencyGraph(self.PACKAGE) 93 | 94 | assert set(graph.get_descendants(Module('foo.one'))) == { 95 | Module('foo.one.alpha'), 96 | Module('foo.one.beta'), 97 | Module('foo.one.beta.green'), 98 | } 99 | 100 | def test_get_descendants_none(self): 101 | graph = graph_module.DependencyGraph(self.PACKAGE) 102 | 103 | assert graph.get_descendants(Module('foo.two')) == [] 104 | 105 | def test_module_count(self): 106 | graph = graph_module.DependencyGraph(self.PACKAGE) 107 | 108 | # Assert the module count is the number of modules returned by 109 | # PackageScanner.scan_for_modules. 110 | assert graph.module_count == 6 111 | 112 | def test_dependency_count(self): 113 | graph = graph_module.DependencyGraph(self.PACKAGE) 114 | 115 | # Should be number of ImportPaths returned by DependencyAnalyzer.determine_import_paths. 116 | assert graph.dependency_count == 3 117 | 118 | def test_contains(self): 119 | graph = graph_module.DependencyGraph(self.PACKAGE) 120 | 121 | assert Module('foo.one.alpha') in graph 122 | assert Module('foo.one.omega') not in graph 123 | -------------------------------------------------------------------------------- /tests/unit/dependencies/test_path.py: -------------------------------------------------------------------------------- 1 | from layer_linter.dependencies.path import ImportPath 2 | from layer_linter.module import Module 3 | 4 | 5 | class TestImportPath: 6 | def test_repr(self): 7 | import_path = ImportPath( 8 | importer=Module('foo'), imported=Module('bar') 9 | ) 10 | assert repr(import_path) == '' 11 | 12 | def test_equals(self): 13 | a = ImportPath(importer=Module('foo'), imported=Module('bar')) 14 | b = ImportPath(importer=Module('foo'), imported=Module('bar')) 15 | c = ImportPath(importer=Module('foo'), imported=Module('foo.baz')) 16 | 17 | assert a == b 18 | assert a != c 19 | # Also non-ImportPath instances should not be treated as equal. 20 | assert a != 'foo' 21 | 22 | def test_hash(self): 23 | a = ImportPath(importer=Module('foo'), imported=Module('bar')) 24 | b = ImportPath(importer=Module('foo'), imported=Module('bar')) 25 | c = ImportPath(importer=Module('bar'), imported=Module('foo')) 26 | 27 | assert hash(a) == hash(b) 28 | assert hash(a) != hash(c) 29 | -------------------------------------------------------------------------------- /tests/unit/test_cmdline.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | import pytest 4 | 5 | from layer_linter import cmdline 6 | from layer_linter.cmdline import _normalise_verbosity, _main 7 | from layer_linter.report import VERBOSITY_HIGH, VERBOSITY_NORMAL, VERBOSITY_QUIET 8 | 9 | 10 | @pytest.mark.parametrize( 11 | 'verbosity,is_quiet,expected_result', ( 12 | (0, True, VERBOSITY_QUIET), 13 | (0, False, VERBOSITY_NORMAL), 14 | (1, False, VERBOSITY_HIGH), 15 | (1, True, "Invalid parameters: quiet and verbose called together. Choose one or the " 16 | "other."), 17 | (2, False, "That level of verbosity is not supported. Maximum verbosity is -v."), 18 | ) 19 | ) 20 | def test_normalise_verbosity(verbosity, is_quiet, expected_result): 21 | if expected_result in (VERBOSITY_QUIET, VERBOSITY_NORMAL, VERBOSITY_HIGH): 22 | result = _normalise_verbosity(verbosity, is_quiet) 23 | assert result == expected_result 24 | else: 25 | with pytest.raises(RuntimeError) as e: 26 | _normalise_verbosity(verbosity, is_quiet) 27 | assert str(e.value) == expected_result 28 | 29 | 30 | @pytest.mark.parametrize( 31 | 'is_debug', (True, False) 32 | ) 33 | @patch.object(cmdline, 'get_report_class') 34 | @patch.object(cmdline, 'DependencyGraph') 35 | @patch.object(cmdline, '_get_package') 36 | @patch.object(cmdline, '_get_contracts') 37 | @patch.object(cmdline, 'logging') 38 | def test_debug(mock_logging, mock_get_contracts, mock_get_package, mock_graph, 39 | mock_get_report_class, is_debug): 40 | 41 | _main('foo', is_debug=is_debug) 42 | 43 | if is_debug: 44 | mock_logging.basicConfig.assert_called_once_with(level=mock_logging.DEBUG) 45 | else: 46 | mock_logging.basicConfig.assert_not_called() 47 | -------------------------------------------------------------------------------- /tests/unit/test_contract.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | import importlib 3 | import pytest 4 | from layer_linter import contract 5 | from layer_linter.module import Module 6 | from layer_linter.contract import Contract, Layer 7 | import logging 8 | import sys 9 | 10 | logger = logging.getLogger('layer_linter') 11 | logging.basicConfig(stream=sys.stdout, level=logging.DEBUG) 12 | 13 | 14 | class StubDependencyGraph: 15 | """ 16 | Stub of the DependencyGraph for use in tests. 17 | 18 | Args: 19 | descendants: Dictionary keyed with Modules, whose values are the descendants of 20 | those modules. This should not include the module itself. 21 | paths: Nested dictionary, in the form: 22 | 23 | { 24 | upstream_module: { 25 | downstream_module: [downstream_module, ..., upstream_module], 26 | ... 27 | } 28 | ... 29 | } 30 | A call to .find_path(downstream=downstream_module, upstream=upstream_module) 31 | will return the supplied path, if it is present in the dictionary. Otherwise, 32 | the stub will return None. 33 | modules: List of all modules in the graph. 34 | Usage: 35 | 36 | graph = StubDependencyGraph( 37 | descendants={ 38 | Module('foo.one'): [ 39 | Module('foo.one.red'), Module('foo.one.green') 40 | ], 41 | }, 42 | paths = { 43 | Module('foo.one.green'): { 44 | Module('foo.three.blue.alpha'): [ 45 | Module('foo.one.green'), Module('baz'), Module('foo.three.blue.alpha') 46 | ], 47 | }, 48 | ... 49 | }, 50 | modules [Module('foo.one'), Module('foo.one.red'), Module('foo.one.green')], 51 | ) 52 | 53 | """ 54 | def __init__(self, descendants=None, paths=None, modules=None): 55 | self.descendants = descendants if descendants else {} 56 | self.paths = paths if paths else {} 57 | self.modules = modules if modules else [] 58 | 59 | def get_descendants(self, module): 60 | try: 61 | return self.descendants[module] 62 | except KeyError: 63 | return [] 64 | 65 | def find_path(self, downstream, upstream, ignore_paths=None): 66 | try: 67 | return self.paths[upstream][downstream] 68 | except KeyError: 69 | return None 70 | 71 | def __contains__(self, item): 72 | return item in self.modules 73 | 74 | 75 | class TestContractCheck: 76 | def test_kept_contract(self): 77 | contract = Contract( 78 | name='Foo contract', 79 | containers=( 80 | Module('foo.blue'), 81 | Module('foo.green'), 82 | ), 83 | layers=( 84 | Layer('three'), 85 | Layer('two'), 86 | Layer('one'), 87 | ), 88 | whitelisted_paths=mock.sentinel.whitelisted_paths, 89 | ) 90 | graph = StubDependencyGraph( 91 | descendants={ 92 | Module('foo.green.one'): [ 93 | Module('foo.green.one.alpha'), 94 | Module('foo.green.one.beta'), 95 | ], 96 | Module('foo.green.three'): [ 97 | Module('foo.green.three.alpha'), 98 | Module('foo.green.three.beta'), 99 | ], 100 | }, 101 | paths={ 102 | # Include some allowed paths. 103 | Module('foo.blue.two'): { 104 | # Layer directly importing a layer below it. 105 | Module('foo.blue.three'): [Module('foo.blue.three'), Module('foo.blue.two')], 106 | }, 107 | Module('foo.blue.one'): { 108 | # Layer directly importing two layers below. 109 | Module('foo.blue.three'): [Module('foo.blue.three'), Module('foo.blue.one')], 110 | }, 111 | Module('foo.green.three'): { 112 | # Layer importing higher up layer, but from another container. 113 | Module('foo.blue.one'): [Module('foo.blue.one'), Module('foo.green.three')], 114 | }, 115 | Module('foo.green.three.beta'): { 116 | # Module inside layer importing another module in same layer. 117 | Module('foo.green.three.alpha'): [Module('foo.green.three.alpha'), 118 | Module('foo.green.three.beta')], 119 | }, 120 | Module('foo.green.one.alpha'): { 121 | # Module inside layer importing a module inside a lower layer. 122 | Module('foo.green.three.alpha'): [Module('foo.green.three.alpha'), 123 | Module('foo.green.one.alpha')] 124 | }, 125 | }, 126 | modules=[ 127 | Module('foo.green'), 128 | Module('foo.green.one'), 129 | Module('foo.green.one.alpha'), 130 | Module('foo.green.one.beta'), 131 | Module('foo.green.two'), 132 | Module('foo.green.three'), 133 | Module('foo.blue'), 134 | Module('foo.blue.one'), 135 | Module('foo.blue.two'), 136 | Module('foo.blue.three'), 137 | ] 138 | ) 139 | 140 | contract.check_dependencies(graph) 141 | 142 | assert contract.is_kept is True 143 | 144 | def test_broken_contract(self): 145 | contract = Contract( 146 | name='Foo contract', 147 | containers=( 148 | Module('foo.blue'), 149 | Module('foo.green'), 150 | ), 151 | layers=( 152 | Layer('three'), 153 | Layer('two'), 154 | Layer('one'), 155 | ), 156 | ) 157 | graph = StubDependencyGraph( 158 | descendants={ 159 | Module('foo.green.one'): [ 160 | Module('foo.green.one.alpha'), 161 | Module('foo.green.one.beta'), 162 | ], 163 | Module('foo.green.three'): [ 164 | Module('foo.green.three.alpha'), 165 | Module('foo.green.three.beta'), 166 | ], 167 | }, 168 | paths={ 169 | Module('foo.blue.two'): { 170 | # An allowed path: layer directly importing a layer below it. 171 | Module('foo.blue.three'): [Module('foo.blue.three'), Module('foo.blue.two')], 172 | # Disallowed path: layer directly importing a layer above it. 173 | Module('foo.blue.one'): [Module('foo.blue.one'), Module('foo.blue.two')], 174 | }, 175 | Module('foo.green.three.alpha'): { 176 | # Module inside layer importing a module inside a higher layer. 177 | Module('foo.green.one.alpha'): [Module('foo.green.one.alpha'), 178 | Module('foo.green.three.alpha')], 179 | }, 180 | }, 181 | modules=[ 182 | Module('foo.green'), 183 | Module('foo.green.one'), 184 | Module('foo.green.one.alpha'), 185 | Module('foo.green.one.beta'), 186 | Module('foo.green.two'), 187 | Module('foo.green.three'), 188 | Module('foo.blue'), 189 | Module('foo.blue.one'), 190 | Module('foo.blue.two'), 191 | Module('foo.blue.three'), 192 | ] 193 | ) 194 | 195 | contract.check_dependencies(graph) 196 | 197 | assert contract.is_kept is False 198 | assert contract.illegal_dependencies == [ 199 | [Module('foo.blue.one'), Module('foo.blue.two')], 200 | [Module('foo.green.one.alpha'), Module('foo.green.three.alpha')] 201 | ] 202 | 203 | def test_unchecked_contract_raises_exception(self): 204 | contract = Contract( 205 | name='Foo contract', 206 | containers=( 207 | 'foo', 208 | ), 209 | layers=( 210 | Layer('three'), 211 | Layer('two'), 212 | Layer('one'), 213 | ), 214 | ) 215 | 216 | with pytest.raises(RuntimeError) as excinfo: 217 | contract.is_kept 218 | assert 'Cannot check whether contract is ' \ 219 | 'kept until check_dependencies is called.' in str(excinfo.value) 220 | 221 | def test_broken_contract_via_other_layer(self): 222 | # If an illegal import happens via another layer, we don't want to report it 223 | # (as it will already be reported). 224 | 225 | contract = Contract( 226 | name='Foo contract', 227 | containers=( 228 | 'foo', 229 | ), 230 | layers=( 231 | Layer('three'), 232 | Layer('two'), 233 | Layer('one'), 234 | ), 235 | ) 236 | graph = StubDependencyGraph( 237 | descendants={}, 238 | paths={ 239 | Module('foo.three'): { 240 | Module('foo.two'): [Module('foo.two'), Module('foo.three')], 241 | Module('foo.one'): [Module('foo.one'), Module('foo.two'), Module('foo.three')], 242 | }, 243 | Module('foo.two'): { 244 | Module('foo.one'): [Module('foo.one'), Module('foo.two')], 245 | }, 246 | }, 247 | modules=[ 248 | Module('foo.one'), 249 | Module('foo.two'), 250 | Module('foo.three'), 251 | ] 252 | ) 253 | 254 | contract.check_dependencies(graph) 255 | 256 | assert contract.illegal_dependencies == [ 257 | [Module('foo.one'), Module('foo.two')], 258 | [Module('foo.two'), Module('foo.three')], 259 | ] 260 | 261 | @pytest.mark.parametrize('longer_first', (True, False)) 262 | def test_only_shortest_violation_is_reported(self, longer_first): 263 | contract = Contract( 264 | name='Foo contract', 265 | containers=( 266 | 'foo', 267 | ), 268 | layers=( 269 | Layer('two'), 270 | Layer('one'), 271 | ), 272 | ) 273 | 274 | # These are both dependency violations, but it's more useful just to report 275 | # the more direct violation. 276 | if longer_first: 277 | paths = { 278 | Module('foo.two'): { 279 | Module('foo.one.alpha'): [ 280 | Module('foo.one.alpha'), Module('foo.one.alpha.green'), 281 | Module('foo.another'), Module('foo.two'), 282 | ], 283 | Module('foo.one.alpha.green'): [ 284 | Module('foo.one.alpha.green'), Module('foo.another'), Module('foo.two'), 285 | ], 286 | 287 | }, 288 | } 289 | else: 290 | paths = { 291 | Module('foo.two'): { 292 | Module('foo.one.alpha'): [ 293 | Module('foo.one.alpha'), Module('foo.another'), Module('foo.two'), 294 | ], 295 | Module('foo.one.alpha.green'): [ 296 | Module('foo.one.alpha.green'), Module('foo.one.alpha'), 297 | Module('foo.another'), Module('foo.two'), 298 | ], 299 | 300 | }, 301 | } 302 | graph = StubDependencyGraph( 303 | descendants={ 304 | Module('foo.one'): [Module('foo.one.alpha'), Module('foo.one.beta'), 305 | Module('foo.one.alpha.blue'), Module('foo.one.alpha.green')], 306 | }, 307 | paths=paths, 308 | modules=[Module('foo.one'), Module('foo.two')] 309 | ) 310 | 311 | contract.check_dependencies(graph) 312 | 313 | if longer_first: 314 | assert contract.illegal_dependencies == [ 315 | [Module('foo.one.alpha.green'), Module('foo.another'), Module('foo.two')], 316 | ] 317 | else: 318 | assert contract.illegal_dependencies == [ 319 | [Module('foo.one.alpha'), Module('foo.another'), Module('foo.two')], 320 | ] 321 | 322 | @pytest.mark.parametrize( 323 | 'is_optional', (True, False), 324 | ) 325 | def test_missing_contract(self, is_optional): 326 | contract = Contract( 327 | name='Foo contract', 328 | containers=( 329 | 'foo.one', 330 | 'foo.two', 331 | ), 332 | layers=( 333 | Layer('blue'), 334 | Layer('yellow', is_optional=is_optional), # Missing from foo.two. 335 | Layer('green'), 336 | ), 337 | ) 338 | graph = StubDependencyGraph( 339 | modules=[ 340 | Module('foo.one'), 341 | Module('foo.one.blue'), 342 | Module('foo.one.blue.alpha'), 343 | Module('foo.one.yellow'), 344 | Module('foo.one.green'), 345 | Module('foo.two'), 346 | Module('foo.two.blue'), 347 | Module('foo.two.blue.alpha'), 348 | Module('foo.two.green'), 349 | ] 350 | ) 351 | 352 | if is_optional: 353 | # Should pass. 354 | contract.check_dependencies(graph) 355 | else: 356 | with pytest.raises(ValueError) as e: 357 | contract.check_dependencies(graph) 358 | 359 | assert str(e.value) == ( 360 | "Missing layer in container 'foo.two': module foo.two.yellow does not exist." 361 | ) 362 | 363 | 364 | @mock.patch.object(importlib.util, 'find_spec') 365 | class TestContractFromYAML: 366 | 367 | def test_parentheses_indicate_optional_layer(self, mock_find_spec): 368 | data = { 369 | 'containers': ['mypackage.foo'], 370 | 'layers': ['one', '(two)'], 371 | } 372 | 373 | parsed_contract = contract.contract_from_yaml('Contract Foo', data, 'mypackage') 374 | 375 | assert not parsed_contract.layers[0].is_optional 376 | assert parsed_contract.layers[1].is_optional 377 | 378 | def test_incorrect_whitelisted_path_format(self, mock_find_spec): 379 | data = { 380 | 'containers': ['mypackage.foo', 'mypackage.bar'], 381 | 'layers': ['one', 'two'], 382 | 'whitelisted_paths': [ 383 | 'not the right format', 384 | ] 385 | } 386 | 387 | with pytest.raises(ValueError) as exception: 388 | contract.contract_from_yaml('Contract Foo', data, 'mypackage') 389 | assert str(exception.value) == ( 390 | 'Whitelisted paths must be in the format ' 391 | '"importer.module <- imported.module".' 392 | ) 393 | 394 | def test_container_not_in_package(self, mock_find_spec): 395 | data = { 396 | 'containers': ['mypackage.foo', 'anotherpackage.foo'], 397 | 'layers': ['one', 'two'], 398 | } 399 | 400 | with pytest.raises(ValueError) as exception: 401 | contract.contract_from_yaml('Contract Foo', data, 'mypackage') 402 | assert str(exception.value) == ( 403 | "Invalid container 'anotherpackage.foo': containers must be either a subpackage of " 404 | "'mypackage', or 'mypackage' itself." 405 | ) 406 | -------------------------------------------------------------------------------- /tests/unit/test_report.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock, patch, call, sentinel 2 | import pytest 3 | 4 | from layer_linter.module import Module 5 | from layer_linter.contract import Contract 6 | from layer_linter import report 7 | from layer_linter.report import QuietReport, NormalReport, VerboseReport, ConsolePrinter 8 | 9 | 10 | class StubDependencyGraph: 11 | def __init__(self): 12 | self.modules = [ 13 | Module('foo.one'), 14 | Module('foo.two'), 15 | Module('foo.three'), 16 | ] 17 | self.module_count = len(self.modules) 18 | self.dependency_count = 4 19 | 20 | def get_modules_directly_imported_by(self, importer): 21 | return { 22 | self.modules[0]: [ 23 | Module('foo.four.one'), 24 | Module('foo.four.two'), 25 | ], 26 | self.modules[1]: [], 27 | self.modules[2]: [ 28 | Module('foo.five'), 29 | ] 30 | }[importer] 31 | 32 | 33 | @pytest.fixture 34 | def make_kept_contract(): 35 | def _make_contract(): 36 | contract = Contract('Foo contract', [], []) 37 | contract.check_dependencies(None) 38 | return contract 39 | return _make_contract 40 | 41 | 42 | @pytest.fixture 43 | def make_broken_contract(): 44 | def _make_contract(name, illegal_dependencies=None): 45 | contract = Contract(name, [], []) 46 | if illegal_dependencies is None: 47 | illegal_dependencies = [ 48 | ('foo.upstream', 'foo.downstream'), 49 | ] 50 | contract.illegal_dependencies = illegal_dependencies 51 | return contract 52 | return _make_contract 53 | 54 | 55 | @pytest.mark.parametrize( 56 | 'report_class', ( 57 | QuietReport, NormalReport, VerboseReport, 58 | ) 59 | ) 60 | @patch.object(report, 'ConsolePrinter') 61 | class TestReport: 62 | def _report_contracts(self, report_class, contracts): 63 | """ 64 | Report on the supplied list of Contracts. 65 | """ 66 | self.report = report_class(graph=StubDependencyGraph()) 67 | 68 | for contract in contracts: 69 | self.report.add_contract(contract) 70 | self.report.output() 71 | 72 | def test_single_kept_contract(self, printer, report_class, make_kept_contract): 73 | kept_contract = make_kept_contract() 74 | 75 | self._report_contracts(report_class, [kept_contract]) 76 | 77 | self.assert_headings_printed(report_class, printer, is_successful=True) 78 | 79 | if report_class is VerboseReport: 80 | self.assert_dependencies_printed(printer) 81 | 82 | if report_class is not QuietReport: 83 | printer.print_contract_one_liner.assert_called_once_with(kept_contract) 84 | printer.print_success.assert_called_once_with('Contracts: 1 kept, 0 broken.') 85 | 86 | assert self.report.has_broken_contracts is False 87 | 88 | def test_single_broken_contract_direct(self, printer, report_class, make_broken_contract): 89 | broken_contract = make_broken_contract( 90 | name='Foo', 91 | illegal_dependencies=[ 92 | ('foo.two', 'foo.one'), 93 | ] 94 | ) 95 | 96 | self._report_contracts(report_class, [broken_contract]) 97 | 98 | self.assert_headings_printed(report_class, printer, is_successful=False) 99 | 100 | if report_class is VerboseReport: 101 | self.assert_dependencies_printed(printer) 102 | 103 | printer.print_error.assert_has_calls([call('Contracts: 0 kept, 1 broken.')]) 104 | printer.print_heading.assert_has_calls([ 105 | call('Broken contracts', printer.HEADING_LEVEL_TWO, 106 | style=printer.ERROR), 107 | call('Foo', printer.HEADING_LEVEL_THREE, 108 | style=printer.ERROR), 109 | ]) 110 | printer.print_error.assert_has_calls([ 111 | call('1. foo.two imports foo.one:'), 112 | call('foo.two <-', bold=False), 113 | call('foo.one', bold=False), 114 | ]) 115 | 116 | assert self.report.has_broken_contracts is True 117 | 118 | def test_multiple_kept_and_broken_contracts(self, printer, report_class, make_kept_contract, 119 | make_broken_contract): 120 | broken_contract_1 = make_broken_contract( 121 | name='Contract 1', 122 | illegal_dependencies=[ 123 | ('foo.two', 'foo.one'), 124 | ] 125 | ) 126 | broken_contract_2 = make_broken_contract( 127 | name='Contract 2', 128 | illegal_dependencies=[ 129 | ('bar.two', 'bar.one'), 130 | ] 131 | ) 132 | kept_contract_1 = make_kept_contract() 133 | kept_contract_2 = make_kept_contract() 134 | kept_contract_3 = make_kept_contract() 135 | self._report_contracts( 136 | report_class, 137 | [ 138 | broken_contract_1, 139 | kept_contract_1, 140 | broken_contract_2, 141 | kept_contract_2, 142 | kept_contract_3, 143 | ] 144 | ) 145 | 146 | self.assert_headings_printed(report_class, printer, is_successful=False) 147 | 148 | if report_class is VerboseReport: 149 | self.assert_dependencies_printed(printer) 150 | 151 | printer.print_contract_one_liner.assert_has_calls([ 152 | call(kept_contract_1), 153 | call(kept_contract_2), 154 | call(kept_contract_3), 155 | call(broken_contract_1), 156 | call(broken_contract_2), 157 | ]) 158 | printer.print_error.assert_has_calls([call('Contracts: 3 kept, 2 broken.')]) 159 | printer.print_heading.assert_has_calls([ 160 | call('Broken contracts', printer.HEADING_LEVEL_TWO, 161 | style=printer.ERROR), 162 | call('Contract 1', printer.HEADING_LEVEL_THREE, 163 | style=printer.ERROR), 164 | ]) 165 | printer.print_error.assert_has_calls([ 166 | call('1. foo.two imports foo.one:'), 167 | call('foo.two <-', bold=False), 168 | call('foo.one', bold=False), 169 | ]) 170 | printer.print_heading.assert_has_calls([ 171 | call('Contract 2', printer.HEADING_LEVEL_THREE, 172 | style=printer.ERROR), 173 | ]) 174 | printer.print_error.assert_has_calls([ 175 | call('1. bar.two imports bar.one:'), 176 | call('bar.two <-', bold=False), 177 | call('bar.one', bold=False), 178 | ]) 179 | assert self.report.has_broken_contracts is True 180 | 181 | def test_single_broken_contract_indirect(self, printer, report_class, make_broken_contract): 182 | contract = make_broken_contract( 183 | name='Foo', 184 | illegal_dependencies=[ 185 | ('foo.four', 'foo.three', 'foo.two', 'foo.one'), 186 | ] 187 | ) 188 | 189 | self._report_contracts(report_class, [contract]) 190 | 191 | self.assert_headings_printed(report_class, printer, is_successful=False) 192 | 193 | if report_class is VerboseReport: 194 | self.assert_dependencies_printed(printer) 195 | 196 | printer.print_contract_one_liner.assert_has_calls([ 197 | call(contract), 198 | ]) 199 | printer.print_error.assert_has_calls([call('Contracts: 0 kept, 1 broken.')]) 200 | printer.print_heading.assert_has_calls([ 201 | call('Broken contracts', printer.HEADING_LEVEL_TWO, 202 | style=printer.ERROR), 203 | call('Foo', printer.HEADING_LEVEL_THREE, 204 | style=printer.ERROR), 205 | ]) 206 | printer.print_error.assert_has_calls([ 207 | call('1. foo.four imports foo.one:'), 208 | call('foo.four <-', bold=False), 209 | call('foo.three <-', bold=False), 210 | call('foo.two <-', bold=False), 211 | call('foo.one', bold=False), 212 | ]) 213 | assert self.report.has_broken_contracts is True 214 | 215 | def test_single_broken_contract_multiple_illegal_dependencies(self, printer, report_class, 216 | make_broken_contract): 217 | contract = make_broken_contract( 218 | name='Foo', 219 | illegal_dependencies=[ 220 | ('foo.four', 'foo.three', 'foo.two', 'foo.one'), 221 | ('bar.two', 'bar.one'), 222 | ] 223 | ) 224 | 225 | self._report_contracts(report_class, [contract]) 226 | 227 | self.assert_headings_printed(report_class, printer, is_successful=False) 228 | 229 | if report_class is VerboseReport: 230 | self.assert_dependencies_printed(printer) 231 | 232 | printer.print_contract_one_liner.assert_has_calls([ 233 | call(contract), 234 | ]) 235 | printer.print_error.assert_has_calls([call('Contracts: 0 kept, 1 broken.')]) 236 | printer.print_heading.assert_has_calls([ 237 | call('Broken contracts', printer.HEADING_LEVEL_TWO, 238 | style=printer.ERROR), 239 | call('Foo', printer.HEADING_LEVEL_THREE, 240 | style=printer.ERROR), 241 | ]) 242 | printer.print_error.assert_has_calls([ 243 | call('1. foo.four imports foo.one:'), 244 | call('foo.four <-', bold=False), 245 | call('foo.three <-', bold=False), 246 | call('foo.two <-', bold=False), 247 | call('foo.one', bold=False), 248 | call('2. bar.two imports bar.one:'), 249 | call('bar.two <-', bold=False), 250 | call('bar.one', bold=False), 251 | ]) 252 | assert self.report.has_broken_contracts is True 253 | 254 | def assert_headings_printed(self, report_class, printer, is_successful): 255 | if (report_class is QuietReport) and is_successful: 256 | printer.print_heading.assert_not_called() 257 | printer.print_contract_one_liner.assert_not_called() 258 | printer.print_success.assert_not_called() 259 | elif report_class is VerboseReport: 260 | printer.print_heading.assert_has_calls([ 261 | call('Layer Linter', printer.HEADING_LEVEL_ONE), 262 | call('Dependencies', printer.HEADING_LEVEL_TWO), 263 | call('Contracts', printer.HEADING_LEVEL_TWO), 264 | ]) 265 | else: 266 | printer.print_heading.assert_has_calls([ 267 | call('Layer Linter', printer.HEADING_LEVEL_ONE), 268 | call('Contracts', printer.HEADING_LEVEL_TWO), 269 | ]) 270 | 271 | def assert_dependencies_printed(self, printer): 272 | printer.print.assert_has_calls([ 273 | call('foo.one imports:', bold=True), 274 | call('- foo.four.one'), 275 | call('- foo.four.two'), 276 | call('foo.two imports:', bold=True), 277 | call('- (nothing)'), 278 | call('foo.three imports:', bold=True), 279 | call('- foo.five'), 280 | ]) 281 | 282 | 283 | @patch.object(report, 'click') 284 | class TestConsolePrinter: 285 | @pytest.mark.parametrize('style,expected_color', [ 286 | (None, None), 287 | (ConsolePrinter.SUCCESS, 'green',), 288 | (ConsolePrinter.ERROR, 'red'), 289 | ]) 290 | def test_print_heading_1(self, click, style, expected_color): 291 | ConsolePrinter.print_heading( 292 | 'Lorem ipsum', ConsolePrinter.HEADING_LEVEL_ONE, style) 293 | 294 | assert click.secho.call_count == 3 295 | click.secho.assert_has_calls([ 296 | call('===========', bold=True, fg=expected_color), 297 | call('Lorem ipsum', bold=True, fg=expected_color), 298 | call('===========', bold=True, fg=expected_color), 299 | ]) 300 | click.echo.assert_called_once_with() 301 | 302 | def test_print_heading_2(self, click): 303 | ConsolePrinter.print_heading( 304 | 'Dolor sic', ConsolePrinter.HEADING_LEVEL_TWO) 305 | 306 | assert click.secho.call_count == 3 307 | click.secho.assert_has_calls([ 308 | call('---------', bold=True, fg=None), 309 | call('Dolor sic', bold=True, fg=None), 310 | call('---------', bold=True, fg=None), 311 | ]) 312 | click.echo.assert_called_once_with() 313 | 314 | def test_print_heading_3(self, click): 315 | ConsolePrinter.print_heading( 316 | 'Amet consectetur', ConsolePrinter.HEADING_LEVEL_THREE) 317 | 318 | assert click.secho.call_count == 2 319 | click.secho.assert_has_calls([ 320 | call('Amet consectetur', bold=True, fg=None), 321 | call('----------------', bold=True, fg=None), 322 | ]) 323 | click.echo.assert_called_once_with() 324 | 325 | @pytest.mark.parametrize('bold', (True, False)) 326 | def test_print(self, click, bold): 327 | ConsolePrinter.print(sentinel.text, bold) 328 | 329 | click.secho.assert_called_once_with( 330 | sentinel.text, bold=bold) 331 | 332 | @pytest.mark.parametrize('bold', (True, False)) 333 | def test_print_success(self, click, bold): 334 | ConsolePrinter.print_success(sentinel.text, bold) 335 | 336 | click.secho.assert_called_once_with( 337 | sentinel.text, fg='green', bold=bold) 338 | 339 | @pytest.mark.parametrize('bold', (True, False)) 340 | def test_print_error(self, click, bold): 341 | ConsolePrinter.print_error(sentinel.text, bold) 342 | 343 | click.secho.assert_called_once_with( 344 | sentinel.text, fg='red', bold=bold) 345 | 346 | def test_indent_cursor(self, click): 347 | ConsolePrinter.indent_cursor() 348 | 349 | click.echo.assert_called_once_with(' ', nl=False) 350 | 351 | def test_new_line(self, click): 352 | ConsolePrinter.new_line() 353 | 354 | click.echo.assert_called_once_with() 355 | 356 | @pytest.mark.parametrize( 357 | 'is_kept,whitelisted_path_length,expected_label,expected_color', [ 358 | (True, 4, 'KEPT', 'green'), 359 | (False, 0, 'BROKEN', 'red'), 360 | ]) 361 | def test_print_contract_one_liner(self, click, is_kept, whitelisted_path_length, 362 | expected_label, expected_color): 363 | contract = MagicMock( 364 | whitelisted_paths=[MagicMock()] * whitelisted_path_length, 365 | is_kept=is_kept, 366 | ) 367 | contract.__str__.return_value = 'Foo' 368 | 369 | ConsolePrinter.print_contract_one_liner(contract) 370 | 371 | if whitelisted_path_length: 372 | click.secho.assert_has_calls([ 373 | call('Foo ', nl=False), 374 | call('({} whitelisted paths) '.format(whitelisted_path_length), nl=False), 375 | call(expected_label, fg=expected_color, bold=True) 376 | ]) 377 | else: 378 | click.secho.assert_has_calls([ 379 | call('Foo ', nl=False), 380 | call(expected_label, fg=expected_color, bold=True) 381 | ]) 382 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py36, py37, flake8, layer_lint, rst 3 | 4 | [travis] 5 | python = 6 | 3.6.1: py36 7 | 8 | [flake8] 9 | max-line-length = 99 10 | 11 | [testenv:flake8] 12 | basepython = python 13 | deps = flake8 14 | commands = flake8 layer_linter tests/unit tests/functional 15 | 16 | [testenv:layer_lint] 17 | deps = layer-linter 18 | commands = layer-lint layer_linter 19 | 20 | [testenv:mypy] 21 | commands = mypy src/layer_linter 22 | 23 | [testenv:rst] 24 | deps = restructuredtext_lint 25 | commands = restructuredtext-lint README.rst 26 | 27 | [testenv] 28 | setenv = 29 | PYTHONPATH = {toxinidir} 30 | deps = 31 | -r{toxinidir}/requirements_dev.txt 32 | ; If you want to make tox run the tests with the same versions, create a 33 | ; requirements.txt with the pinned versions and uncomment the following line: 34 | ; -r{toxinidir}/requirements.txt 35 | commands = 36 | py.test --basetemp={envtmpdir} --cov=layer_linter --cov-report=term --no-cov-on-fail [] 37 | --------------------------------------------------------------------------------