Skip to content

Commit e83b8d7

Browse files
authored
Merge pull request #97 from zyskarch/fix_module_resolution
Bugfix: Internal modules now recognised as such when root and module …
2 parents 383d1a4 + 86d7011 commit e83b8d7

15 files changed

Lines changed: 165 additions & 17 deletions

File tree

docs/changelog.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
This project uses semantic versioning and follows [keep a changelog](https://keepachangelog.com).
44

5+
## [2.0.2] -- 2024-03-16
6+
### Fixed
7+
- Module resolution for absolute imports when root and module path differ.
8+
59
## [2.0.1] -- 2024-03-14
610
### Fixed
711
- Readme description on module name references.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "PyTestArch"
3-
version = "2.0.1"
3+
version = "2.0.2"
44
description = "Test framework for software architecture based on imports between modules"
55
authors = ["zyskarch <zyskarch@gmail.com>"]
66
maintainers = ["zyskarch <zyskarch@gmail.com>"]

src/pytestarch/eval_structure_generation/file_import/converter.py

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from __future__ import annotations
22

33
import ast
4-
from typing import List, Optional, Sequence
4+
from typing import List, Optional, Sequence, Set
55

66
from pytestarch.eval_structure.types import Import
77
from pytestarch.eval_structure_generation.file_import.import_types import (
@@ -14,7 +14,13 @@
1414
class ImportConverter:
1515
"""Converts all ast imports to custom import types."""
1616

17-
def convert(self, asts: List[NamedModule]) -> Sequence[Import]:
17+
def convert(
18+
self,
19+
asts: List[NamedModule],
20+
root_prefix_relevant: bool,
21+
root_prefix: str,
22+
all_modules: Set[str],
23+
) -> Sequence[Import]:
1824
"""Converts ast modules to custom import modules. Filters out all modules
1925
that are not imports.
2026
@@ -36,15 +42,26 @@ def convert(self, asts: List[NamedModule]) -> Sequence[Import]:
3642
[NamedModule(m, module_name) for m in ast_module.body] # type: ignore
3743
)
3844
else:
39-
new_imports = self._convert(module.module, module.name)
45+
new_imports = self._convert(
46+
module.module,
47+
module.name,
48+
root_prefix_relevant,
49+
root_prefix,
50+
all_modules,
51+
)
4052

4153
if new_imports:
4254
imports.extend(new_imports)
4355

4456
return imports
4557

4658
def _convert(
47-
self, module: ast.Module, module_name: str
59+
self,
60+
module: ast.Module,
61+
module_name: str,
62+
root_prefix_relevant: bool,
63+
root_prefix: str,
64+
all_internal_modules: Set[str],
4865
) -> Optional[Sequence[Import]]:
4966
"""Calculates all imports of the given ast module.
5067
@@ -60,10 +77,10 @@ def _convert(
6077
if isinstance(module, ast.Import):
6178
new_imports = []
6279
for alias in module.names:
63-
new_imports.append(AbsoluteImport(module_name, alias.name)) # type: ignore
80+
new_imports.append(AbsoluteImport(module_name, self._adjust_with_root_prefix(alias.name, root_prefix_relevant, root_prefix, all_internal_modules))) # type: ignore
6481
elif isinstance(module, ast.ImportFrom):
6582
if module.level == 0:
66-
new_imports = [AbsoluteImport(module_name, module.module)] # type: ignore
83+
new_imports = [AbsoluteImport(module_name, self._adjust_with_root_prefix(module.module, root_prefix_relevant, root_prefix, all_internal_modules))] # type: ignore
6784
else:
6885
new_imports = []
6986
for alias in module.names:
@@ -74,3 +91,28 @@ def _convert(
7491
)
7592

7693
return new_imports
94+
95+
@classmethod
96+
def _adjust_with_root_prefix(
97+
cls,
98+
module_name: str,
99+
root_prefix_relevant: bool,
100+
root_prefix: str,
101+
all_internal_modules: Set[str],
102+
) -> str:
103+
"""If the user specifies a module import start point that is not the root path, the ast module names will not contain
104+
the root path. Hence, they will later be flagged as external modules.
105+
To avoid this, we prepend the root path and check whether this gives a module that we have detected as an internal module earlier.
106+
If so, we optimistically assume that this prepended module name is the correct module name.
107+
108+
Only absolute imports are affected by this, as the relative imports are resolved internally anyway.
109+
"""
110+
if not root_prefix_relevant:
111+
return module_name
112+
113+
potential_full_name = f"{root_prefix}.{module_name}"
114+
115+
if potential_full_name in all_internal_modules:
116+
return potential_full_name
117+
118+
return module_name

src/pytestarch/eval_structure_generation/graph_generation/graph_generator.py

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from __future__ import annotations
22

33
from pathlib import Path
4-
from typing import List, Optional, Sequence, Tuple
4+
from typing import List, Optional, Sequence, Set, Tuple
55

66
from pytestarch.eval_structure.evaluable_graph import EvaluableArchitectureGraph
77
from pytestarch.eval_structure.networkxgraph import NetworkxGraph, Node
@@ -31,12 +31,18 @@ def generate_graph(
3131
)
3232

3333
all_modules, ast = _get_all_ast_modules(module_path, root_path, exclusions)
34-
imports = _get_imports_from_ast(ast)
3534

3635
internal_module_prefix = _get_internal_module_prefix(
3736
path_diff_between_root_and_module, root_path
3837
)
3938

39+
imports = _get_imports_from_ast(
40+
ast,
41+
_actual_difference_between_root_and_module(path_diff_between_root_and_module),
42+
root_path.name,
43+
_get_all_internal_modules(all_modules, internal_module_prefix),
44+
)
45+
4046
imports = _remove_excluded_imports(
4147
exclude_external_libraries, imports, internal_module_prefix
4248
)
@@ -78,16 +84,22 @@ def _get_internal_module_prefix(
7884
) -> str:
7985
"""In general, all internal modules should start with the root module name to be able to differentiate between
8086
internal and external modules. If the root and base module differ, the modules between them also need to be taken
81-
into account, as not root.a.b modules are external, but root.a.b.base are internal.
87+
into account, as not-root.a.b-modules are external, but root.a.b.base-modules are internal.
8288
"""
8389
internal_module_prefix = root_path.name + "."
8490

85-
if path_diff_between_root_and_module != ".":
91+
if _actual_difference_between_root_and_module(path_diff_between_root_and_module):
8692
internal_module_prefix += path_diff_between_root_and_module
8793

8894
return internal_module_prefix
8995

9096

97+
def _actual_difference_between_root_and_module(
98+
path_diff_between_root_and_module: str,
99+
) -> bool:
100+
return path_diff_between_root_and_module != "."
101+
102+
91103
def _add_extra_levels_to_limit_if_root_and_module_path_differ(
92104
level_limit: Optional[int], path_diff_between_root_and_module: str
93105
) -> Optional[int]:
@@ -97,16 +109,23 @@ def _add_extra_levels_to_limit_if_root_and_module_path_differ(
97109
if level_limit is None:
98110
return None
99111

100-
if path_diff_between_root_and_module != ".":
112+
if _actual_difference_between_root_and_module(path_diff_between_root_and_module):
101113
levels = len(path_diff_between_root_and_module.split("."))
102114
level_limit += levels
103115

104116
return level_limit
105117

106118

107-
def _get_imports_from_ast(ast: List[NamedModule]) -> Sequence[Import]:
119+
def _get_imports_from_ast(
120+
ast: List[NamedModule],
121+
root_prefix_relevant: bool,
122+
root_prefix: str,
123+
all_internal_modules: Set[str],
124+
) -> Sequence[Import]:
108125
converter = ImportConverter()
109-
return converter.convert(ast)
126+
return converter.convert(
127+
ast, root_prefix_relevant, root_prefix, all_internal_modules
128+
)
110129

111130

112131
def _get_all_ast_modules(
@@ -118,3 +137,9 @@ def _get_all_ast_modules(
118137
parser = Parser(file_filter, root_path)
119138
all_modules, ast = parser.parse(module_path)
120139
return all_modules, ast
140+
141+
142+
def _get_all_internal_modules(
143+
modules: List[str], internal_module_prefix: str
144+
) -> Set[str]:
145+
return {m for m in modules if m.startswith(internal_module_prefix)}

tests/eval_structure_generation/test_converter.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ def test_converter_retains_all_non_empty_modules() -> None:
1616
all_modules, asts = parser.parse(RESOURCES_DIR)
1717

1818
converter = ImportConverter()
19-
imports = converter.convert(asts)
19+
imports = converter.convert(asts, False, "", set())
2020

2121
expected_imports = {
2222
"pytestarch.pytestarch",

tests/eval_structure_generation/test_module_graph.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ def modules_and_ast(
4444

4545
@pytest.fixture(scope="module")
4646
def imports(modules_and_ast: Tuple[List[str], List[NamedModule]]) -> Sequence[Import]:
47-
return ImportConverter().convert(modules_and_ast[1])
47+
return ImportConverter().convert(modules_and_ast[1], False, "", set())
4848

4949

5050
@pytest.fixture(scope="module")

tests/integration/test_integration.py

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
from __future__ import annotations
22

3+
import os
4+
35
import pytest
46
from integration.interesting_rules_for_tests import (
57
partial_name_match_test_cases,
68
single_rule_subject_multiple_rule_objects_error_message_test_cases,
79
single_rule_subject_single_rule_object_error_message_test_cases,
810
)
11+
from resources import root_module_mismatch_project
12+
from resources.root_module_mismatch_project import app
913

10-
from pytestarch import EvaluableArchitecture, Rule
14+
from pytestarch import EvaluableArchitecture, Rule, get_evaluable_architecture
1115

1216

1317
@pytest.mark.parametrize(
@@ -48,3 +52,51 @@ def test_rule_violation_correctly_detected_for_partial_name_matches(
4852
else:
4953
rule.assert_applies(graph_based_on_string_module_names)
5054
# no exception
55+
56+
57+
def test_root_module_mismatch_handled_as_expected() -> None:
58+
evaluable = get_evaluable_architecture(
59+
os.path.dirname(root_module_mismatch_project.__file__),
60+
os.path.dirname(app.__file__),
61+
("*__pycache__", "*__init__.py", "*Test.py"),
62+
)
63+
64+
rule = (
65+
Rule()
66+
.modules_that()
67+
.are_sub_modules_of("root_module_mismatch_project.app.red")
68+
.should_not()
69+
.be_imported_by_modules_that()
70+
.are_sub_modules_of("root_module_mismatch_project.app.green")
71+
)
72+
73+
error_message = (
74+
'"root_module_mismatch_project.app.red.red" is imported by "root_module_mismatch_project.app.green.green".\n'
75+
'"root_module_mismatch_project.app.red.red2" is imported by "root_module_mismatch_project.app.green.green".\n'
76+
'"root_module_mismatch_project.app.red.red3" is imported by "root_module_mismatch_project.app.green.green".'
77+
)
78+
with pytest.raises(AssertionError, match=error_message):
79+
rule.assert_applies(evaluable)
80+
81+
82+
def test_root_module_match_handled_as_expected() -> None:
83+
evaluable = get_evaluable_architecture(
84+
os.path.dirname(app.__file__),
85+
os.path.dirname(app.__file__),
86+
("*__pycache__", "*__init__.py", "*Test.py"),
87+
)
88+
89+
rule = (
90+
Rule()
91+
.modules_that()
92+
.are_named("app.red")
93+
.should_not()
94+
.be_imported_by_modules_that()
95+
.are_sub_modules_of("app.green")
96+
)
97+
98+
with pytest.raises(
99+
AssertionError,
100+
match='"app.red.red" is imported by "app.green.green".\n"app.red.red2" is imported by "app.green.green".\n"app.red.red3" is imported by "app.green.green".',
101+
):
102+
rule.assert_applies(evaluable)

tests/resources/root_module_mismatch_project/__init__.py

Whitespace-only changes.

tests/resources/root_module_mismatch_project/app/__init__.py

Whitespace-only changes.

tests/resources/root_module_mismatch_project/app/green/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)