untangled 1.0.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.
- untangled/__init__.py +2 -0
- untangled/__main__.py +6 -0
- untangled/analysis/__init__.py +1 -0
- untangled/analysis/analyzer.py +108 -0
- untangled/analysis/clustering.py +96 -0
- untangled/analysis/cycles.py +88 -0
- untangled/analysis/filtering.py +75 -0
- untangled/cli.py +223 -0
- untangled/graph.py +144 -0
- untangled/report/__init__.py +1 -0
- untangled/report/assets/LICENSE.MIT +21 -0
- untangled/report/assets/report.css +355 -0
- untangled/report/assets/report.js +503 -0
- untangled/report/dot.py +91 -0
- untangled/report/html.py +608 -0
- untangled/report/report.py +74 -0
- untangled/scanner/__init__.py +1 -0
- untangled/scanner/imports.py +414 -0
- untangled/scanner/packages.py +161 -0
- untangled/scanner/project.py +179 -0
- untangled/scanner/scanner.py +251 -0
- untangled/scanner/stdlib.py +43 -0
- untangled/text.py +29 -0
- untangled/version.py +5 -0
- untangled-1.0.0.dist-info/METADATA +185 -0
- untangled-1.0.0.dist-info/RECORD +31 -0
- untangled-1.0.0.dist-info/WHEEL +5 -0
- untangled-1.0.0.dist-info/entry_points.txt +3 -0
- untangled-1.0.0.dist-info/licenses/LICENSE +339 -0
- untangled-1.0.0.dist-info/licenses/src/untangled/report/assets/LICENSE.MIT +21 -0
- untangled-1.0.0.dist-info/top_level.txt +1 -0
untangled/__init__.py
ADDED
untangled/__main__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-2.0-only
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-2.0-only
|
|
2
|
+
"""Computes the analysis document of spec/metrics.md from a filtered file-level pairs document:
|
|
3
|
+
per-unit coupling, Ca / Ce for clusters, cycles, and the project summary."""
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from untangled import version
|
|
8
|
+
from untangled.analysis import cycles as cycle_finder
|
|
9
|
+
from untangled.analysis.clustering import Clustering
|
|
10
|
+
from untangled.graph import CLUSTER, EXTERNAL, INTERNAL, UNRESOLVED, PairsDocument, number
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def analyze(files: PairsDocument, clustering: Clustering | None = None) -> dict[str, Any]:
|
|
14
|
+
"""Raises ValueError when the document is already clustered."""
|
|
15
|
+
if any(node["kind"] == CLUSTER for node in files.nodes()):
|
|
16
|
+
raise ValueError(
|
|
17
|
+
"the pairs document is already clustered; analyze the file-level document and choose a clustering instead"
|
|
18
|
+
)
|
|
19
|
+
graph = files
|
|
20
|
+
cluster_of = None
|
|
21
|
+
if clustering is not None:
|
|
22
|
+
clustered = clustering.apply(files)
|
|
23
|
+
graph, cluster_of = clustered.document, clustered.cluster_of
|
|
24
|
+
|
|
25
|
+
unit_kind = INTERNAL if clustering is None else CLUSTER
|
|
26
|
+
successors: dict[str, list[str]] = {}
|
|
27
|
+
predecessors: dict[str, list[str]] = {}
|
|
28
|
+
external: dict[str, set[str]] = {}
|
|
29
|
+
unresolved: dict[str, set[str]] = {}
|
|
30
|
+
members: dict[str, list[str]] = {}
|
|
31
|
+
for node in graph.nodes():
|
|
32
|
+
if node["kind"] == unit_kind:
|
|
33
|
+
successors[node["id"]] = []
|
|
34
|
+
predecessors[node["id"]] = []
|
|
35
|
+
external[node["id"]] = set()
|
|
36
|
+
unresolved[node["id"]] = set()
|
|
37
|
+
members[node["id"]] = node.get("members", [])
|
|
38
|
+
|
|
39
|
+
edges = 0
|
|
40
|
+
external_used: set[str] = set()
|
|
41
|
+
unresolved_used: set[str] = set()
|
|
42
|
+
for edge in graph.edges():
|
|
43
|
+
source, target = edge["from"], edge["to"]
|
|
44
|
+
kind = (graph.node(target) or {}).get("kind")
|
|
45
|
+
if kind == unit_kind:
|
|
46
|
+
successors[source].append(target)
|
|
47
|
+
predecessors[target].append(source)
|
|
48
|
+
edges += 1
|
|
49
|
+
elif kind == EXTERNAL:
|
|
50
|
+
external[source].add(target)
|
|
51
|
+
external_used.add(target)
|
|
52
|
+
elif kind == UNRESOLVED:
|
|
53
|
+
unresolved[source].add(target)
|
|
54
|
+
unresolved_used.add(target)
|
|
55
|
+
|
|
56
|
+
# Ca / Ce of a cluster count the distinct files outside it, on the file graph.
|
|
57
|
+
ca: dict[str, set[str]] = {}
|
|
58
|
+
ce: dict[str, set[str]] = {}
|
|
59
|
+
if cluster_of is not None:
|
|
60
|
+
for edge in files.edges():
|
|
61
|
+
source, target = cluster_of.get(edge["from"]), cluster_of.get(edge["to"])
|
|
62
|
+
if source is None or target is None or source == target:
|
|
63
|
+
continue
|
|
64
|
+
ce.setdefault(source, set()).add(edge["to"])
|
|
65
|
+
ca.setdefault(target, set()).add(edge["from"])
|
|
66
|
+
|
|
67
|
+
units = []
|
|
68
|
+
fan_out_total = 0
|
|
69
|
+
for unit_id, targets in successors.items():
|
|
70
|
+
fan_in, fan_out = len(predecessors[unit_id]), len(targets)
|
|
71
|
+
fan_out_total += fan_out
|
|
72
|
+
unit: dict[str, Any] = {"id": unit_id, "fan_in": fan_in, "fan_out": fan_out}
|
|
73
|
+
if cluster_of is not None:
|
|
74
|
+
unit["ca"], unit["ce"] = len(ca.get(unit_id, ())), len(ce.get(unit_id, ()))
|
|
75
|
+
afferent, efferent = unit["ca"], unit["ce"]
|
|
76
|
+
else:
|
|
77
|
+
afferent, efferent = fan_in, fan_out
|
|
78
|
+
unit["external"] = len(external[unit_id])
|
|
79
|
+
unit["unresolved"] = len(unresolved[unit_id])
|
|
80
|
+
unit["instability"] = None if afferent + efferent == 0 else number(efferent / (afferent + efferent))
|
|
81
|
+
if cluster_of is not None:
|
|
82
|
+
unit["members"] = members[unit_id]
|
|
83
|
+
units.append(unit)
|
|
84
|
+
units.sort(key=lambda unit: unit["id"])
|
|
85
|
+
|
|
86
|
+
cycles = cycle_finder.find(successors)
|
|
87
|
+
in_cycles = sum(cycle["size"] for cycle in cycles)
|
|
88
|
+
count = len(units)
|
|
89
|
+
return {
|
|
90
|
+
"format": "untangled-analysis",
|
|
91
|
+
"version": version.SPEC,
|
|
92
|
+
"language": files.language,
|
|
93
|
+
"generator": {"name": version.NAME, "version": version.VERSION},
|
|
94
|
+
"clustering": None if clustering is None else clustering.name(),
|
|
95
|
+
"summary": {
|
|
96
|
+
"units": count,
|
|
97
|
+
"edges": edges,
|
|
98
|
+
"cycles": len(cycles),
|
|
99
|
+
"largest_cycle": cycles[0]["size"] if cycles else 0,
|
|
100
|
+
"units_in_cycles": in_cycles,
|
|
101
|
+
"units_in_cycles_ratio": number(in_cycles / count) if count else 0,
|
|
102
|
+
"mean_fan_out": number(fan_out_total / count) if count else 0,
|
|
103
|
+
"external_packages": len(external_used),
|
|
104
|
+
"unresolved_references": len(unresolved_used),
|
|
105
|
+
},
|
|
106
|
+
"units": units,
|
|
107
|
+
"cycles": cycles,
|
|
108
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-2.0-only
|
|
2
|
+
"""A clustering strategy from spec/clustering.md (`namespace:N`, `folder:N` or `rules`) and the
|
|
3
|
+
step that applies it: one node per cluster, edges merged per (cluster, target), and edges
|
|
4
|
+
inside a cluster dropped."""
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from untangled.graph import CLUSTER, INTERNAL, PairsDocument
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class Clustered:
|
|
15
|
+
document: PairsDocument
|
|
16
|
+
cluster_of: dict[str, str]
|
|
17
|
+
"""Each internal id's cluster."""
|
|
18
|
+
dropped: dict[str, int] = field(default_factory=dict)
|
|
19
|
+
"""Per cluster, how many file edges fell inside it and were dropped."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Clustering:
|
|
23
|
+
def __init__(self, strategy: str, depth: int = 0, rules: list[tuple[str, str]] | None = None) -> None:
|
|
24
|
+
if strategy in ("namespace", "folder") and depth < 1:
|
|
25
|
+
raise ValueError("clustering depth must be at least 1")
|
|
26
|
+
self.strategy = strategy
|
|
27
|
+
self.depth = depth
|
|
28
|
+
self.rules = rules or []
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def from_string(cls, strategy: str) -> "Clustering":
|
|
32
|
+
"""A strategy as written on the command line: `namespace:N` or `folder:N`.
|
|
33
|
+
(`rules` needs a rules file, so it is built with parse_rules().)"""
|
|
34
|
+
match = re.fullmatch(r"(namespace|folder):([1-9][0-9]*)", strategy)
|
|
35
|
+
if match is None:
|
|
36
|
+
raise ValueError(f'unknown clustering "{strategy}" (use namespace:N, folder:N or rules:<file>)')
|
|
37
|
+
return cls(match.group(1), int(match.group(2)))
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def parse_rules(cls, text: str) -> "Clustering":
|
|
41
|
+
"""One `prefix cluster` pair per line, `#` starts a comment."""
|
|
42
|
+
rules = []
|
|
43
|
+
for number, line in enumerate(text.splitlines(), start=1):
|
|
44
|
+
fields = line.split("#", 1)[0].split()
|
|
45
|
+
if not fields:
|
|
46
|
+
continue
|
|
47
|
+
if len(fields) != 2:
|
|
48
|
+
raise ValueError(f'rules line {number} must be "<prefix> <cluster>"')
|
|
49
|
+
rules.append((fields[0], fields[1]))
|
|
50
|
+
return cls("rules", rules=rules)
|
|
51
|
+
|
|
52
|
+
def name(self) -> str:
|
|
53
|
+
"""The name written in the analysis document's `clustering` field."""
|
|
54
|
+
return "rules" if self.strategy == "rules" else f"{self.strategy}:{self.depth}"
|
|
55
|
+
|
|
56
|
+
def cluster_of(self, node: dict[str, Any]) -> str:
|
|
57
|
+
node_id = node["id"]
|
|
58
|
+
if self.strategy == "rules":
|
|
59
|
+
for prefix, cluster in self.rules:
|
|
60
|
+
if node_id.startswith(prefix):
|
|
61
|
+
return cluster
|
|
62
|
+
return node_id
|
|
63
|
+
module = node.get("module")
|
|
64
|
+
if self.strategy == "namespace" and isinstance(module, str) and module:
|
|
65
|
+
# Segments are separated by `\\` (PHP namespaces) or `.` (Python modules); the
|
|
66
|
+
# separators are kept, so the cluster id reads like the module it shortens.
|
|
67
|
+
parts = re.split(r"([\\\\.])", module)
|
|
68
|
+
return "".join(parts[: 2 * self.depth - 1])
|
|
69
|
+
folders = node_id.split("/")[:-1]
|
|
70
|
+
return "/".join(folders[: self.depth]) if folders else "."
|
|
71
|
+
|
|
72
|
+
def apply(self, files: PairsDocument) -> Clustered:
|
|
73
|
+
cluster_of: dict[str, str] = {}
|
|
74
|
+
members: dict[str, list[str]] = {}
|
|
75
|
+
others = []
|
|
76
|
+
for node in files.nodes():
|
|
77
|
+
if node["kind"] == INTERNAL:
|
|
78
|
+
cluster = self.cluster_of(node)
|
|
79
|
+
cluster_of[node["id"]] = cluster
|
|
80
|
+
members.setdefault(cluster, []).append(node["id"])
|
|
81
|
+
else:
|
|
82
|
+
others.append(node)
|
|
83
|
+
document = PairsDocument(files.language)
|
|
84
|
+
for cluster, ids in members.items():
|
|
85
|
+
document.add_node(cluster, CLUSTER, members=sorted(ids))
|
|
86
|
+
for node in others:
|
|
87
|
+
document.add_node(node["id"], node["kind"], **{k: v for k, v in node.items() if k not in ("id", "kind")})
|
|
88
|
+
dropped: dict[str, int] = {}
|
|
89
|
+
for edge in files.edges():
|
|
90
|
+
source = cluster_of.get(edge["from"], edge["from"])
|
|
91
|
+
target = cluster_of.get(edge["to"], edge["to"])
|
|
92
|
+
if source == target:
|
|
93
|
+
dropped[source] = dropped.get(source, 0) + 1
|
|
94
|
+
continue
|
|
95
|
+
document.add_edge(source, target, edge["kinds"], edge["count"])
|
|
96
|
+
return Clustered(document, cluster_of, dict(sorted(dropped.items())))
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-2.0-only
|
|
2
|
+
"""Cycles as defined in spec/metrics.md: strongly connected components with two or more units,
|
|
3
|
+
each with one concrete example loop."""
|
|
4
|
+
|
|
5
|
+
from collections import deque
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def find(successors: dict[str, list[str]]) -> list[dict[str, Any]]:
|
|
10
|
+
"""`successors` has every unit as a key, with the units it depends on."""
|
|
11
|
+
graph = {unit: sorted(set(targets)) for unit, targets in sorted(successors.items())}
|
|
12
|
+
cycles = []
|
|
13
|
+
for component in _components(graph):
|
|
14
|
+
if len(component) < 2:
|
|
15
|
+
continue
|
|
16
|
+
members = sorted(component)
|
|
17
|
+
cycles.append({"members": members, "size": len(members), "example": _example(members[0], set(members), graph)})
|
|
18
|
+
cycles.sort(key=lambda cycle: (-cycle["size"], cycle["members"][0]))
|
|
19
|
+
return cycles
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _components(graph: dict[str, list[str]]) -> list[list[str]]:
|
|
23
|
+
"""Tarjan's algorithm, written iteratively so deep dependency chains cannot overflow the stack."""
|
|
24
|
+
index: dict[str, int] = {}
|
|
25
|
+
low: dict[str, int] = {}
|
|
26
|
+
on_stack: set[str] = set()
|
|
27
|
+
stack: list[str] = []
|
|
28
|
+
components = []
|
|
29
|
+
for root in graph:
|
|
30
|
+
if root in index:
|
|
31
|
+
continue
|
|
32
|
+
# Each frame is [unit, position of the next successor to visit].
|
|
33
|
+
frames = [[root, 0]]
|
|
34
|
+
index[root] = low[root] = len(index)
|
|
35
|
+
stack.append(root)
|
|
36
|
+
on_stack.add(root)
|
|
37
|
+
while frames:
|
|
38
|
+
unit, position = frames[-1]
|
|
39
|
+
targets = graph[unit]
|
|
40
|
+
if position < len(targets):
|
|
41
|
+
frames[-1][1] += 1
|
|
42
|
+
target = targets[position]
|
|
43
|
+
if target not in index:
|
|
44
|
+
index[target] = low[target] = len(index)
|
|
45
|
+
stack.append(target)
|
|
46
|
+
on_stack.add(target)
|
|
47
|
+
frames.append([target, 0])
|
|
48
|
+
elif target in on_stack:
|
|
49
|
+
low[unit] = min(low[unit], index[target])
|
|
50
|
+
continue
|
|
51
|
+
frames.pop()
|
|
52
|
+
if frames:
|
|
53
|
+
parent = frames[-1][0]
|
|
54
|
+
low[parent] = min(low[parent], low[unit])
|
|
55
|
+
if low[unit] == index[unit]:
|
|
56
|
+
component = []
|
|
57
|
+
while True:
|
|
58
|
+
member = stack.pop()
|
|
59
|
+
on_stack.discard(member)
|
|
60
|
+
component.append(member)
|
|
61
|
+
if member == unit:
|
|
62
|
+
break
|
|
63
|
+
components.append(component)
|
|
64
|
+
return components
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _example(start: str, members: set[str], graph: dict[str, list[str]]) -> list[str]:
|
|
68
|
+
"""The shortest loop from start back to itself, by breadth-first search visiting
|
|
69
|
+
neighbours in byte order. Any such loop stays inside the cycle, so the search does too."""
|
|
70
|
+
parent: dict[str, str] = {}
|
|
71
|
+
queue = deque([start])
|
|
72
|
+
while queue:
|
|
73
|
+
unit = queue.popleft()
|
|
74
|
+
for target in graph[unit]:
|
|
75
|
+
if target not in members:
|
|
76
|
+
continue
|
|
77
|
+
if target == start:
|
|
78
|
+
path = [start]
|
|
79
|
+
step = unit
|
|
80
|
+
while step != start:
|
|
81
|
+
path.append(step)
|
|
82
|
+
step = parent[step]
|
|
83
|
+
path.append(start)
|
|
84
|
+
return path[::-1]
|
|
85
|
+
if target not in parent:
|
|
86
|
+
parent[target] = unit
|
|
87
|
+
queue.append(target)
|
|
88
|
+
raise AssertionError(f"no loop through {start}")
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-2.0-only
|
|
2
|
+
"""The filtering step of spec/clustering.md: drops builtin nodes (unless kept), then applies
|
|
3
|
+
ordered include / exclude globs to internal ids, where the last matching pattern wins."""
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from untangled.graph import BUILTIN, CLUSTER, INTERNAL, PairsDocument
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Glob:
|
|
11
|
+
"""`*` and `?` stay within one path segment, `**` matches across `/`, and `**` followed
|
|
12
|
+
by `/` also matches no directory at all."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, pattern: str) -> None:
|
|
15
|
+
self.pattern = pattern
|
|
16
|
+
regex = ""
|
|
17
|
+
i = 0
|
|
18
|
+
while i < len(pattern):
|
|
19
|
+
char = pattern[i]
|
|
20
|
+
if pattern.startswith("**/", i):
|
|
21
|
+
regex += "(?:.*/)?"
|
|
22
|
+
i += 3
|
|
23
|
+
continue
|
|
24
|
+
if pattern.startswith("**", i):
|
|
25
|
+
regex += ".*"
|
|
26
|
+
i += 2
|
|
27
|
+
continue
|
|
28
|
+
regex += "[^/]*" if char == "*" else "[^/]" if char == "?" else re.escape(char)
|
|
29
|
+
i += 1
|
|
30
|
+
self._regex = re.compile(regex, re.DOTALL)
|
|
31
|
+
|
|
32
|
+
def matches(self, node_id: str) -> bool:
|
|
33
|
+
return self._regex.fullmatch(node_id) is not None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Filter:
|
|
37
|
+
def __init__(self, keep_builtins: bool = False) -> None:
|
|
38
|
+
self.keep_builtins = keep_builtins
|
|
39
|
+
self._patterns: list[tuple[bool, Glob]] = []
|
|
40
|
+
|
|
41
|
+
def include(self, pattern: str) -> "Filter":
|
|
42
|
+
self._patterns.append((True, Glob(pattern)))
|
|
43
|
+
return self
|
|
44
|
+
|
|
45
|
+
def exclude(self, pattern: str) -> "Filter":
|
|
46
|
+
self._patterns.append((False, Glob(pattern)))
|
|
47
|
+
return self
|
|
48
|
+
|
|
49
|
+
def keeps(self, internal_id: str) -> bool:
|
|
50
|
+
keep = True
|
|
51
|
+
for include, glob in self._patterns:
|
|
52
|
+
if glob.matches(internal_id):
|
|
53
|
+
keep = include
|
|
54
|
+
return keep
|
|
55
|
+
|
|
56
|
+
def apply(self, document: PairsDocument) -> PairsDocument:
|
|
57
|
+
"""Returns the filtered document. Removing a node removes its edges, and a non-internal
|
|
58
|
+
node that no remaining edge points to is removed with them."""
|
|
59
|
+
kept = {}
|
|
60
|
+
for node in document.nodes():
|
|
61
|
+
if node["kind"] == BUILTIN and not self.keep_builtins:
|
|
62
|
+
continue
|
|
63
|
+
if node["kind"] == INTERNAL and not self.keeps(node["id"]):
|
|
64
|
+
continue
|
|
65
|
+
kept[node["id"]] = node
|
|
66
|
+
edges = [edge for edge in document.edges() if edge["from"] in kept and edge["to"] in kept]
|
|
67
|
+
targets = {edge["to"] for edge in edges}
|
|
68
|
+
filtered = PairsDocument(document.language)
|
|
69
|
+
for node_id, node in kept.items():
|
|
70
|
+
# Cluster nodes pass through so that the analyzer can refuse an already clustered document.
|
|
71
|
+
if node["kind"] in (INTERNAL, CLUSTER) or node_id in targets:
|
|
72
|
+
filtered.add_node(node_id, node["kind"], **{k: v for k, v in node.items() if k not in ("id", "kind")})
|
|
73
|
+
for edge in edges:
|
|
74
|
+
filtered.add_edge(edge["from"], edge["to"], edge["kinds"], edge["count"])
|
|
75
|
+
return filtered
|
untangled/cli.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-2.0-only
|
|
2
|
+
"""`untangled <command> [options]`. The command and option names are shared by every
|
|
3
|
+
Untangled library: `scan`, `analyze` and `report`."""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import tomllib
|
|
9
|
+
from typing import TextIO
|
|
10
|
+
|
|
11
|
+
from untangled import version
|
|
12
|
+
from untangled.analysis.analyzer import analyze
|
|
13
|
+
from untangled.analysis.clustering import Clustering
|
|
14
|
+
from untangled.analysis.filtering import Filter
|
|
15
|
+
from untangled.graph import DocumentError, PairsDocument, encode
|
|
16
|
+
from untangled.report import dot, html
|
|
17
|
+
from untangled.report.report import Report
|
|
18
|
+
from untangled.scanner.project import ProjectError
|
|
19
|
+
from untangled.scanner.scanner import Scanner
|
|
20
|
+
|
|
21
|
+
USAGE = """\
|
|
22
|
+
Usage: untangled scan [<path>] [--dev] [--source=<path>]... [--venv=<path>]...
|
|
23
|
+
[--format=json|snakefood] [--output=<file>]
|
|
24
|
+
untangled analyze [<path>|<pairs.json>] [scan options] [--cluster=<strategy>]
|
|
25
|
+
[--include=<glob>]... [--exclude=<glob>]... [--output=<file>]
|
|
26
|
+
untangled report [<path>|<pairs.json>] [--format=html|dot] [--style=classic]
|
|
27
|
+
[--title=<text>] [analyze options]
|
|
28
|
+
|
|
29
|
+
Commands:
|
|
30
|
+
scan Write the dependency pairs of a Python project (default path: .)
|
|
31
|
+
analyze Write cycles and coupling metrics for a project or a pairs document
|
|
32
|
+
report Write a readable HTML report (or a Graphviz DOT graph) of the analysis
|
|
33
|
+
|
|
34
|
+
Options:
|
|
35
|
+
--dev Also scan tests (test/ and tests/ folders, test_*.py, *_test.py, conftest.py)
|
|
36
|
+
--source=PATH Add PATH, a folder relative to the project, to the import roots (repeatable),
|
|
37
|
+
for code that is importable from a folder the scanner does not find by itself
|
|
38
|
+
--venv=PATH Read installed packages from this virtualenv instead of the ones found in the
|
|
39
|
+
project (repeatable)
|
|
40
|
+
--format=FORMAT scan: json (default, the Untangled pairs document) or snakefood
|
|
41
|
+
report: html (default) or dot
|
|
42
|
+
--style=STYLE report --format=dot only: default, or classic for snakefood's
|
|
43
|
+
black and white sfood-graph look
|
|
44
|
+
--title=TEXT report only: the report's title (default: the project name in pyproject.toml)
|
|
45
|
+
--cluster=STRATEGY analyze, report: namespace:N, folder:N or rules:<file>
|
|
46
|
+
--include=GLOB analyze, report: keep files matching GLOB (repeatable, last match wins)
|
|
47
|
+
--exclude=GLOB analyze, report: drop files matching GLOB (repeatable, last match wins)
|
|
48
|
+
--output=FILE Write to FILE instead of standard output
|
|
49
|
+
-h, --help Show this help
|
|
50
|
+
-V, --version Show the version
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
_SCAN = {"dev", "source", "venv"}
|
|
54
|
+
_COMMANDS = {
|
|
55
|
+
"scan": _SCAN | {"format", "output"},
|
|
56
|
+
"analyze": _SCAN | {"cluster", "include", "exclude", "output"},
|
|
57
|
+
"report": _SCAN | {"cluster", "include", "exclude", "output", "format", "style", "title"},
|
|
58
|
+
}
|
|
59
|
+
_REPEATABLE = {"source", "venv"}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class _Usage(Exception):
|
|
63
|
+
"""A usage error: exit code 2."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class _Failure(Exception):
|
|
67
|
+
"""An error reading or writing: exit code 1."""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def main(argv: list[str] | None = None, stdout: TextIO | None = None, stderr: TextIO | None = None) -> int:
|
|
71
|
+
out = stdout or sys.stdout
|
|
72
|
+
err = stderr or sys.stderr
|
|
73
|
+
try:
|
|
74
|
+
return _run(sys.argv[1:] if argv is None else argv, out, err)
|
|
75
|
+
except _Usage as error:
|
|
76
|
+
err.write(f"{error}\n")
|
|
77
|
+
return 2
|
|
78
|
+
except _Failure as error:
|
|
79
|
+
err.write(f"error: {error}\n")
|
|
80
|
+
return 1
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _run(args: list[str], out: TextIO, err: TextIO) -> int:
|
|
84
|
+
positional: list[str] = []
|
|
85
|
+
options: dict[str, str | bool] = {}
|
|
86
|
+
repeated: dict[str, list[str]] = {name: [] for name in _REPEATABLE}
|
|
87
|
+
patterns: list[tuple[str, str]] = []
|
|
88
|
+
for arg in args:
|
|
89
|
+
if arg in ("-h", "--help"):
|
|
90
|
+
out.write(USAGE)
|
|
91
|
+
return 0
|
|
92
|
+
if arg in ("-V", "--version"):
|
|
93
|
+
out.write(f"{version.NAME} {version.VERSION}\n")
|
|
94
|
+
return 0
|
|
95
|
+
if arg.startswith("--"):
|
|
96
|
+
name, has_value, value = arg[2:].partition("=")
|
|
97
|
+
options[name] = value if has_value else True
|
|
98
|
+
if has_value and name in _REPEATABLE:
|
|
99
|
+
repeated[name].append(value)
|
|
100
|
+
elif has_value and name in ("include", "exclude"):
|
|
101
|
+
# Order matters for these, so they are kept as a list rather than by name.
|
|
102
|
+
patterns.append((name, value))
|
|
103
|
+
continue
|
|
104
|
+
positional.append(arg)
|
|
105
|
+
|
|
106
|
+
command = positional.pop(0) if positional else None
|
|
107
|
+
if command not in _COMMANDS:
|
|
108
|
+
raise _Usage(USAGE.rstrip("\n") if command is None else f"Unknown command: {command}\n\n{USAGE.rstrip()}")
|
|
109
|
+
unknown = [name for name in options if name not in _COMMANDS[command]]
|
|
110
|
+
if unknown:
|
|
111
|
+
raise _Usage(f"Unknown option for {command}: --{unknown[0]}")
|
|
112
|
+
for name, value in options.items():
|
|
113
|
+
if name != "dev" and not isinstance(value, str):
|
|
114
|
+
raise _Usage(f"Option --{name} needs a value, as in --{name}=...")
|
|
115
|
+
if "dev" in options and options["dev"] is not True:
|
|
116
|
+
raise _Usage("Option --dev takes no value")
|
|
117
|
+
|
|
118
|
+
path = positional[0] if positional else "."
|
|
119
|
+
scanner = Scanner(dev="dev" in options, sources=repeated["source"], venvs=repeated["venv"] or None)
|
|
120
|
+
output = options.get("output")
|
|
121
|
+
output = output if isinstance(output, str) else None
|
|
122
|
+
|
|
123
|
+
if command == "scan":
|
|
124
|
+
output_format = options.get("format", "json")
|
|
125
|
+
if output_format not in ("json", "snakefood"):
|
|
126
|
+
raise _Usage(f"Unknown format: {output_format}")
|
|
127
|
+
pairs = _scan(scanner, path, err)
|
|
128
|
+
return _write(pairs.to_json() if output_format == "json" else pairs.to_snakefood(path), output, out)
|
|
129
|
+
|
|
130
|
+
output_format = options.get("format", "html")
|
|
131
|
+
if command == "report" and output_format not in ("html", "dot"):
|
|
132
|
+
raise _Usage(f"Unknown format: {output_format}")
|
|
133
|
+
style = options.get("style", "default")
|
|
134
|
+
if style not in dot.STYLES:
|
|
135
|
+
raise _Usage(f"Unknown style: {style}")
|
|
136
|
+
if "style" in options and output_format != "dot":
|
|
137
|
+
raise _Usage("--style only applies to --format=dot")
|
|
138
|
+
clustering = _clustering(options.get("cluster"))
|
|
139
|
+
|
|
140
|
+
if os.path.isfile(path):
|
|
141
|
+
warnings: list[str] = []
|
|
142
|
+
try:
|
|
143
|
+
with open(path, encoding="utf-8") as handle:
|
|
144
|
+
data = json.load(handle)
|
|
145
|
+
pairs = PairsDocument.from_dict(data, warnings)
|
|
146
|
+
except OSError as error:
|
|
147
|
+
raise _Failure(f"cannot read {path}") from error
|
|
148
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as error:
|
|
149
|
+
raise _Failure(f"{path} is not valid JSON: {error}") from error
|
|
150
|
+
except DocumentError as error:
|
|
151
|
+
raise _Failure(str(error)) from error
|
|
152
|
+
for warning in warnings:
|
|
153
|
+
err.write(f"warning: {warning}\n")
|
|
154
|
+
else:
|
|
155
|
+
pairs = _scan(scanner, path, err)
|
|
156
|
+
|
|
157
|
+
selection = Filter()
|
|
158
|
+
for kind, glob in patterns:
|
|
159
|
+
selection.include(glob) if kind == "include" else selection.exclude(glob)
|
|
160
|
+
try:
|
|
161
|
+
if command == "analyze":
|
|
162
|
+
return _write(encode(analyze(selection.apply(pairs), clustering)), output, out)
|
|
163
|
+
title = options.get("title")
|
|
164
|
+
report = Report.build(title if isinstance(title, str) else _title(path), selection.apply(pairs), clustering)
|
|
165
|
+
except ValueError as error:
|
|
166
|
+
raise _Failure(str(error)) from error
|
|
167
|
+
return _write(dot.render(report, str(style)) if output_format == "dot" else html.render(report), output, out)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _scan(scanner: Scanner, path: str, err: TextIO) -> PairsDocument:
|
|
171
|
+
try:
|
|
172
|
+
result = scanner.scan(path)
|
|
173
|
+
except ProjectError as error:
|
|
174
|
+
raise _Failure(str(error)) from error
|
|
175
|
+
for warning in result.warnings:
|
|
176
|
+
err.write(f"warning: {warning}\n")
|
|
177
|
+
return result.pairs
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _clustering(strategy: str | bool | None) -> Clustering | None:
|
|
181
|
+
if not isinstance(strategy, str):
|
|
182
|
+
return None
|
|
183
|
+
if strategy.startswith("rules:"):
|
|
184
|
+
rules_file = strategy[len("rules:") :]
|
|
185
|
+
try:
|
|
186
|
+
with open(rules_file, encoding="utf-8") as handle:
|
|
187
|
+
text = handle.read()
|
|
188
|
+
except (OSError, UnicodeDecodeError) as error:
|
|
189
|
+
raise _Failure(f"cannot read rules file {rules_file}") from error
|
|
190
|
+
try:
|
|
191
|
+
return Clustering.parse_rules(text)
|
|
192
|
+
except ValueError as error:
|
|
193
|
+
raise _Usage(f"error: {error}") from error
|
|
194
|
+
try:
|
|
195
|
+
return Clustering.from_string(strategy)
|
|
196
|
+
except ValueError as error:
|
|
197
|
+
raise _Usage(f"error: {error}") from error
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _title(path: str) -> str:
|
|
201
|
+
"""The default report title: the project name in pyproject.toml, else the folder or file name."""
|
|
202
|
+
if os.path.isfile(path):
|
|
203
|
+
return os.path.basename(path)
|
|
204
|
+
try:
|
|
205
|
+
with open(os.path.join(path, "pyproject.toml"), "rb") as handle:
|
|
206
|
+
name = tomllib.load(handle).get("project", {}).get("name")
|
|
207
|
+
except (OSError, tomllib.TOMLDecodeError, UnicodeDecodeError, AttributeError):
|
|
208
|
+
name = None
|
|
209
|
+
if isinstance(name, str) and name:
|
|
210
|
+
return name
|
|
211
|
+
return os.path.basename(os.path.abspath(path))
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _write(text: str, output: str | None, out: TextIO) -> int:
|
|
215
|
+
if output is None:
|
|
216
|
+
out.write(text)
|
|
217
|
+
return 0
|
|
218
|
+
try:
|
|
219
|
+
with open(output, "w", encoding="utf-8", newline="\n") as handle:
|
|
220
|
+
handle.write(text)
|
|
221
|
+
except OSError as error:
|
|
222
|
+
raise _Failure(f"cannot write {output}") from error
|
|
223
|
+
return 0
|