Skip to content

Commit

Permalink
Add support for namespace packages
Browse files Browse the repository at this point in the history
  • Loading branch information
seddonym committed Aug 22, 2022
1 parent 4cfd579 commit c7d6819
Show file tree
Hide file tree
Showing 21 changed files with 266 additions and 9 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ latest
------

* Add Python API for reading configuration.
* Add support for namespace packages.

1.2.7 (2022-04-04)
------------------
Expand Down
15 changes: 11 additions & 4 deletions docs/usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,15 @@ Or, with multiple root packages:
**Options:**

- ``root_package``:
The name of the top-level Python package to validate. This package must be importable: usually this
means it is has been installed using pip, or it's in the current directory. (Either this or ``root_packages`` is required.)
The name of the Python package to validate. For regular packages, this must be the top level package (i.e. one with no
dots in its name). However, in the special case of `namespace packages`_, the name of the `portion`_ should be
supplied, for example ``'mynamespace.foo'``.
This package must be importable: usually this means it is has been installed using pip, or it's in the current
directory. (Either this or ``root_packages`` is required.)
- ``root_packages``:
The names of the top-level Python packages to validate. This should be used in place of ``root_package`` if you want
to analyse the imports of multiple packages. (Either this or ``root_package`` is required.)
The names of the Python packages to validate. This should be used in place of ``root_package`` if you want
to analyse the imports of multiple packages, and is subject to the same requirements. (Either this or
``root_package`` is required.)
- ``include_external_packages``:
Whether to include external packages when building the import graph. Unlike root packages, external packages are
*not* statically analyzed, so no imports from external packages will be checked. However, imports *of* external
Expand Down Expand Up @@ -112,3 +116,6 @@ Running this will check that your project adheres to the contracts you've define
.. code-block:: text
lint-imports --show-timings
.. _namespace packages: https://docs.python.org/3/glossary.html#term-namespace-package
.. _portion: https://docs.python.org/3/glossary.html#term-portion
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,7 @@ force_grid_wrap = 0
use_parentheses = "True"
line_length = 99

[tool.mypy]
exclude = [
'^tests/assets/',
]
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ def read(*names, **kwargs):
"Topic :: Utilities",
],
python_requires=">=3.7",
install_requires=["click>=6,<9", "grimp>=1.2.3,<2", "typing-extensions>=3.10.0.0"],
install_requires=["click>=6,<9", "grimp>=1.3,<2", "typing-extensions>=3.10.0.0"],
extras_require={"toml": ["toml"]},
entry_points={
"console_scripts": ["lint-imports = importlinter.cli:lint_imports_command"]
Expand Down
6 changes: 4 additions & 2 deletions src/importlinter/contracts/forbidden.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from importlinter.application.contract_utils import AlertLevel
from importlinter.domain import fields
from importlinter.domain.contract import Contract, ContractCheck
from importlinter.domain.imports import Module
from importlinter.domain.ports.graph import ImportGraph


Expand Down Expand Up @@ -137,9 +138,10 @@ def _check_external_forbidden_modules(self, graph: ImportGraph) -> None:
)

def _contains_external_forbidden_modules(self, graph: ImportGraph) -> bool:
root_packages = self.session_options["root_packages"]
root_packages = [Module(name) for name in self.session_options["root_packages"]]
return not all(
m.root_package_name in root_packages for m in self.forbidden_modules # type: ignore
any(forbidden_module.is_in_package(root_package) for root_package in root_packages)
for forbidden_module in self.forbidden_modules # type: ignore
)

def _graph_was_built_with_externals(self) -> bool:
Expand Down
2 changes: 1 addition & 1 deletion src/importlinter/contracts/independence.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ class IndependenceContract(Contract):
Configuration options:
- modules: A list of Modules that should be independent from each other.
- modules: A list of Modules that should be independent of each other.
- ignore_imports: A set of ImportExpressions. These imports will be ignored: if the import
would cause a contract to be broken, adding it to the set will cause
the contract be kept instead. (Optional.)
Expand Down
6 changes: 5 additions & 1 deletion src/importlinter/contracts/layers.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,8 +154,12 @@ def _render_direct_import(

def _validate_containers(self, graph: ImportGraph) -> None:
root_package_names = self.session_options["root_packages"]
root_packages = tuple(Module(name) for name in root_package_names)

for container in self.containers: # type: ignore
if Module(container).root_package_name not in root_package_names:
if not any(
Module(container).is_in_package(root_package) for root_package in root_packages
):
if len(root_package_names) == 1:
root_package_name = root_package_names[0]
error_message = (
Expand Down
20 changes: 20 additions & 0 deletions tests/assets/namespacepackages/brokencontract.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
; For ease of reference, these are the imports of all the files:
;
; nestednamespace.foo.alpha.blue.one:
; pytest, itertools, urllib.request, nestednamespace.foo.alpha.blue.two
; nestednamespace.foo.alpha.green.one:
; nestednamespace.foo.alpha.blue.one, nestednamespace.bar.beta.orange

[importlinter]
root_packages =
nestednamespace.foo.alpha.blue
nestednamespace.foo.alpha.green
nestednamespace.bar.beta



[importlinter:contract:one]
name=Namespaces broken contract
type=forbidden
source_modules=nestednamespace.foo.alpha.green.one
forbidden_modules=nestednamespace.bar.beta.orange
20 changes: 20 additions & 0 deletions tests/assets/namespacepackages/keptcontract.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
; For ease of reference, these are the imports of all the files:
;
; nestednamespace.foo.alpha.blue.one:
; pytest, itertools, urllib.request, nestednamespace.foo.alpha.blue.two
; nestednamespace.foo.alpha.green.one:
; nestednamespace.foo.alpha.blue.one, nestednamespace.bar.beta.orange

[importlinter]
root_packages =
nestednamespace.foo.alpha.blue
nestednamespace.foo.alpha.green
nestednamespace.bar.beta



[importlinter:contract:one]
name=Namespaces kept contract
type=forbidden
source_modules=nestednamespace.foo.alpha.blue.one
forbidden_modules=nestednamespace.bar.beta
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import itertools
import urllib.request

import pytest

from . import two
Empty file.
Empty file.
Empty file.
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from nestednamespace.foo.alpha.blue import one

from ....bar.beta import orange
Empty file.
13 changes: 13 additions & 0 deletions tests/functional/test_lint_imports.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import os
import sys
from pathlib import Path

import pytest
Expand All @@ -11,6 +12,12 @@
testpackage_directory = assets_directory / "testpackage"
multipleroots_directory = assets_directory / "multipleroots"
unmatched_ignore_imports_directory = testpackage_directory / "unmatched_ignore_imports_alerting"
namespace_packages_directory = assets_directory / "namespacepackages"

# Add namespace packages to Python path
sys.path.extend(
[str(namespace_packages_directory / location) for location in ("locationone", "locationtwo")],
)


@pytest.mark.parametrize(
Expand Down Expand Up @@ -89,6 +96,9 @@
str(unmatched_ignore_imports_directory / "none.ini"),
cli.EXIT_STATUS_SUCCESS,
),
# Namespace packages
(namespace_packages_directory, "keptcontract.ini", cli.EXIT_STATUS_SUCCESS),
(namespace_packages_directory, "brokencontract.ini", cli.EXIT_STATUS_ERROR),
),
)
def test_lint_imports(working_directory, config_filename, expected_result):
Expand All @@ -104,6 +114,8 @@ def test_lint_imports(working_directory, config_filename, expected_result):

@pytest.mark.parametrize("is_debug_mode", (True, False))
def test_lint_imports_debug_mode(is_debug_mode):
os.chdir(testpackage_directory)

kwargs = dict(config_filename=".nonexistentcontract.ini", is_debug_mode=is_debug_mode)
if is_debug_mode:
with pytest.raises(FileNotFoundError):
Expand All @@ -113,4 +125,5 @@ def test_lint_imports_debug_mode(is_debug_mode):


def test_show_timings_smoke_test():
os.chdir(testpackage_directory)
assert cli.EXIT_STATUS_SUCCESS == cli.lint_imports(show_timings=True)
58 changes: 58 additions & 0 deletions tests/unit/contracts/test_forbidden.py
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,64 @@ def _build_contract(
)


class TestForbiddenContractForNamespacePackages:
@pytest.mark.parametrize(
"forbidden, is_kept",
[
("namespace.subnamespace.portiontwo.green", False),
("namespace.subnamespace.portiontwo.blue", True),
],
)
def test_allows_forbidding_of_inter_portion_imports(self, forbidden, is_kept):
graph = ImportGraph()
for module in (
"portionone",
"portionone.blue",
"subnamespace.portiontwo",
"subnamespace.portiontwo.green",
"subnamespace.portiontwo.blue",
):
graph.add_module(f"namespace.{module}")
for external_module in ("sqlalchemy", "requests"):
graph.add_module(external_module, is_squashed=True)
# Add import from one portion to another.
graph.add_import(
importer="namespace.portionone.blue",
imported="namespace.subnamespace.portiontwo.green",
line_number=3,
line_contents="-",
)
contract = self._build_contract(
root_packages=["namespace.portionone", "namespace.subnamespace.portiontwo"],
source_modules=("namespace.portionone.blue",),
forbidden_modules=(forbidden,),
)

contract_check = contract.check(graph=graph)

assert contract_check.kept == is_kept

def _build_contract(
self,
root_packages,
source_modules,
forbidden_modules,
include_external_packages=False,
):
session_options = {"root_packages": root_packages}
if include_external_packages:
session_options["include_external_packages"] = "True"

return ForbiddenContract(
name="Forbid contract",
session_options=session_options,
contract_options={
"source_modules": source_modules,
"forbidden_modules": forbidden_modules,
},
)


def test_render_broken_contract():
settings.configure(PRINTER=FakePrinter())
contract = ForbiddenContract(
Expand Down
50 changes: 50 additions & 0 deletions tests/unit/contracts/test_independence.py
Original file line number Diff line number Diff line change
Expand Up @@ -450,3 +450,53 @@ def test_ignore_imports_tolerates_duplicates():
contract_check = contract.check(graph=graph)

assert contract_check.kept


@pytest.mark.parametrize(
"independent_modules, is_kept",
(
(("namespace.portionone.blue", "namespace.subnamespace.portiontwo.blue"), True),
(("namespace.portionone.blue", "namespace.subnamespace.portiontwo.green"), False),
(
("namespace.subnamespace.portiontwo.blue", "namespace.subnamespace.portiontwo.green"),
False,
),
),
)
def test_namespace_packages(independent_modules, is_kept):
graph = ImportGraph()
for module in (
"portionone",
"portionone.blue",
"subnamespace.portiontwo",
"subnamespace.portiontwo.green",
"subnamespace.portiontwo.blue",
):
graph.add_module(f"namespace.{module}")
# Add imports between portions to another.
graph.add_import(
importer="namespace.portionone.blue",
imported="namespace.subnamespace.portiontwo.green",
line_number=3,
line_contents="-",
)
graph.add_import(
importer="namespace.subnamespace.portiontwo.blue",
imported="namespace.subnamespace.portiontwo.green",
line_number=3,
line_contents="-",
)

contract = IndependenceContract(
name="Independence contract",
session_options={
"root_packages": ["namespace.portionone", "namespace.subnamespace.portiontwo"]
},
contract_options={
"modules": independent_modules,
},
)

contract_check = contract.check(graph=graph)

assert contract_check.kept == is_kept
69 changes: 69 additions & 0 deletions tests/unit/contracts/test_layers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1428,6 +1428,75 @@ def _make_detailed_chain_and_add_to_graph(self, graph, *items):
return detailed_chain


class TestLayersContractForNamespacePackages:
@pytest.mark.parametrize(
"containers, is_kept",
[
(("namespace.subnamespace.portiontwo.green", "namespace.portionone.blue"), True),
(
(
"namespace.subnamespace.portiontwo.green",
"namespace.subnamespace.portiontwo.blue",
),
False,
),
],
)
def test_allows_namespace_containers(self, containers, is_kept):
graph = ImportGraph()
for module in (
"portionone",
"portionone.blue",
"portionone.blue.high",
"portionone.blue.middle",
"portionone.blue.low",
"subnamespace.portiontwo",
"subnamespace.portiontwo.green",
"subnamespace.portiontwo.green.high",
"subnamespace.portiontwo.green.middle",
"subnamespace.portiontwo.green.low",
"subnamespace.portiontwo.blue",
"subnamespace.portiontwo.blue.high",
"subnamespace.portiontwo.blue.middle",
"subnamespace.portiontwo.blue.low",
):
graph.add_module(f"namespace.{module}")
# Add legal imports
for package in (
"portionone.blue",
"subnamespace.portiontwo.green",
"subnamespace.portiontwo.blue",
):
for importer_name, imported_name in (("high", "middle"), ("middle", "low")):
graph.add_import(
importer=f"namespace.{package}.{importer_name}",
imported=f"namespace.{package}.{imported_name}",
line_number=3,
line_contents="-",
)
# Add an illegal import
graph.add_import(
importer="namespace.subnamespace.portiontwo.blue.low",
imported="namespace.subnamespace.portiontwo.blue.middle",
line_number=3,
line_contents="-",
)
contract = LayersContract(
name="Layers contract",
session_options={
"root_packages": ["namespace.portionone", "namespace.subnamespace.portiontwo"]
},
contract_options={
"layers": ["high", "middle", "low"],
"containers": containers,
},
)

contract_check = contract.check(graph=graph)

assert contract_check.kept == is_kept


class TestPopDirectImports:
def test_direct_import_between_descendants(self):
graph = self._build_graph()
Expand Down

0 comments on commit c7d6819

Please sign in to comment.