graphyos 0.1.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.
- graphy/__init__.py +38 -0
- graphy/__main__.py +3 -0
- graphy/_portable_flock.py +19 -0
- graphy/_shared.py +49 -0
- graphy/adapters/__init__.py +12 -0
- graphy/adapters/outline.py +76 -0
- graphy/adapters/python_ast.py +349 -0
- graphy/adapters/typescript_ast.py +561 -0
- graphy/arms.py +278 -0
- graphy/augment_registry.py +266 -0
- graphy/bridge.py +264 -0
- graphy/cartograph.py +306 -0
- graphy/cli.py +1657 -0
- graphy/container.py +219 -0
- graphy/converge.py +330 -0
- graphy/cross_substrate.py +746 -0
- graphy/doors.py +289 -0
- graphy/draw.py +228 -0
- graphy/fanout.py +609 -0
- graphy/farm.py +354 -0
- graphy/federated_store.py +695 -0
- graphy/index.py +333 -0
- graphy/index_estate.py +139 -0
- graphy/inventory.py +218 -0
- graphy/ir.py +367 -0
- graphy/journal.py +735 -0
- graphy/lightning/__init__.py +20 -0
- graphy/lightning/__main__.py +3 -0
- graphy/lightning/archive.py +23 -0
- graphy/lightning/blitz_hunt.py +258 -0
- graphy/lightning/block_blast.py +181 -0
- graphy/lightning/bloodhound.py +217 -0
- graphy/lightning/bolt_cli.py +190 -0
- graphy/lightning/code_hit.py +35 -0
- graphy/lightning/context_strike.py +267 -0
- graphy/lightning/extras/__init__.py +0 -0
- graphy/lightning/extras/stats.py +34 -0
- graphy/lightning/extras/storm_cooccurrence.py +387 -0
- graphy/lightning/files_from_envelope.py +54 -0
- graphy/lightning/formatters.py +74 -0
- graphy/lightning/hit_kind.py +70 -0
- graphy/lightning/log_blast.py +62 -0
- graphy/lightning/models.py +154 -0
- graphy/lightning/pattern_splinter.py +70 -0
- graphy/lightning/prose_blast.py +41 -0
- graphy/lightning/pseudo_ast/__init__.py +12 -0
- graphy/lightning/pseudo_ast/blocks.py +392 -0
- graphy/lightning/pseudo_ast/brace.py +224 -0
- graphy/lightning/pseudo_ast/svelte.py +70 -0
- graphy/lightning/reseed_graph.py +400 -0
- graphy/lightning/ripgrep.py +222 -0
- graphy/lightning/source_kind.py +83 -0
- graphy/mcp.py +230 -0
- graphy/mesh_federation_gate.py +597 -0
- graphy/native_json_graph_ir.py +370 -0
- graphy/parity.py +240 -0
- graphy/pillars.py +382 -0
- graphy/provision.py +75 -0
- graphy/query.py +483 -0
- graphy/refresh.py +410 -0
- graphy/release.py +100 -0
- graphy/reseed.py +302 -0
- graphy/session_tail.py +302 -0
- graphy/shell/README.md +46 -0
- graphy/shell/__init__.py +5 -0
- graphy/shell/claude/GRAPHY.md +25 -0
- graphy/shell/claude/settings.json +18 -0
- graphy/shell/gate.py +79 -0
- graphy/shell/hooks/before_edit.sh +12 -0
- graphy/shell/hooks/session_end.sh +9 -0
- graphy/shell/hooks/session_start.sh +8 -0
- graphy/shell/install.py +70 -0
- graphy/showcase.py +186 -0
- graphy/smash.py +490 -0
- graphy/sugiyama.py +1243 -0
- graphy/tenant.py +121 -0
- graphy/traversal.py +327 -0
- graphyos-0.1.0.dist-info/METADATA +289 -0
- graphyos-0.1.0.dist-info/RECORD +84 -0
- graphyos-0.1.0.dist-info/WHEEL +5 -0
- graphyos-0.1.0.dist-info/entry_points.txt +2 -0
- graphyos-0.1.0.dist-info/licenses/LICENSE +202 -0
- graphyos-0.1.0.dist-info/licenses/NOTICE +9 -0
- graphyos-0.1.0.dist-info/top_level.txt +1 -0
graphy/__init__.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from graphy.ir import (
|
|
5
|
+
SCHEMA_VERSION,
|
|
6
|
+
Edge,
|
|
7
|
+
Evidence,
|
|
8
|
+
IRError,
|
|
9
|
+
Node,
|
|
10
|
+
Provenance,
|
|
11
|
+
Vocabulary,
|
|
12
|
+
PYTHON_AST_VOCABULARY,
|
|
13
|
+
validate_graph,
|
|
14
|
+
)
|
|
15
|
+
from graphy.parity import Golden, Harness, ParityError, load_golden
|
|
16
|
+
from graphy.tenant import REQUIRED_FIELDS, Tenant, TenantError
|
|
17
|
+
|
|
18
|
+
__version__ = "0.1.0"
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"Golden",
|
|
22
|
+
"Harness",
|
|
23
|
+
"ParityError",
|
|
24
|
+
"load_golden",
|
|
25
|
+
"SCHEMA_VERSION",
|
|
26
|
+
"IRError",
|
|
27
|
+
"Node",
|
|
28
|
+
"Edge",
|
|
29
|
+
"Provenance",
|
|
30
|
+
"Evidence",
|
|
31
|
+
"validate_graph",
|
|
32
|
+
"Vocabulary",
|
|
33
|
+
"PYTHON_AST_VOCABULARY",
|
|
34
|
+
"Tenant",
|
|
35
|
+
"TenantError",
|
|
36
|
+
"REQUIRED_FIELDS",
|
|
37
|
+
"__version__",
|
|
38
|
+
]
|
graphy/__main__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
try:
|
|
4
|
+
import fcntl # type: ignore # noqa: F401 — re-exported on POSIX
|
|
5
|
+
HAVE_FCNTL = True
|
|
6
|
+
except ImportError:
|
|
7
|
+
HAVE_FCNTL = False
|
|
8
|
+
|
|
9
|
+
class _NoFcntl:
|
|
10
|
+
LOCK_EX = 2
|
|
11
|
+
LOCK_SH = 1
|
|
12
|
+
LOCK_UN = 8
|
|
13
|
+
LOCK_NB = 4
|
|
14
|
+
|
|
15
|
+
@staticmethod
|
|
16
|
+
def flock(*_args, **_kwargs) -> None:
|
|
17
|
+
return None
|
|
18
|
+
|
|
19
|
+
fcntl = _NoFcntl() # type: ignore
|
graphy/_shared.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import math
|
|
5
|
+
|
|
6
|
+
SUPPORT_PRIOR = 5
|
|
7
|
+
|
|
8
|
+
DEFAULT_EXCLUDE = ("tests/", "test/", "docs_src/", "docs/", "scripts/", "examples/")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def nonneg_int(v) -> int:
|
|
12
|
+
import argparse
|
|
13
|
+
i = int(v)
|
|
14
|
+
if i < 0:
|
|
15
|
+
raise argparse.ArgumentTypeError("--support-prior must be >= 0")
|
|
16
|
+
return i
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def credibility(support: int, prior: int = SUPPORT_PRIOR) -> float:
|
|
20
|
+
if support <= 0:
|
|
21
|
+
return 0.0
|
|
22
|
+
prior = max(0, prior)
|
|
23
|
+
return support / (support + prior)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def salience(lift: float, support: int, recency: float, prior: int = SUPPORT_PRIOR) -> float:
|
|
27
|
+
return lift * credibility(support, prior) * recency
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def lift(support: int, n_a: int, n_b: int, n_commits: int) -> float:
|
|
31
|
+
return (support * n_commits) / (n_a * n_b) if (n_a and n_b and n_commits) else 0.0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def resolve_excludes(user_exclude: str, use_defaults: bool = True) -> tuple[str, ...]:
|
|
35
|
+
user = tuple(x.strip() for x in user_exclude.split(",") if x.strip())
|
|
36
|
+
return (DEFAULT_EXCLUDE + user) if use_defaults else user
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
AST_STRUCT_SALIENCE = 1.0
|
|
40
|
+
AST_WIRE_SALIENCE = 5.0
|
|
41
|
+
AST_WIRE_RELATIONS = {"serves", "fetches", "fetches_static", "navigates"}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _ast_edge_salience(relation: str) -> float:
|
|
45
|
+
return AST_WIRE_SALIENCE if relation in AST_WIRE_RELATIONS else AST_STRUCT_SALIENCE
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _shim_adj_for_activate(unified_adj: dict) -> dict:
|
|
49
|
+
return {k: [(n, s, w) for (n, s, w, _r) in vs] for k, vs in unified_adj.items()}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from graphy.adapters.outline import OUTLINE_VOCABULARY, build_ir as build_outline
|
|
5
|
+
from graphy.adapters.python_ast import PYTHON_AST_VOCABULARY, build_ir as build_python_ast
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"PYTHON_AST_VOCABULARY",
|
|
9
|
+
"OUTLINE_VOCABULARY",
|
|
10
|
+
"build_python_ast",
|
|
11
|
+
"build_outline",
|
|
12
|
+
]
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from graphy.ir import Vocabulary
|
|
9
|
+
|
|
10
|
+
__all__ = ["build_ir", "OUTLINE_VOCABULARY"]
|
|
11
|
+
|
|
12
|
+
OUTLINE_VOCABULARY = Vocabulary(
|
|
13
|
+
node_types=("outline_line",),
|
|
14
|
+
edge_types=("contains",),
|
|
15
|
+
producer="outline",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
_BULLET = re.compile(r"^[-*+]\s+(.*)$")
|
|
19
|
+
_SLUG = re.compile(r"[^a-z0-9]+")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _NodeRecords(dict):
|
|
23
|
+
|
|
24
|
+
def __iter__(self): # type: ignore[override]
|
|
25
|
+
return iter(self.values())
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _slugify(label: str) -> str:
|
|
29
|
+
return (_SLUG.sub("_", label.lower()).strip("_")[:60]) or "line"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def parse_outline(text: str, doc: str) -> list[dict]:
|
|
33
|
+
records: list[dict] = []
|
|
34
|
+
stack: list[tuple[int, str]] = []
|
|
35
|
+
seen: dict[str, int] = {}
|
|
36
|
+
for raw in text.splitlines():
|
|
37
|
+
if not raw.strip():
|
|
38
|
+
continue
|
|
39
|
+
body = raw.lstrip(" \t")
|
|
40
|
+
indent = len(raw[: len(raw) - len(body)].replace("\t", " "))
|
|
41
|
+
label = body.rstrip()
|
|
42
|
+
m = _BULLET.match(label)
|
|
43
|
+
if m:
|
|
44
|
+
label = m.group(1)
|
|
45
|
+
slug = _slugify(label)
|
|
46
|
+
n = seen.get(slug, 0) + 1
|
|
47
|
+
seen[slug] = n
|
|
48
|
+
if n > 1:
|
|
49
|
+
slug = f"{slug}~{n}"
|
|
50
|
+
nid = f"outline://{doc}/{slug}"
|
|
51
|
+
while stack and stack[-1][0] >= indent:
|
|
52
|
+
stack.pop()
|
|
53
|
+
parent = stack[-1][1] if stack else None
|
|
54
|
+
stack.append((indent, nid))
|
|
55
|
+
records.append({
|
|
56
|
+
"kind": "node", "node_type": "outline_line", "id": nid,
|
|
57
|
+
"label": label, "doc": doc,
|
|
58
|
+
"dotted": f"outline.{doc}.{slug}",
|
|
59
|
+
})
|
|
60
|
+
if parent:
|
|
61
|
+
records.append({"kind": "edge", "edge_type": "contains", "src": parent, "dst": nid})
|
|
62
|
+
return records
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def build_ir(outline_file: str | Path) -> tuple[_NodeRecords, list[dict]]:
|
|
66
|
+
path = Path(outline_file)
|
|
67
|
+
text = path.read_text(encoding="utf-8")
|
|
68
|
+
doc = _slugify(path.stem)
|
|
69
|
+
nodes: _NodeRecords = _NodeRecords()
|
|
70
|
+
edges: list[dict] = []
|
|
71
|
+
for rec in parse_outline(text, doc):
|
|
72
|
+
if rec.get("kind") == "node":
|
|
73
|
+
nodes[rec["id"]] = rec
|
|
74
|
+
else:
|
|
75
|
+
edges.append(rec)
|
|
76
|
+
return nodes, edges
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import ast
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Iterator
|
|
8
|
+
|
|
9
|
+
from graphy.ir import PYTHON_AST_VOCABULARY
|
|
10
|
+
|
|
11
|
+
__all__ = ["build_ir", "walk_files", "is_package_dir", "PYTHON_AST_VOCABULARY"]
|
|
12
|
+
|
|
13
|
+
_DEFAULT_EXCLUDES = (
|
|
14
|
+
"__pycache__", ".git", ".venv", "venv", "node_modules",
|
|
15
|
+
"build", "dist", ".pytest_cache", ".mypy_cache", ".ruff_cache",
|
|
16
|
+
)
|
|
17
|
+
_PACKAGE_EXCLUDES = ("__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _NodeRecords(dict):
|
|
21
|
+
|
|
22
|
+
def __iter__(self): # type: ignore[override]
|
|
23
|
+
return iter(self.values())
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _dotted_for(file: Path, root: Path, package: str) -> str:
|
|
29
|
+
rel = file.relative_to(root)
|
|
30
|
+
parts = list(rel.parts)
|
|
31
|
+
if parts[-1] == "__init__.py":
|
|
32
|
+
parts = parts[:-1]
|
|
33
|
+
else:
|
|
34
|
+
parts[-1] = parts[-1].removesuffix(".pyi").removesuffix(".py")
|
|
35
|
+
if parts == [package]:
|
|
36
|
+
return package
|
|
37
|
+
return ".".join([package] + parts) if parts else package
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _scheme_of(dotted: str) -> str:
|
|
41
|
+
return dotted.split(".", 1)[0] if dotted else "unknown"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _node_id(kind: str, dotted: str) -> str:
|
|
45
|
+
return f"{_scheme_of(dotted)}://{kind}/{dotted}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _expr_repr(node: ast.AST) -> str:
|
|
51
|
+
if isinstance(node, ast.Name):
|
|
52
|
+
return node.id
|
|
53
|
+
if isinstance(node, ast.Attribute):
|
|
54
|
+
return f"{_expr_repr(node.value)}.{node.attr}"
|
|
55
|
+
if isinstance(node, ast.Call):
|
|
56
|
+
return _expr_repr(node.func) + "(...)"
|
|
57
|
+
try:
|
|
58
|
+
return ast.unparse(node)[:120]
|
|
59
|
+
except Exception:
|
|
60
|
+
return f"<{type(node).__name__}>"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _calls_in(func: ast.AST) -> Iterator[tuple[str, int]]:
|
|
64
|
+
for node in ast.walk(func):
|
|
65
|
+
if isinstance(node, ast.Call):
|
|
66
|
+
yield _expr_repr(node.func), getattr(node, "lineno", 0)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _resolve_module_dst(raw: str, package: str, local_packages: frozenset[str]) -> str:
|
|
72
|
+
first = raw.split(".", 1)[0]
|
|
73
|
+
if first == package:
|
|
74
|
+
return raw
|
|
75
|
+
if first in local_packages:
|
|
76
|
+
return f"{package}.{raw}"
|
|
77
|
+
return raw
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _emit_import_edges(
|
|
81
|
+
tree: ast.AST,
|
|
82
|
+
module_id: str,
|
|
83
|
+
module_dotted: str,
|
|
84
|
+
package: str,
|
|
85
|
+
local_packages: frozenset[str],
|
|
86
|
+
is_package: bool = False,
|
|
87
|
+
) -> Iterator[dict]:
|
|
88
|
+
for node in ast.walk(tree):
|
|
89
|
+
if isinstance(node, ast.Import):
|
|
90
|
+
for alias in node.names:
|
|
91
|
+
yield {
|
|
92
|
+
"kind": "edge",
|
|
93
|
+
"edge_type": "imports",
|
|
94
|
+
"src": module_id,
|
|
95
|
+
"dst": _node_id(
|
|
96
|
+
"module",
|
|
97
|
+
_resolve_module_dst(alias.name, package, local_packages),
|
|
98
|
+
),
|
|
99
|
+
"alias": alias.asname,
|
|
100
|
+
"line": node.lineno,
|
|
101
|
+
}
|
|
102
|
+
elif isinstance(node, ast.ImportFrom):
|
|
103
|
+
mod = node.module or ""
|
|
104
|
+
if node.level:
|
|
105
|
+
drop = node.level - 1 if is_package else node.level
|
|
106
|
+
base = module_dotted.split(".")
|
|
107
|
+
base = base[: max(0, len(base) - drop)] if drop else base
|
|
108
|
+
pieces = [p for p in base + ([mod] if mod else []) if p]
|
|
109
|
+
mod = ".".join(pieces)
|
|
110
|
+
elif mod:
|
|
111
|
+
mod = _resolve_module_dst(mod, package, local_packages)
|
|
112
|
+
for alias in node.names:
|
|
113
|
+
dst = _node_id("module", mod) if mod else _node_id("module", alias.name)
|
|
114
|
+
yield {
|
|
115
|
+
"kind": "edge",
|
|
116
|
+
"edge_type": "imports",
|
|
117
|
+
"src": module_id,
|
|
118
|
+
"dst": dst,
|
|
119
|
+
"name": alias.name,
|
|
120
|
+
"alias": alias.asname,
|
|
121
|
+
"line": node.lineno,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
_COMPOUND = (ast.If, ast.Try, ast.With, ast.AsyncWith, ast.For, ast.AsyncFor, ast.While)
|
|
126
|
+
if hasattr(ast, "TryStar"):
|
|
127
|
+
_COMPOUND = _COMPOUND + (ast.TryStar,)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _defs_in(body: list) -> Iterator[ast.AST]:
|
|
131
|
+
"""The def and class statements a body defines, descending through the compound statements a
|
|
132
|
+
module or class guards them with — ``if``/``else``, ``try``/``except``/``finally``, ``with``,
|
|
133
|
+
``for``, ``while`` — and never into a function body. A version-gated backport
|
|
134
|
+
(``if hasattr(typing, X): ... else: class X``) defines X on the module; the module is its parent."""
|
|
135
|
+
for stmt in body:
|
|
136
|
+
if isinstance(stmt, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
137
|
+
yield stmt
|
|
138
|
+
elif isinstance(stmt, _COMPOUND):
|
|
139
|
+
yield from _defs_in(stmt.body)
|
|
140
|
+
yield from _defs_in(getattr(stmt, "orelse", []) or [])
|
|
141
|
+
for handler in getattr(stmt, "handlers", []) or []:
|
|
142
|
+
yield from _defs_in(handler.body)
|
|
143
|
+
yield from _defs_in(getattr(stmt, "finalbody", []) or [])
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _walk_stmt(
|
|
147
|
+
stmt: ast.AST,
|
|
148
|
+
parent_id: str,
|
|
149
|
+
parent_dotted: str,
|
|
150
|
+
file_rel: str,
|
|
151
|
+
container_class: str | None = None,
|
|
152
|
+
package: str = "",
|
|
153
|
+
local_packages: frozenset[str] = frozenset(),
|
|
154
|
+
) -> Iterator[dict]:
|
|
155
|
+
if isinstance(stmt, ast.ClassDef):
|
|
156
|
+
class_dotted = f"{parent_dotted}.{stmt.name}"
|
|
157
|
+
class_id = _node_id("class", class_dotted)
|
|
158
|
+
yield {
|
|
159
|
+
"kind": "node",
|
|
160
|
+
"node_type": "class",
|
|
161
|
+
"id": class_id,
|
|
162
|
+
"name": stmt.name,
|
|
163
|
+
"dotted": class_dotted,
|
|
164
|
+
"file": file_rel,
|
|
165
|
+
"line": stmt.lineno,
|
|
166
|
+
"docstring": (ast.get_docstring(stmt) or "")[:200],
|
|
167
|
+
}
|
|
168
|
+
yield {"kind": "edge", "edge_type": "contains", "src": parent_id, "dst": class_id}
|
|
169
|
+
for base in stmt.bases:
|
|
170
|
+
yield {
|
|
171
|
+
"kind": "edge",
|
|
172
|
+
"edge_type": "inherits",
|
|
173
|
+
"src": class_id,
|
|
174
|
+
"dst_repr": _expr_repr(base),
|
|
175
|
+
"line": stmt.lineno,
|
|
176
|
+
}
|
|
177
|
+
for dec in stmt.decorator_list:
|
|
178
|
+
yield {
|
|
179
|
+
"kind": "edge",
|
|
180
|
+
"edge_type": "decorates",
|
|
181
|
+
"src_repr": _expr_repr(dec),
|
|
182
|
+
"dst": class_id,
|
|
183
|
+
"line": stmt.lineno,
|
|
184
|
+
}
|
|
185
|
+
for sub in _defs_in(stmt.body):
|
|
186
|
+
yield from _walk_stmt(sub, class_id, class_dotted, file_rel,
|
|
187
|
+
container_class=stmt.name, package=package,
|
|
188
|
+
local_packages=local_packages)
|
|
189
|
+
elif isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
190
|
+
kind = "method" if container_class else "func"
|
|
191
|
+
func_dotted = f"{parent_dotted}.{stmt.name}"
|
|
192
|
+
func_id = _node_id(kind, func_dotted)
|
|
193
|
+
yield {
|
|
194
|
+
"kind": "node",
|
|
195
|
+
"node_type": kind,
|
|
196
|
+
"id": func_id,
|
|
197
|
+
"name": stmt.name,
|
|
198
|
+
"dotted": func_dotted,
|
|
199
|
+
"file": file_rel,
|
|
200
|
+
"line": stmt.lineno,
|
|
201
|
+
"is_async": isinstance(stmt, ast.AsyncFunctionDef),
|
|
202
|
+
"args": [a.arg for a in stmt.args.args],
|
|
203
|
+
"returns": _expr_repr(stmt.returns) if stmt.returns else None,
|
|
204
|
+
"docstring": (ast.get_docstring(stmt) or "")[:200],
|
|
205
|
+
"container_class": container_class,
|
|
206
|
+
}
|
|
207
|
+
yield {"kind": "edge", "edge_type": "contains", "src": parent_id, "dst": func_id}
|
|
208
|
+
for dec in stmt.decorator_list:
|
|
209
|
+
yield {
|
|
210
|
+
"kind": "edge",
|
|
211
|
+
"edge_type": "decorates",
|
|
212
|
+
"src_repr": _expr_repr(dec),
|
|
213
|
+
"dst": func_id,
|
|
214
|
+
"line": stmt.lineno,
|
|
215
|
+
}
|
|
216
|
+
for call_repr, line in _calls_in(stmt):
|
|
217
|
+
yield {
|
|
218
|
+
"kind": "edge",
|
|
219
|
+
"edge_type": "calls",
|
|
220
|
+
"src": func_id,
|
|
221
|
+
"dst_repr": call_repr,
|
|
222
|
+
"line": line,
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _is_excluded(file: Path, exclude_patterns: tuple[str, ...], root: Path) -> bool:
|
|
227
|
+
try:
|
|
228
|
+
parts = set(file.relative_to(root).parts)
|
|
229
|
+
except ValueError:
|
|
230
|
+
parts = set(file.parts)
|
|
231
|
+
return any(p in parts for p in exclude_patterns)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _local_package_names(root: Path) -> frozenset[str]:
|
|
235
|
+
names: set[str] = set()
|
|
236
|
+
for entry in root.iterdir():
|
|
237
|
+
if entry.name.startswith(".") or entry.name in _DEFAULT_EXCLUDES:
|
|
238
|
+
continue
|
|
239
|
+
if entry.is_dir() and next(entry.rglob("*.py"), None) is not None:
|
|
240
|
+
names.add(entry.name)
|
|
241
|
+
elif entry.is_file() and entry.suffix == ".py":
|
|
242
|
+
names.add(entry.stem)
|
|
243
|
+
return frozenset(names)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _emit_raw_records_for_file(
|
|
247
|
+
file: Path,
|
|
248
|
+
root: Path,
|
|
249
|
+
package: str,
|
|
250
|
+
local_packages: frozenset[str] = frozenset(),
|
|
251
|
+
rel_base: Path | None = None,
|
|
252
|
+
) -> Iterator[dict]:
|
|
253
|
+
try:
|
|
254
|
+
src = file.read_text(encoding="utf-8")
|
|
255
|
+
except (UnicodeDecodeError, OSError):
|
|
256
|
+
return
|
|
257
|
+
try:
|
|
258
|
+
tree = ast.parse(src)
|
|
259
|
+
except SyntaxError:
|
|
260
|
+
return
|
|
261
|
+
|
|
262
|
+
module_dotted = _dotted_for(file, root, package)
|
|
263
|
+
module_id = _node_id("module", module_dotted)
|
|
264
|
+
file_rel = str(file.relative_to(rel_base if rel_base is not None else root.parent))
|
|
265
|
+
|
|
266
|
+
yield {
|
|
267
|
+
"kind": "node",
|
|
268
|
+
"node_type": "module",
|
|
269
|
+
"id": module_id,
|
|
270
|
+
"dotted": module_dotted,
|
|
271
|
+
"file": file_rel,
|
|
272
|
+
"loc": len(src.splitlines()),
|
|
273
|
+
"docstring": (ast.get_docstring(tree) or "")[:200],
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
yield from _emit_import_edges(tree, module_id, module_dotted, package,
|
|
277
|
+
local_packages,
|
|
278
|
+
is_package=file.name == "__init__.py")
|
|
279
|
+
|
|
280
|
+
for stmt in _defs_in(tree.body):
|
|
281
|
+
yield from _walk_stmt(stmt, module_id, module_dotted, file_rel,
|
|
282
|
+
package=package, local_packages=local_packages)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
TEST_FILE = re.compile(r"(^|/)(tests?|testing)/|(^|/)test_[^/]*\.py$|_tests?\.py$|(^|/)conftest\.py$")
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _emit_records_for_file(
|
|
292
|
+
file: Path,
|
|
293
|
+
root: Path,
|
|
294
|
+
package: str,
|
|
295
|
+
local_packages: frozenset[str] = frozenset(),
|
|
296
|
+
rel_base: Path | None = None,
|
|
297
|
+
) -> Iterator[dict]:
|
|
298
|
+
"""Every node carries ``module`` (the dotted module that holds it) and, when the producer's
|
|
299
|
+
own rule says the file is a test, ``role: test``. The consumers read those fields; no consumer
|
|
300
|
+
derives a module from a ``.py`` path or decides what a test is."""
|
|
301
|
+
module_dotted: str | None = None
|
|
302
|
+
for rec in _emit_raw_records_for_file(file, root, package, local_packages, rel_base):
|
|
303
|
+
if rec.get("kind") == "node":
|
|
304
|
+
if rec["node_type"] == "module":
|
|
305
|
+
module_dotted = rec["dotted"]
|
|
306
|
+
rec["module"] = module_dotted if module_dotted is not None else rec["dotted"]
|
|
307
|
+
if TEST_FILE.search(str(rec.get("file") or "")):
|
|
308
|
+
rec["role"] = "test"
|
|
309
|
+
yield rec
|
|
310
|
+
|
|
311
|
+
def is_package_dir(corpus_dir: str | Path) -> bool:
|
|
312
|
+
"""A corpus that carries its own ``__init__.py`` is one importable package: its name is the
|
|
313
|
+
scheme, its siblings are other packages. Anything else is a repo root whose top-level
|
|
314
|
+
directories are local packages that resolve under the root's name."""
|
|
315
|
+
return (Path(corpus_dir) / "__init__.py").is_file()
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def walk_files(corpus_dir: str | Path) -> list[Path]:
|
|
319
|
+
"""The exact files a corpus is minted from, in mint order. A package directory skips only
|
|
320
|
+
caches; a repo root also skips build and vendored trees; a ``.py`` file is itself."""
|
|
321
|
+
root = Path(corpus_dir).resolve()
|
|
322
|
+
if root.is_file():
|
|
323
|
+
return [root] if root.suffix == ".py" else []
|
|
324
|
+
if not root.is_dir():
|
|
325
|
+
raise RuntimeError(f"path is not a directory or a .py file: {root}")
|
|
326
|
+
excludes = _PACKAGE_EXCLUDES if is_package_dir(root) else _DEFAULT_EXCLUDES
|
|
327
|
+
return [f for f in sorted(root.rglob("*.py")) if not _is_excluded(f, excludes, root)]
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def build_ir(corpus_dir: str | Path) -> tuple[_NodeRecords, list[dict]]:
|
|
331
|
+
"""Mint the IR of one corpus. A directory is a package (``__init__.py`` present) or a repo
|
|
332
|
+
root; a single ``.py`` file is a one-module distribution (``typing_extensions.py``) whose
|
|
333
|
+
scheme is the file's stem. Files are named relative to the corpus's parent."""
|
|
334
|
+
root = Path(corpus_dir).resolve()
|
|
335
|
+
nodes: _NodeRecords = _NodeRecords()
|
|
336
|
+
edges: list[dict] = []
|
|
337
|
+
if root.is_file() and root.suffix == ".py":
|
|
338
|
+
package, base, local_packages = root.stem, root.parent, frozenset()
|
|
339
|
+
else:
|
|
340
|
+
package, base = root.name, root
|
|
341
|
+
local_packages = frozenset() if is_package_dir(root) else _local_package_names(root)
|
|
342
|
+
for file in walk_files(root):
|
|
343
|
+
for rec in _emit_records_for_file(file, base, package, local_packages=local_packages,
|
|
344
|
+
rel_base=base.parent if root.is_dir() else base):
|
|
345
|
+
if rec.get("kind") == "node":
|
|
346
|
+
nodes[rec["id"]] = rec
|
|
347
|
+
else:
|
|
348
|
+
edges.append(rec)
|
|
349
|
+
return nodes, edges
|