Skip to content

Commit

Permalink
Add ability to have sibling layers
Browse files Browse the repository at this point in the history
  • Loading branch information
seddonym committed Aug 18, 2023
1 parent 0f2d925 commit 8f4cdd3
Show file tree
Hide file tree
Showing 4 changed files with 212 additions and 89 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Changelog

* Update to Grimp 3.0b3.
* Use Grimp's find_illegal_dependencies_for_layers method in independence contracts.
* Add ability to define independent siblings in layers contracts.

1.10.0 (2023-07-06)
-------------------
Expand Down
19 changes: 19 additions & 0 deletions docs/contract_types.rst
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,10 @@ way around.
They do this by checking, for an ordered list of modules, that none higher up the list imports anything from a module
lower down the list, even indirectly.

Additionally, multiple layers can listed on the same line, separated by pipe characters (``|``).These layers will be
treated as being at the same level in relation to the other layers, but independent with respect to each other. In other
words, layers on the same line are not allowed to import from each other, nor from any layers above.

Layers are required by default: if a layer is listed in the contract, the contract will be broken if the layer
doesn't exist. You can make a layer optional by wrapping it in parentheses.

Expand Down Expand Up @@ -180,6 +184,21 @@ as they are in different containers:
Notice that ``medium`` is an optional layer. This means that if it is missing from any of the containers, Import Linter
won't complain.

This is an example of a contract with sibling layers:

.. code-block:: ini
[importlinter:contract:my-layers-contract]
name = Contract with sibling layers
type = layers
layers=
high
medium_a | medium_b | medium_c
low
``medium_a``, ``medium_b`` and ``medium_c`` are three 'sibling' layers that sit immediately below ``high`` and ``low``.
These must be independent; neither is allow to import from the others.

This is an example of an 'exhaustive' contract.

.. code-block:: ini
Expand Down
74 changes: 49 additions & 25 deletions src/importlinter/contracts/layers.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

from typing import List, cast
from dataclasses import dataclass
from typing import List, Sequence, cast

import grimp
from typing_extensions import TypedDict
Expand All @@ -14,28 +15,34 @@
from ._common import DetailedChain, build_detailed_chain_from_route, render_chain_data


@dataclass(frozen=True)
class Layer:
def __init__(self, name: str, is_optional: bool = False) -> None:
self.name = name
self.is_optional = is_optional
name: str
is_optional: bool = False


class LayerField(fields.Field):
def parse(self, raw_data: str | list) -> Layer:
def parse(self, raw_data: str | list) -> Layer | set[Layer]:
layers = set()
raw_string = fields.StringField().parse(raw_data)
if raw_string.startswith("(") and raw_string.endswith(")"):
layer_name = raw_string[1:-1]
is_optional = True
else:
layer_name = raw_string
is_optional = False
return Layer(name=layer_name, is_optional=is_optional)
raw_items = [item.strip() for item in raw_string.split("|")]
for raw_item in raw_items:
if raw_item.startswith("(") and raw_item.endswith(")"):
layer_name = raw_item[1:-1]
is_optional = True
else:
layer_name = raw_item
is_optional = False
layers.add(Layer(name=layer_name, is_optional=is_optional))
if len(layers) == 1:
return layers.pop()
return layers


class _LayerChainData(TypedDict):
higher_layer: str
lower_layer: str
chains: list[DetailedChain]
importer: str
imported: str
routes: list[DetailedChain]


_UNKNOWN_LINE_NUMBER = -1
Expand All @@ -56,6 +63,7 @@ class LayersContract(Contract):
- layers: An ordered list of layers. Each layer is the name of a module relative
to its parent package. The order is from higher to lower level layers.
TODO docs.
- containers: A list of the parent Modules of the layers. (Optional.)
- 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
Expand Down Expand Up @@ -95,6 +103,7 @@ def check(self, graph: grimp.ImportGraph, verbose: bool) -> ContractCheck:
ignore_imports=self.ignore_imports, # type: ignore
unmatched_alerting=self.unmatched_ignore_imports_alerting, # type: ignore
)
self.flattened_layers = self._flatten_layers(self.layers) # type: ignore

if self.containers:
self._validate_containers(graph)
Expand All @@ -104,7 +113,7 @@ def check(self, graph: grimp.ImportGraph, verbose: bool) -> ContractCheck:
undeclared_modules = self._get_undeclared_modules(graph)

dependencies = graph.find_illegal_dependencies_for_layers(
layers=tuple(layer.name for layer in self.layers), # type: ignore
layers=self._stringify_layers(self.layers), # type: ignore
containers=self.containers, # type: ignore
)
invalid_chains = self._build_invalid_chains(dependencies, graph)
Expand All @@ -113,18 +122,33 @@ def check(self, graph: grimp.ImportGraph, verbose: bool) -> ContractCheck:
kept=not (dependencies or undeclared_modules),
warnings=warnings,
metadata={
"invalid_chains": invalid_chains,
"invalid_dependencies": invalid_chains,
"undeclared_modules": undeclared_modules,
},
)

def _flatten_layers(self, layers: Sequence[Layer | set[Layer]]) -> set[Layer]:
flattened = set()
for layer in layers:
if isinstance(layer, set):
flattened |= layer
else:
flattened.add(layer)
return flattened

def _stringify_layers(self, layers: Sequence[Layer | set[Layer]]) -> Sequence[str | set[str]]:
return tuple(
{sublayer.name for sublayer in layer} if isinstance(layer, set) else layer.name
for layer in layers
)

def render_broken_contract(self, check: ContractCheck) -> None:
for chains_data in cast(List[_LayerChainData], check.metadata["invalid_chains"]):
higher_layer, lower_layer = (chains_data["higher_layer"], chains_data["lower_layer"])
for chains_data in cast(List[_LayerChainData], check.metadata["invalid_dependencies"]):
higher_layer, lower_layer = (chains_data["imported"], chains_data["importer"])
output.print(f"{lower_layer} is not allowed to import {higher_layer}:")
output.new_line()

for chain_data in chains_data["chains"]:
for chain_data in chains_data["routes"]:
render_chain_data(chain_data)
output.new_line()

Expand Down Expand Up @@ -169,7 +193,7 @@ def _validate_containers(self, graph: grimp.ImportGraph) -> None:
def _check_all_layers_exist_for_container(
self, container: str, graph: grimp.ImportGraph
) -> None:
for layer in self.layers: # type: ignore
for layer in self.flattened_layers:
if layer.is_optional:
continue
layer_module_name = ".".join([container, layer.name])
Expand All @@ -186,7 +210,7 @@ def _get_undeclared_modules(self, graph: grimp.ImportGraph) -> set[str]:
undeclared_modules = set()

exhaustive_ignores: set[str] = self.exhaustive_ignores or set() # type: ignore
layers: set[str] = {layer.name for layer in self.layers} # type: ignore
layers: set[str] = {layer.name for layer in self.flattened_layers}
declared_modules = layers | exhaustive_ignores

for container in self.containers: # type: ignore[attr-defined]
Expand Down Expand Up @@ -218,9 +242,9 @@ def _build_invalid_chains(
) -> list[_LayerChainData]:
return [
{
"higher_layer": dependency.imported,
"lower_layer": dependency.importer,
"chains": [build_detailed_chain_from_route(c, graph) for c in dependency.routes],
"imported": dependency.imported,
"importer": dependency.importer,
"routes": [build_detailed_chain_from_route(c, graph) for c in dependency.routes],
}
for dependency in dependencies
]

0 comments on commit 8f4cdd3

Please sign in to comment.