Skip to content

Commit b06af8c

Browse files
Add a function to get layered architectures (#459)
* Add a function to get layered architectures * Add tests * Add tests
1 parent 8ca7cc4 commit b06af8c

6 files changed

Lines changed: 544 additions & 10 deletions

File tree

pyproject.toml

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ dynamic = [
3535
]
3636
dependencies = [
3737
"configupdater>=3.2",
38-
"mergedeep>=1.3.1",
38+
"grimp>=2.5.0",
39+
"mergedeep>=1.3.4",
3940
"packaging>=20.9",
4041
"pydantic>=2.5.0",
4142
"requests>=2.26.0",
@@ -157,18 +158,15 @@ root_packages = [ "usethis" ]
157158
name = "Modular Design"
158159
type = "layers"
159160
layers = [
160-
"_test",
161-
"__main__",
161+
"_test | __main__",
162162
"_app",
163163
"_interface",
164164
"_core",
165165
"_tool",
166166
"_config_file",
167167
"_integrations",
168-
"_console",
169-
"_config | _io",
170-
"errors | _subprocess",
171-
"_pipeweld",
168+
"_io | _pipeweld | _subprocess | _console",
169+
"_config | errors",
172170
]
173171
containers = [ "usethis" ]
174172
exhaustive = true
@@ -188,9 +186,8 @@ exhaustive = true
188186
name = "Core Modular Design"
189187
type = "layers"
190188
layers = [
191-
"list",
192189
# docstyle uses (Ruff) tool, badge uses readme
193-
"badge | docstyle",
190+
"badge | docstyle | list",
194191
"author | browse | ci | readme | show | tool",
195192
]
196193
containers = [ "usethis._core" ]
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from usethis.errors import UsethisError
2+
3+
4+
class ImportGraphBuildFailedError(UsethisError):
5+
"""Raised when the import graph cannot be built."""
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import grimp
2+
import grimp.application
3+
import grimp.application.config
4+
import grimp.exceptions
5+
from pydantic import BaseModel
6+
7+
from usethis._integrations.project.errors import ImportGraphBuildFailedError
8+
9+
10+
class LayeredArchitecture(BaseModel):
11+
layers: list[set[str]]
12+
excluded: set[str] = set()
13+
14+
15+
def get_layered_architectures(pkg_name: str) -> dict[str, LayeredArchitecture]:
16+
"""Get the suggested layers for a package.
17+
18+
This is intended to inform the basis of a layer contract.
19+
20+
Reference:
21+
https://import-linter.readthedocs.io/en/stable/contract_types.html#layers
22+
23+
Args:
24+
pkg_name: The name of the package.
25+
26+
Returns:
27+
A dictionary mapping module names to their layered architecture.
28+
"""
29+
graph = _get_graph(pkg_name)
30+
31+
arch_by_module = {}
32+
33+
for module in graph.modules:
34+
arch = _get_module_layered_architecture(module, graph=graph)
35+
if len(arch.layers) > 1:
36+
arch_by_module[module] = arch
37+
38+
return arch_by_module
39+
40+
41+
def _get_module_layered_architecture(
42+
module: str, *, graph: grimp.ImportGraph
43+
) -> LayeredArchitecture:
44+
deps_by_module = _get_child_dependencies(module, graph=graph)
45+
46+
layered = set()
47+
layers = []
48+
49+
for _ in range(len(deps_by_module)):
50+
# Form a layer: cycle through all siblings. For ones with no deps, add them
51+
# to the layer and ignore them in the next iteration.
52+
53+
layer = set()
54+
for m, deps in deps_by_module.items():
55+
if m in layered:
56+
continue
57+
58+
unlayered_deps = deps - layered - {m}
59+
if not unlayered_deps:
60+
layer.add(m)
61+
62+
if not layer:
63+
break
64+
65+
layers.append(layer)
66+
layered.update(layer)
67+
68+
excluded = set()
69+
for m in deps_by_module:
70+
if m not in layered:
71+
excluded.add(m)
72+
73+
return LayeredArchitecture(
74+
layers=list(reversed(layers)),
75+
excluded=excluded,
76+
)
77+
78+
79+
def _get_child_dependencies(
80+
module: str, *, graph: grimp.ImportGraph
81+
) -> dict[str, set[str]]:
82+
"""For each child submodule, give a set of the sibling submodules it depends on.
83+
84+
For example, let's say we have `c` which depends on `a`, and `b` depends on `a`.
85+
`a` does not depend on anything, and `c` depends on `a` through `b`. Then the
86+
function will return:
87+
88+
```
89+
{
90+
"a": set(),
91+
"b": {"a"},
92+
"c": {"a", "b"},
93+
}
94+
```
95+
"""
96+
children = sorted(graph.find_children(module))
97+
98+
deps_by_module = {}
99+
for child in children:
100+
downstreams = graph.find_upstream_modules(module=child, as_package=True)
101+
downstreams = _filter_to_submodule(downstreams, submodule=module)
102+
103+
deps_by_module[_narrow_to_submodule(child, submodule=module)] = downstreams
104+
105+
return deps_by_module
106+
107+
108+
def _filter_to_submodule(modules: set[str], *, submodule: str) -> set[str]:
109+
filtered = set()
110+
for module in modules:
111+
if module.startswith(submodule + "."):
112+
filtered.add(_narrow_to_submodule(module, submodule=submodule))
113+
114+
return filtered
115+
116+
117+
def _narrow_to_submodule(module: str, *, submodule: str) -> str:
118+
return module.removeprefix(submodule + ".").split(".")[0]
119+
120+
121+
def _get_graph(pkg_name: str) -> grimp.ImportGraph:
122+
try:
123+
graph = grimp.build_graph(pkg_name, cache_dir=None)
124+
except ValueError as err:
125+
raise ImportGraphBuildFailedError(err) from None
126+
except ModuleNotFoundError as err:
127+
raise ImportGraphBuildFailedError(err) from None
128+
except grimp.exceptions.NotATopLevelModule:
129+
msg = f"Module {pkg_name} is not a top-level module, cannot build graph."
130+
raise ImportGraphBuildFailedError(msg) from None
131+
132+
return graph

src/usethis/_test.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,12 @@
1212

1313
@contextmanager
1414
def change_cwd(new_dir: Path) -> Generator[None, None, None]:
15-
"""Change the working directory temporarily."""
15+
"""Change the working directory temporarily.
16+
17+
Arguments:
18+
new_dir: The new directory to change to.
19+
add_to_path: Whether to add the new directory to the PYTHONPATH.
20+
"""
1621
old_dir = Path.cwd()
1722
os.chdir(new_dir)
1823
try:

0 commit comments

Comments
 (0)