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 ADDED
@@ -0,0 +1,2 @@
1
+ # SPDX-License-Identifier: GPL-2.0-only
2
+ """Untangled for Python: dependency health your whole team can understand."""
untangled/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ # SPDX-License-Identifier: GPL-2.0-only
2
+ import sys
3
+
4
+ from untangled.cli import main
5
+
6
+ sys.exit(main())
@@ -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