archy 0.4.1__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.
- archy/__init__.py +1 -0
- archy/cli.py +473 -0
- archy/cycles.py +59 -0
- archy/graph.py +335 -0
- archy/history.py +197 -0
- archy/layers.py +224 -0
- archy/mcp.py +260 -0
- archy/parser.py +140 -0
- archy/score.py +123 -0
- archy/trend.py +61 -0
- archy-0.4.1.dist-info/METADATA +277 -0
- archy-0.4.1.dist-info/RECORD +15 -0
- archy-0.4.1.dist-info/WHEEL +4 -0
- archy-0.4.1.dist-info/entry_points.txt +2 -0
- archy-0.4.1.dist-info/licenses/LICENSE +21 -0
archy/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.4.1"
|
archy/cli.py
ADDED
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
"""Click-based command-line interface for archy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
import networkx as nx
|
|
11
|
+
|
|
12
|
+
from archy import __version__
|
|
13
|
+
from archy.cycles import Cycle, find_cycles
|
|
14
|
+
from archy.graph import build_graph
|
|
15
|
+
from archy.history import append as append_history
|
|
16
|
+
from archy.history import git_metadata, row_from_score
|
|
17
|
+
from archy.history import read as read_history
|
|
18
|
+
from archy.layers import (
|
|
19
|
+
LayerConfigError,
|
|
20
|
+
Violation,
|
|
21
|
+
discover_config,
|
|
22
|
+
find_violations,
|
|
23
|
+
load_config,
|
|
24
|
+
)
|
|
25
|
+
from archy.score import Score, compute_score
|
|
26
|
+
from archy.trend import render_text as render_trend
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@click.group()
|
|
30
|
+
@click.version_option(__version__)
|
|
31
|
+
def main() -> None:
|
|
32
|
+
"""archy - architectural sensor for Python codebases."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@main.command()
|
|
36
|
+
@click.argument("path", type=click.Path(exists=True, file_okay=False, path_type=Path))
|
|
37
|
+
@click.option(
|
|
38
|
+
"--format",
|
|
39
|
+
"fmt",
|
|
40
|
+
type=click.Choice(["json", "dot", "text"]),
|
|
41
|
+
default="text",
|
|
42
|
+
help="Output format.",
|
|
43
|
+
)
|
|
44
|
+
@click.option(
|
|
45
|
+
"--internal-only",
|
|
46
|
+
is_flag=True,
|
|
47
|
+
help="Hide edges to external (third-party / stdlib) modules.",
|
|
48
|
+
)
|
|
49
|
+
def graph(path: Path, fmt: str, internal_only: bool) -> None:
|
|
50
|
+
"""Build the import graph for a Python project rooted at PATH."""
|
|
51
|
+
g = _load_graph(path, internal_only=internal_only)
|
|
52
|
+
|
|
53
|
+
if fmt == "json":
|
|
54
|
+
click.echo(json.dumps(_graph_to_dict(g), indent=2, sort_keys=True))
|
|
55
|
+
elif fmt == "dot":
|
|
56
|
+
click.echo(_graph_to_dot(g))
|
|
57
|
+
else:
|
|
58
|
+
click.echo(_graph_to_text(g))
|
|
59
|
+
|
|
60
|
+
if g.graph.get("parse_errors"):
|
|
61
|
+
click.echo(
|
|
62
|
+
f"\n[archy] {len(g.graph['parse_errors'])} file(s) had parse errors "
|
|
63
|
+
"(partial trees were used). Run with --format json to see which.",
|
|
64
|
+
err=True,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@main.command()
|
|
69
|
+
@click.argument("path", type=click.Path(exists=True, file_okay=False, path_type=Path))
|
|
70
|
+
@click.option(
|
|
71
|
+
"--format",
|
|
72
|
+
"fmt",
|
|
73
|
+
type=click.Choice(["text", "json"]),
|
|
74
|
+
default="text",
|
|
75
|
+
help="Output format.",
|
|
76
|
+
)
|
|
77
|
+
@click.option(
|
|
78
|
+
"--internal-only/--all",
|
|
79
|
+
default=True,
|
|
80
|
+
help="Restrict cycle detection to internal modules (the default).",
|
|
81
|
+
)
|
|
82
|
+
@click.option(
|
|
83
|
+
"--min-size",
|
|
84
|
+
type=int,
|
|
85
|
+
default=2,
|
|
86
|
+
show_default=True,
|
|
87
|
+
help="Minimum SCC size to report.",
|
|
88
|
+
)
|
|
89
|
+
@click.option(
|
|
90
|
+
"--strict",
|
|
91
|
+
is_flag=True,
|
|
92
|
+
help="Exit non-zero if any cycles are found.",
|
|
93
|
+
)
|
|
94
|
+
def cycles(path: Path, fmt: str, internal_only: bool, min_size: int, strict: bool) -> None:
|
|
95
|
+
"""Find import cycles in a Python project rooted at PATH."""
|
|
96
|
+
g = _load_graph(path, internal_only=internal_only)
|
|
97
|
+
|
|
98
|
+
found = find_cycles(g, min_size=min_size)
|
|
99
|
+
|
|
100
|
+
if fmt == "json":
|
|
101
|
+
click.echo(json.dumps(_cycles_to_json(found), indent=2, sort_keys=True))
|
|
102
|
+
else:
|
|
103
|
+
click.echo(_cycles_to_text(found, min_size))
|
|
104
|
+
|
|
105
|
+
if strict and found:
|
|
106
|
+
sys.exit(1)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@main.command()
|
|
110
|
+
@click.argument(
|
|
111
|
+
"path",
|
|
112
|
+
type=click.Path(exists=True, file_okay=False, path_type=Path),
|
|
113
|
+
default=".",
|
|
114
|
+
)
|
|
115
|
+
@click.option(
|
|
116
|
+
"--config",
|
|
117
|
+
"config_path",
|
|
118
|
+
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
|
119
|
+
default=None,
|
|
120
|
+
help="Path to archy.yaml. Discovered from PATH upward if omitted.",
|
|
121
|
+
)
|
|
122
|
+
@click.option(
|
|
123
|
+
"--format",
|
|
124
|
+
"fmt",
|
|
125
|
+
type=click.Choice(["text", "json"]),
|
|
126
|
+
default="text",
|
|
127
|
+
help="Output format.",
|
|
128
|
+
)
|
|
129
|
+
def check(path: Path, config_path: Path | None, fmt: str) -> None:
|
|
130
|
+
"""Check the project at PATH against layer rules in archy.yaml.
|
|
131
|
+
|
|
132
|
+
Exits 0 if there are no violations, 1 otherwise.
|
|
133
|
+
"""
|
|
134
|
+
if config_path is None:
|
|
135
|
+
discovered = discover_config(path)
|
|
136
|
+
if discovered is None:
|
|
137
|
+
raise click.ClickException(
|
|
138
|
+
f"no archy.yaml found near {path}; pass --config or create one at the project root."
|
|
139
|
+
)
|
|
140
|
+
config_path = discovered
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
config = load_config(config_path)
|
|
144
|
+
except LayerConfigError as exc:
|
|
145
|
+
raise click.ClickException(str(exc)) from exc
|
|
146
|
+
|
|
147
|
+
g = build_graph(path)
|
|
148
|
+
try:
|
|
149
|
+
violations = find_violations(g, config)
|
|
150
|
+
except LayerConfigError as exc:
|
|
151
|
+
raise click.ClickException(str(exc)) from exc
|
|
152
|
+
|
|
153
|
+
if fmt == "json":
|
|
154
|
+
click.echo(json.dumps(_violations_to_json(violations), indent=2, sort_keys=True))
|
|
155
|
+
else:
|
|
156
|
+
click.echo(_violations_to_text(violations, config_path))
|
|
157
|
+
|
|
158
|
+
if violations:
|
|
159
|
+
sys.exit(1)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@main.command()
|
|
163
|
+
@click.argument(
|
|
164
|
+
"path",
|
|
165
|
+
type=click.Path(exists=True, file_okay=False, path_type=Path),
|
|
166
|
+
default=".",
|
|
167
|
+
)
|
|
168
|
+
@click.option(
|
|
169
|
+
"--format",
|
|
170
|
+
"fmt",
|
|
171
|
+
type=click.Choice(["text", "json"]),
|
|
172
|
+
default="text",
|
|
173
|
+
help="Output format.",
|
|
174
|
+
)
|
|
175
|
+
@click.option(
|
|
176
|
+
"--internal-only/--all",
|
|
177
|
+
default=True,
|
|
178
|
+
help="Restrict scoring to internal modules (default).",
|
|
179
|
+
)
|
|
180
|
+
@click.option(
|
|
181
|
+
"--record",
|
|
182
|
+
is_flag=True,
|
|
183
|
+
help="Append this score to .archy/history.jsonl for archy trend.",
|
|
184
|
+
)
|
|
185
|
+
@click.option(
|
|
186
|
+
"--strict",
|
|
187
|
+
is_flag=True,
|
|
188
|
+
help=(
|
|
189
|
+
"Compare against the most recent recorded score; exit 1 if the overall "
|
|
190
|
+
"score drops by more than --strict-tolerance."
|
|
191
|
+
),
|
|
192
|
+
)
|
|
193
|
+
@click.option(
|
|
194
|
+
"--strict-tolerance",
|
|
195
|
+
type=float,
|
|
196
|
+
default=0.02,
|
|
197
|
+
show_default=True,
|
|
198
|
+
help="Maximum allowed drop in overall score before --strict fails.",
|
|
199
|
+
)
|
|
200
|
+
def score(
|
|
201
|
+
path: Path,
|
|
202
|
+
fmt: str,
|
|
203
|
+
internal_only: bool,
|
|
204
|
+
record: bool,
|
|
205
|
+
strict: bool,
|
|
206
|
+
strict_tolerance: float,
|
|
207
|
+
) -> None:
|
|
208
|
+
"""Compute the composite architecture quality score for PATH."""
|
|
209
|
+
g = _load_graph(path, internal_only=internal_only)
|
|
210
|
+
s = compute_score(g)
|
|
211
|
+
|
|
212
|
+
history_path = path / ".archy" / "history.jsonl"
|
|
213
|
+
# Strict reads BEFORE recording so the comparison is against the truly
|
|
214
|
+
# previous run rather than the row we are about to append.
|
|
215
|
+
gate = _gate_against_history(s, history_path) if strict else None
|
|
216
|
+
|
|
217
|
+
if fmt == "json":
|
|
218
|
+
payload = _score_to_dict(s)
|
|
219
|
+
if gate is not None:
|
|
220
|
+
payload["gate"] = _gate_to_dict(gate, strict_tolerance)
|
|
221
|
+
click.echo(json.dumps(payload, indent=2, sort_keys=True))
|
|
222
|
+
else:
|
|
223
|
+
click.echo(_score_to_text(s))
|
|
224
|
+
if gate is not None:
|
|
225
|
+
click.echo("")
|
|
226
|
+
click.echo(_gate_to_text(gate, strict_tolerance))
|
|
227
|
+
|
|
228
|
+
if record:
|
|
229
|
+
commit, branch = git_metadata(path)
|
|
230
|
+
row = row_from_score(s, commit=commit, branch=branch)
|
|
231
|
+
append_history(history_path, row)
|
|
232
|
+
|
|
233
|
+
if gate is not None and gate["delta"] is not None and gate["delta"] < -strict_tolerance:
|
|
234
|
+
sys.exit(1)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@main.command()
|
|
238
|
+
@click.argument(
|
|
239
|
+
"path",
|
|
240
|
+
type=click.Path(exists=True, file_okay=False, path_type=Path),
|
|
241
|
+
default=".",
|
|
242
|
+
)
|
|
243
|
+
@click.option(
|
|
244
|
+
"--last",
|
|
245
|
+
"last_n",
|
|
246
|
+
type=int,
|
|
247
|
+
default=10,
|
|
248
|
+
show_default=True,
|
|
249
|
+
help="Number of most-recent records to display.",
|
|
250
|
+
)
|
|
251
|
+
@click.option(
|
|
252
|
+
"--format",
|
|
253
|
+
"fmt",
|
|
254
|
+
type=click.Choice(["text", "json"]),
|
|
255
|
+
default="text",
|
|
256
|
+
help="Output format.",
|
|
257
|
+
)
|
|
258
|
+
def trend(path: Path, last_n: int, fmt: str) -> None:
|
|
259
|
+
"""Show the archy score trend for PATH (reads .archy/history.jsonl)."""
|
|
260
|
+
rows = read_history(path / ".archy" / "history.jsonl")
|
|
261
|
+
if fmt == "json":
|
|
262
|
+
window = rows[-last_n:] if last_n > 0 else rows
|
|
263
|
+
click.echo(
|
|
264
|
+
json.dumps(
|
|
265
|
+
[
|
|
266
|
+
{
|
|
267
|
+
"timestamp": r.timestamp,
|
|
268
|
+
"commit": r.commit,
|
|
269
|
+
"branch": r.branch,
|
|
270
|
+
"score": {
|
|
271
|
+
"overall": r.overall,
|
|
272
|
+
"modularity": r.modularity,
|
|
273
|
+
"acyclicity": r.acyclicity,
|
|
274
|
+
"depth": r.depth,
|
|
275
|
+
"equality": r.equality,
|
|
276
|
+
},
|
|
277
|
+
}
|
|
278
|
+
for r in window
|
|
279
|
+
],
|
|
280
|
+
indent=2,
|
|
281
|
+
sort_keys=True,
|
|
282
|
+
)
|
|
283
|
+
)
|
|
284
|
+
else:
|
|
285
|
+
click.echo(render_trend(rows, last_n=last_n))
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
@main.command()
|
|
289
|
+
def mcp() -> None:
|
|
290
|
+
"""Run archy as an MCP server on stdio for AI agent integration."""
|
|
291
|
+
from archy.mcp import create_server
|
|
292
|
+
|
|
293
|
+
create_server().run()
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _load_graph(path: Path, *, internal_only: bool) -> nx.DiGraph:
|
|
297
|
+
g = build_graph(path)
|
|
298
|
+
if internal_only:
|
|
299
|
+
external = {n for n, d in g.nodes(data=True) if d.get("external")}
|
|
300
|
+
g.remove_nodes_from(external)
|
|
301
|
+
return g
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _format_lines(lines: tuple[int, ...]) -> str:
|
|
305
|
+
label = "lines" if len(lines) > 1 else "line"
|
|
306
|
+
text = ", ".join(str(n) for n in lines) or "?"
|
|
307
|
+
return f"({label}: {text})"
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _gate_against_history(current: Score, history_path: Path) -> dict:
|
|
311
|
+
rows = read_history(history_path)
|
|
312
|
+
if not rows:
|
|
313
|
+
return {"previous": None, "current": current.overall, "delta": None}
|
|
314
|
+
previous = rows[-1]
|
|
315
|
+
return {
|
|
316
|
+
"previous": previous.overall,
|
|
317
|
+
"previous_commit": previous.commit,
|
|
318
|
+
"previous_timestamp": previous.timestamp,
|
|
319
|
+
"current": current.overall,
|
|
320
|
+
"delta": current.overall - previous.overall,
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _gate_to_dict(gate: dict, tolerance: float) -> dict:
|
|
325
|
+
delta = gate["delta"]
|
|
326
|
+
return {
|
|
327
|
+
"previous": gate["previous"],
|
|
328
|
+
"current": gate["current"],
|
|
329
|
+
"delta": delta,
|
|
330
|
+
"tolerance": tolerance,
|
|
331
|
+
"passed": delta is None or delta >= -tolerance,
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _gate_to_text(gate: dict, tolerance: float) -> str:
|
|
336
|
+
if gate["delta"] is None:
|
|
337
|
+
return (
|
|
338
|
+
"# strict: no prior score recorded; nothing to compare. "
|
|
339
|
+
"Pass `--record` to seed the baseline."
|
|
340
|
+
)
|
|
341
|
+
delta = gate["delta"]
|
|
342
|
+
direction = "improved" if delta >= 0 else "dropped"
|
|
343
|
+
verdict = "PASS" if delta >= -tolerance else "FAIL"
|
|
344
|
+
prev_label = (gate.get("previous_commit") or "?")[:7]
|
|
345
|
+
return (
|
|
346
|
+
f"# strict: {verdict} "
|
|
347
|
+
f"{gate['previous']:.3f} ({prev_label}) -> {gate['current']:.3f} "
|
|
348
|
+
f"({direction} {delta:+.3f}, tolerance {tolerance:.3f})"
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _score_to_dict(s: Score) -> dict:
|
|
353
|
+
return {
|
|
354
|
+
"overall": s.overall,
|
|
355
|
+
"components": {
|
|
356
|
+
"modularity": s.modularity,
|
|
357
|
+
"acyclicity": s.acyclicity,
|
|
358
|
+
"depth": s.depth,
|
|
359
|
+
"equality": s.equality,
|
|
360
|
+
},
|
|
361
|
+
"inputs": {
|
|
362
|
+
"module_count": s.inputs.module_count,
|
|
363
|
+
"edge_count": s.inputs.edge_count,
|
|
364
|
+
"cycle_count": s.inputs.cycle_count,
|
|
365
|
+
"max_depth": s.inputs.max_depth,
|
|
366
|
+
"community_count": s.inputs.community_count,
|
|
367
|
+
"raw_modularity": s.inputs.raw_modularity,
|
|
368
|
+
"raw_gini": s.inputs.raw_gini,
|
|
369
|
+
},
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _score_to_text(s: Score) -> str:
|
|
374
|
+
lines = [
|
|
375
|
+
f"# archy score: {s.overall:.3f}",
|
|
376
|
+
f"modularity: {s.modularity:.3f} "
|
|
377
|
+
f"({s.inputs.community_count} communities, raw Q={s.inputs.raw_modularity:.3f})",
|
|
378
|
+
f"acyclicity: {s.acyclicity:.3f} ({s.inputs.cycle_count} cycles)",
|
|
379
|
+
f"depth: {s.depth:.3f} (max depth {s.inputs.max_depth})",
|
|
380
|
+
f"equality: {s.equality:.3f} (Gini={s.inputs.raw_gini:.3f})",
|
|
381
|
+
f"# graph: {s.inputs.module_count} modules, {s.inputs.edge_count} edges",
|
|
382
|
+
]
|
|
383
|
+
return "\n".join(lines)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _violations_to_json(violations: list[Violation]) -> list[dict]:
|
|
387
|
+
return [
|
|
388
|
+
{
|
|
389
|
+
"rule": {"from": v.rule.from_layer, "to": v.rule.to_layer},
|
|
390
|
+
"source": v.source,
|
|
391
|
+
"target": v.target,
|
|
392
|
+
"lines": list(v.lines),
|
|
393
|
+
}
|
|
394
|
+
for v in violations
|
|
395
|
+
]
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _violations_to_text(violations: list[Violation], config_path: Path) -> str:
|
|
399
|
+
if not violations:
|
|
400
|
+
return f"# No layer violations (config: {config_path})."
|
|
401
|
+
lines = [f"# {len(violations)} layer violation(s) (config: {config_path})"]
|
|
402
|
+
current_rule: tuple[str, str] | None = None
|
|
403
|
+
for v in violations:
|
|
404
|
+
rule_pair = (v.rule.from_layer, v.rule.to_layer)
|
|
405
|
+
if rule_pair != current_rule:
|
|
406
|
+
lines.append(f"\n{v.rule.from_layer} -> {v.rule.to_layer} (forbidden):")
|
|
407
|
+
current_rule = rule_pair
|
|
408
|
+
lines.append(f" {v.source} -> {v.target} {_format_lines(v.lines)}")
|
|
409
|
+
return "\n".join(lines)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _cycles_to_json(cycles: list[Cycle]) -> list[dict]:
|
|
413
|
+
return [
|
|
414
|
+
{
|
|
415
|
+
"modules": list(c.modules),
|
|
416
|
+
"edges": [
|
|
417
|
+
{"source": e.source, "target": e.target, "lines": list(e.lines)} for e in c.edges
|
|
418
|
+
],
|
|
419
|
+
}
|
|
420
|
+
for c in cycles
|
|
421
|
+
]
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _cycles_to_text(cycles: list[Cycle], min_size: int) -> str:
|
|
425
|
+
if not cycles:
|
|
426
|
+
return f"# No cycles found (min_size={min_size})."
|
|
427
|
+
lines = [f"# {len(cycles)} cycle(s) found"]
|
|
428
|
+
for c in cycles:
|
|
429
|
+
lines.append(f"\nCycle of {len(c.modules)} module(s):")
|
|
430
|
+
for m in c.modules:
|
|
431
|
+
lines.append(f" - {m}")
|
|
432
|
+
lines.append("Edges:")
|
|
433
|
+
for e in c.edges:
|
|
434
|
+
lines.append(f" {e.source} -> {e.target} {_format_lines(e.lines)}")
|
|
435
|
+
return "\n".join(lines)
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _graph_to_dict(g: nx.DiGraph) -> dict:
|
|
439
|
+
return {
|
|
440
|
+
"root": g.graph.get("root"),
|
|
441
|
+
"parse_errors": list(g.graph.get("parse_errors", ())),
|
|
442
|
+
"nodes": [{"id": n, **d} for n, d in sorted(g.nodes(data=True))],
|
|
443
|
+
"edges": [
|
|
444
|
+
{"source": u, "target": v, **d}
|
|
445
|
+
for u, v, d in sorted(g.edges(data=True), key=lambda e: (e[0], e[1]))
|
|
446
|
+
],
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _graph_to_dot(g: nx.DiGraph) -> str:
|
|
451
|
+
lines = ["digraph imports {", ' rankdir="LR";']
|
|
452
|
+
for n, d in sorted(g.nodes(data=True)):
|
|
453
|
+
style = ' style="dashed" color="gray"' if d.get("external") else ""
|
|
454
|
+
lines.append(f' "{n}"[{style.strip()}];')
|
|
455
|
+
for u, v in sorted(g.edges()):
|
|
456
|
+
lines.append(f' "{u}" -> "{v}";')
|
|
457
|
+
lines.append("}")
|
|
458
|
+
return "\n".join(lines)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _graph_to_text(g: nx.DiGraph) -> str:
|
|
462
|
+
internal = sorted(n for n, d in g.nodes(data=True) if not d.get("external"))
|
|
463
|
+
lines = [f"# {len(internal)} internal module(s), {g.number_of_edges()} import edge(s)"]
|
|
464
|
+
for n in internal:
|
|
465
|
+
lines.append(f"{n}")
|
|
466
|
+
for t in sorted(g.successors(n)):
|
|
467
|
+
marker = "ext" if g.nodes[t].get("external") else "int"
|
|
468
|
+
lines.append(f" -> [{marker}] {t}")
|
|
469
|
+
return "\n".join(lines)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
if __name__ == "__main__": # pragma: no cover
|
|
473
|
+
sys.exit(main())
|
archy/cycles.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Strongly-connected-component cycle detection over an import graph."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
import networkx as nx
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class CycleEdge:
|
|
12
|
+
source: str
|
|
13
|
+
target: str
|
|
14
|
+
lines: tuple[int, ...]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class Cycle:
|
|
19
|
+
modules: tuple[str, ...]
|
|
20
|
+
edges: tuple[CycleEdge, ...]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def find_cycles(graph: nx.DiGraph, *, min_size: int = 2) -> list[Cycle]:
|
|
24
|
+
"""Return cycles in the graph, sorted largest-first then by qualname.
|
|
25
|
+
|
|
26
|
+
A cycle is either a strongly-connected component of size >= min_size or
|
|
27
|
+
a singleton SCC whose only node has a self-edge. Self-loops are always
|
|
28
|
+
real cycles (a module importing itself), so we report them regardless
|
|
29
|
+
of min_size; the gate only suppresses incidental singletons (DAG nodes
|
|
30
|
+
that happen to be their own SCC because they have no inbound mutual
|
|
31
|
+
relationship).
|
|
32
|
+
"""
|
|
33
|
+
cycles: list[Cycle] = []
|
|
34
|
+
for component in nx.strongly_connected_components(graph):
|
|
35
|
+
size = len(component)
|
|
36
|
+
if size == 1:
|
|
37
|
+
node = next(iter(component))
|
|
38
|
+
if not graph.has_edge(node, node):
|
|
39
|
+
continue
|
|
40
|
+
elif size < min_size:
|
|
41
|
+
continue
|
|
42
|
+
modules = tuple(sorted(component))
|
|
43
|
+
component_set = set(component)
|
|
44
|
+
edges: list[CycleEdge] = []
|
|
45
|
+
for u in modules:
|
|
46
|
+
for v in graph.successors(u):
|
|
47
|
+
if v in component_set:
|
|
48
|
+
data = graph[u][v]
|
|
49
|
+
edges.append(
|
|
50
|
+
CycleEdge(
|
|
51
|
+
source=u,
|
|
52
|
+
target=v,
|
|
53
|
+
lines=tuple(data.get("lines", ())),
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
edges.sort(key=lambda e: (e.source, e.target))
|
|
57
|
+
cycles.append(Cycle(modules=modules, edges=tuple(edges)))
|
|
58
|
+
cycles.sort(key=lambda c: (-len(c.modules), c.modules[0]))
|
|
59
|
+
return cycles
|