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 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