archunitpython 1.1.1__py3-none-any.whl → 1.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- archunitpython/__init__.py +9 -1
- archunitpython/common/extraction/extract_graph.py +117 -8
- archunitpython/graph/__init__.py +35 -0
- archunitpython/graph/graph_reporter.py +795 -0
- archunitpython/layers/__init__.py +8 -0
- archunitpython/layers/assertion/__init__.py +9 -0
- archunitpython/layers/assertion/layer_dependencies.py +83 -0
- archunitpython/layers/fluentapi/__init__.py +6 -0
- archunitpython/layers/fluentapi/layers.py +116 -0
- archunitpython/testing/common/violation_factory.py +10 -0
- {archunitpython-1.1.1.dist-info → archunitpython-1.2.0.dist-info}/METADATA +167 -4
- {archunitpython-1.1.1.dist-info → archunitpython-1.2.0.dist-info}/RECORD +14 -7
- {archunitpython-1.1.1.dist-info → archunitpython-1.2.0.dist-info}/WHEEL +1 -1
- {archunitpython-1.1.1.dist-info → archunitpython-1.2.0.dist-info}/licenses/LICENSE +0 -0
archunitpython/__init__.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""ArchUnitPython - Architecture testing library for Python projects."""
|
|
2
2
|
|
|
3
|
-
__version__ = "1.
|
|
3
|
+
__version__ = "1.2.0"
|
|
4
4
|
|
|
5
5
|
# Files API
|
|
6
6
|
# Common
|
|
@@ -13,6 +13,8 @@ from archunitpython.common import (
|
|
|
13
13
|
)
|
|
14
14
|
from archunitpython.common.extraction import clear_graph_cache, extract_graph
|
|
15
15
|
from archunitpython.files import files, project_files
|
|
16
|
+
from archunitpython.graph import dependency_graph, project_graph
|
|
17
|
+
from archunitpython.layers import layers, project_layers
|
|
16
18
|
|
|
17
19
|
# Metrics API
|
|
18
20
|
from archunitpython.metrics import metrics
|
|
@@ -27,6 +29,12 @@ __all__ = [
|
|
|
27
29
|
# Files
|
|
28
30
|
"project_files",
|
|
29
31
|
"files",
|
|
32
|
+
# Graph
|
|
33
|
+
"project_graph",
|
|
34
|
+
"dependency_graph",
|
|
35
|
+
# Layers
|
|
36
|
+
"project_layers",
|
|
37
|
+
"layers",
|
|
30
38
|
# Slices
|
|
31
39
|
"project_slices",
|
|
32
40
|
# Metrics
|
|
@@ -4,6 +4,8 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import ast
|
|
6
6
|
import os
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass
|
|
7
9
|
|
|
8
10
|
from archunitpython.common.extraction.graph import Edge, Graph, ImportKind
|
|
9
11
|
from archunitpython.common.fluentapi.checkable import CheckOptions
|
|
@@ -27,6 +29,34 @@ _DEFAULT_EXCLUDE = [
|
|
|
27
29
|
"*.egg-info",
|
|
28
30
|
]
|
|
29
31
|
|
|
32
|
+
_IGNORE_DIRECTIVE_REGEX = re.compile(
|
|
33
|
+
r"#\s*archunit(?::|-)\s*ignore"
|
|
34
|
+
r"(?:\([^)]*\))?"
|
|
35
|
+
r"(?P<modules>(?:\s+[\w.]+)*)\s*$"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class _LocatedImport:
|
|
41
|
+
module_name: str
|
|
42
|
+
import_kind: ImportKind
|
|
43
|
+
line_number: int
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class _IgnoreDirective:
|
|
48
|
+
line_number: int
|
|
49
|
+
modules: tuple[str, ...] = ()
|
|
50
|
+
|
|
51
|
+
def matches(self, import_: _LocatedImport) -> bool:
|
|
52
|
+
if not self.modules:
|
|
53
|
+
return True
|
|
54
|
+
return any(
|
|
55
|
+
import_.module_name == module
|
|
56
|
+
or import_.module_name.startswith(f"{module}.")
|
|
57
|
+
for module in self.modules
|
|
58
|
+
)
|
|
59
|
+
|
|
30
60
|
|
|
31
61
|
def clear_graph_cache(options: CheckOptions | None = None) -> None:
|
|
32
62
|
"""Clear the cached dependency graphs."""
|
|
@@ -58,7 +88,9 @@ def extract_graph(
|
|
|
58
88
|
project_path = os.getcwd()
|
|
59
89
|
|
|
60
90
|
project_path = os.path.abspath(project_path)
|
|
61
|
-
excludes =
|
|
91
|
+
excludes = (
|
|
92
|
+
list(set(exclude_patterns)) if exclude_patterns is not None else list(_DEFAULT_EXCLUDE)
|
|
93
|
+
)
|
|
62
94
|
ignore_type_checking_imports = bool(
|
|
63
95
|
options and options.ignore_type_checking_imports
|
|
64
96
|
)
|
|
@@ -117,8 +149,10 @@ def _extract_graph_uncached(
|
|
|
117
149
|
)
|
|
118
150
|
|
|
119
151
|
# Extract and resolve imports
|
|
120
|
-
imports =
|
|
121
|
-
for
|
|
152
|
+
imports = _extract_located_imports(file_path)
|
|
153
|
+
for located_import in imports:
|
|
154
|
+
module_name = located_import.module_name
|
|
155
|
+
import_kind = located_import.import_kind
|
|
122
156
|
if (
|
|
123
157
|
ignore_type_checking_imports
|
|
124
158
|
and import_kind == ImportKind.TYPE_IMPORT
|
|
@@ -185,6 +219,14 @@ def _extract_imports(file_path: str) -> list[tuple[str, ImportKind]]:
|
|
|
185
219
|
|
|
186
220
|
Returns list of (module_name, import_kind) tuples.
|
|
187
221
|
"""
|
|
222
|
+
return [
|
|
223
|
+
(import_.module_name, import_.import_kind)
|
|
224
|
+
for import_ in _extract_located_imports(file_path)
|
|
225
|
+
]
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _extract_located_imports(file_path: str) -> list[_LocatedImport]:
|
|
229
|
+
"""Parse a Python file and extract imports with line numbers."""
|
|
188
230
|
try:
|
|
189
231
|
with open(file_path, "r", encoding="utf-8", errors="replace") as f:
|
|
190
232
|
source = f.read()
|
|
@@ -196,7 +238,8 @@ def _extract_imports(file_path: str) -> list[tuple[str, ImportKind]]:
|
|
|
196
238
|
except SyntaxError:
|
|
197
239
|
return []
|
|
198
240
|
|
|
199
|
-
imports: list[
|
|
241
|
+
imports: list[_LocatedImport] = []
|
|
242
|
+
ignore_directives = _find_ignore_directives(source)
|
|
200
243
|
type_checking_ranges = _find_type_checking_ranges(tree)
|
|
201
244
|
|
|
202
245
|
for node in ast.walk(tree):
|
|
@@ -204,7 +247,7 @@ def _extract_imports(file_path: str) -> list[tuple[str, ImportKind]]:
|
|
|
204
247
|
is_type = _in_type_checking(node, type_checking_ranges)
|
|
205
248
|
kind = ImportKind.TYPE_IMPORT if is_type else ImportKind.IMPORT
|
|
206
249
|
for alias in node.names:
|
|
207
|
-
imports.append((alias.name, kind))
|
|
250
|
+
imports.append(_LocatedImport(alias.name, kind, node.lineno))
|
|
208
251
|
|
|
209
252
|
elif isinstance(node, ast.ImportFrom):
|
|
210
253
|
is_type = _in_type_checking(node, type_checking_ranges)
|
|
@@ -213,13 +256,79 @@ def _extract_imports(file_path: str) -> list[tuple[str, ImportKind]]:
|
|
|
213
256
|
kind = ImportKind.TYPE_IMPORT if is_type else ImportKind.RELATIVE_IMPORT
|
|
214
257
|
module = node.module or ""
|
|
215
258
|
dots = "." * node.level
|
|
216
|
-
imports.append((f"{dots}{module}", kind))
|
|
259
|
+
imports.append(_LocatedImport(f"{dots}{module}", kind, node.lineno))
|
|
217
260
|
else:
|
|
218
261
|
kind = ImportKind.TYPE_IMPORT if is_type else ImportKind.FROM_IMPORT
|
|
219
262
|
if node.module:
|
|
220
|
-
imports.append((node.module, kind))
|
|
263
|
+
imports.append(_LocatedImport(node.module, kind, node.lineno))
|
|
264
|
+
|
|
265
|
+
elif isinstance(node, ast.Call):
|
|
266
|
+
is_type = _in_type_checking(node, type_checking_ranges)
|
|
267
|
+
kind = ImportKind.TYPE_IMPORT if is_type else ImportKind.DYNAMIC_IMPORT
|
|
268
|
+
for module_name in _extract_dynamic_import_names(node):
|
|
269
|
+
imports.append(_LocatedImport(module_name, kind, node.lineno))
|
|
270
|
+
|
|
271
|
+
return [
|
|
272
|
+
import_
|
|
273
|
+
for import_ in imports
|
|
274
|
+
if not _is_ignored_import(import_, ignore_directives)
|
|
275
|
+
]
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _find_ignore_directives(source: str) -> dict[int, _IgnoreDirective]:
|
|
279
|
+
"""Find architecture-ignore directives.
|
|
280
|
+
|
|
281
|
+
Supports inline directives on an import line and standalone directives that
|
|
282
|
+
apply to the following line, for example:
|
|
283
|
+
|
|
284
|
+
- from x import y # archunit: ignore
|
|
285
|
+
- # archunit: ignore
|
|
286
|
+
from x import y
|
|
287
|
+
"""
|
|
288
|
+
directives: dict[int, _IgnoreDirective] = {}
|
|
289
|
+
for index, line in enumerate(source.splitlines(), start=1):
|
|
290
|
+
match = _IGNORE_DIRECTIVE_REGEX.search(line)
|
|
291
|
+
if match is None:
|
|
292
|
+
continue
|
|
293
|
+
|
|
294
|
+
modules = tuple(match.group("modules").split())
|
|
295
|
+
target_line = index + 1 if line.strip().startswith("#") else index
|
|
296
|
+
directives[target_line] = _IgnoreDirective(target_line, modules)
|
|
297
|
+
return directives
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _is_ignored_import(
|
|
301
|
+
import_: _LocatedImport,
|
|
302
|
+
directives: dict[int, _IgnoreDirective],
|
|
303
|
+
) -> bool:
|
|
304
|
+
directive = directives.get(import_.line_number)
|
|
305
|
+
return directive is not None and directive.matches(import_)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _extract_dynamic_import_names(node: ast.Call) -> list[str]:
|
|
309
|
+
"""Extract literal module names from common dynamic import calls."""
|
|
310
|
+
if not node.args:
|
|
311
|
+
return []
|
|
312
|
+
|
|
313
|
+
first_arg = node.args[0]
|
|
314
|
+
if not isinstance(first_arg, ast.Constant) or not isinstance(first_arg.value, str):
|
|
315
|
+
return []
|
|
221
316
|
|
|
222
|
-
|
|
317
|
+
if isinstance(node.func, ast.Name) and node.func.id in {
|
|
318
|
+
"__import__",
|
|
319
|
+
"import_module",
|
|
320
|
+
}:
|
|
321
|
+
return [first_arg.value]
|
|
322
|
+
|
|
323
|
+
if (
|
|
324
|
+
isinstance(node.func, ast.Attribute)
|
|
325
|
+
and node.func.attr == "import_module"
|
|
326
|
+
and isinstance(node.func.value, ast.Name)
|
|
327
|
+
and node.func.value.id == "importlib"
|
|
328
|
+
):
|
|
329
|
+
return [first_arg.value]
|
|
330
|
+
|
|
331
|
+
return []
|
|
223
332
|
|
|
224
333
|
|
|
225
334
|
def _find_type_checking_ranges(tree: ast.Module) -> list[tuple[int, int]]:
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Dependency graph reports."""
|
|
2
|
+
|
|
3
|
+
from archunitpython.graph.graph_reporter import (
|
|
4
|
+
DEFAULT_TITLE,
|
|
5
|
+
FolderDepthCollapse,
|
|
6
|
+
GraphCollapseStrategy,
|
|
7
|
+
GraphQueryOptions,
|
|
8
|
+
GraphReportEdge,
|
|
9
|
+
GraphReporter,
|
|
10
|
+
GraphReportFormat,
|
|
11
|
+
GraphReportNode,
|
|
12
|
+
GraphReportSnapshot,
|
|
13
|
+
GraphReportSummary,
|
|
14
|
+
PatternCollapse,
|
|
15
|
+
ProjectGraphBuilder,
|
|
16
|
+
dependency_graph,
|
|
17
|
+
project_graph,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"DEFAULT_TITLE",
|
|
22
|
+
"FolderDepthCollapse",
|
|
23
|
+
"GraphCollapseStrategy",
|
|
24
|
+
"GraphQueryOptions",
|
|
25
|
+
"GraphReportEdge",
|
|
26
|
+
"GraphReportFormat",
|
|
27
|
+
"GraphReportNode",
|
|
28
|
+
"GraphReportSnapshot",
|
|
29
|
+
"GraphReportSummary",
|
|
30
|
+
"GraphReporter",
|
|
31
|
+
"PatternCollapse",
|
|
32
|
+
"ProjectGraphBuilder",
|
|
33
|
+
"dependency_graph",
|
|
34
|
+
"project_graph",
|
|
35
|
+
]
|