ctxgraph-code 0.1.3__py3-none-any.whl → 0.2.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.
- ctxgraph_code/analyzers/python/importer.py +76 -57
- ctxgraph_code/analyzers/python/semantic.py +14 -6
- ctxgraph_code/analyzers/python/symbols.py +108 -102
- ctxgraph_code/cli.py +399 -112
- ctxgraph_code/config/global_paths.py +16 -0
- ctxgraph_code/config/settings.py +9 -5
- ctxgraph_code/exclude/patterns.py +2 -0
- ctxgraph_code/graph/builder.py +160 -34
- ctxgraph_code/graph/models.py +33 -10
- {ctxgraph_code-0.1.3.dist-info → ctxgraph_code-0.2.1.dist-info}/METADATA +1 -1
- {ctxgraph_code-0.1.3.dist-info → ctxgraph_code-0.2.1.dist-info}/RECORD +14 -13
- {ctxgraph_code-0.1.3.dist-info → ctxgraph_code-0.2.1.dist-info}/WHEEL +0 -0
- {ctxgraph_code-0.1.3.dist-info → ctxgraph_code-0.2.1.dist-info}/entry_points.txt +0 -0
- {ctxgraph_code-0.1.3.dist-info → ctxgraph_code-0.2.1.dist-info}/top_level.txt +0 -0
|
@@ -4,90 +4,99 @@ import ast
|
|
|
4
4
|
from pathlib import Path
|
|
5
5
|
from typing import Optional
|
|
6
6
|
|
|
7
|
-
from ctxgraph_code.graph.models import
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
def analyze_imports(
|
|
11
|
-
|
|
7
|
+
from ctxgraph_code.graph.models import Graph
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def analyze_imports(
|
|
11
|
+
file_path: Path,
|
|
12
|
+
root_path: Path,
|
|
13
|
+
*,
|
|
14
|
+
tree: Optional[ast.AST] = None,
|
|
15
|
+
path_index: Optional[set[str]] = None,
|
|
16
|
+
) -> Graph:
|
|
17
|
+
# Result containers (not full Graph — caller merges)
|
|
18
|
+
nodes: list[dict] = []
|
|
19
|
+
edges: list[dict] = []
|
|
12
20
|
rel_path = _relative_path(file_path, root_path)
|
|
13
21
|
file_node_id = f"file:{rel_path}"
|
|
14
22
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
return graph
|
|
23
|
+
nodes.append(dict(
|
|
24
|
+
id=file_node_id,
|
|
25
|
+
type="file",
|
|
26
|
+
name=file_path.name,
|
|
27
|
+
path=rel_path,
|
|
28
|
+
summary=None,
|
|
29
|
+
importance=0.5,
|
|
30
|
+
size_bytes=file_path.stat().st_size,
|
|
31
|
+
))
|
|
32
|
+
|
|
33
|
+
if tree is None:
|
|
34
|
+
try:
|
|
35
|
+
tree = ast.parse(file_path.read_text(encoding="utf-8", errors="replace"))
|
|
36
|
+
except SyntaxError:
|
|
37
|
+
return Graph.from_batch(nodes, edges)
|
|
31
38
|
|
|
32
39
|
for node in ast.walk(tree):
|
|
33
40
|
if isinstance(node, ast.Import):
|
|
34
41
|
for alias in node.names:
|
|
35
|
-
target = _resolve_import_target(alias.name, root_path)
|
|
42
|
+
target = _resolve_import_target(alias.name, root_path, path_index)
|
|
36
43
|
if target:
|
|
37
|
-
_add_import_edge(
|
|
44
|
+
_add_import_edge(nodes, edges, rel_path, file_node_id, target, alias.name)
|
|
38
45
|
|
|
39
46
|
elif isinstance(node, ast.ImportFrom):
|
|
40
47
|
if node.module is None:
|
|
41
48
|
continue
|
|
42
49
|
if node.level and node.level > 0:
|
|
43
50
|
target = _resolve_relative_import(
|
|
44
|
-
node.module, node.level, rel_path, root_path
|
|
51
|
+
node.module, node.level, rel_path, root_path, path_index
|
|
45
52
|
)
|
|
46
53
|
else:
|
|
47
|
-
target = _resolve_import_target(node.module, root_path)
|
|
54
|
+
target = _resolve_import_target(node.module, root_path, path_index)
|
|
48
55
|
|
|
49
56
|
if target:
|
|
50
57
|
for alias in node.names:
|
|
51
58
|
symbol_name = alias.asname or alias.name
|
|
52
59
|
edge_label = f"{node.module}.{alias.name}"
|
|
53
60
|
_add_import_edge(
|
|
54
|
-
|
|
61
|
+
nodes, edges, rel_path, file_node_id, target, edge_label
|
|
55
62
|
)
|
|
56
63
|
|
|
57
|
-
return
|
|
64
|
+
return Graph.from_batch(nodes, edges)
|
|
58
65
|
|
|
59
66
|
|
|
60
67
|
def _add_import_edge(
|
|
61
|
-
|
|
68
|
+
nodes: list[dict],
|
|
69
|
+
edges: list[dict],
|
|
62
70
|
source_rel: str,
|
|
63
71
|
source_id: str,
|
|
64
72
|
target_rel: str,
|
|
65
73
|
label: str,
|
|
66
74
|
):
|
|
67
75
|
target_id = f"file:{target_rel}"
|
|
68
|
-
if
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
76
|
+
# Check if target node already in our batch list
|
|
77
|
+
if not any(n["id"] == target_id for n in nodes):
|
|
78
|
+
nodes.append(dict(
|
|
79
|
+
id=target_id,
|
|
80
|
+
type="file",
|
|
81
|
+
name=Path(target_rel).name,
|
|
82
|
+
path=target_rel,
|
|
83
|
+
summary=None,
|
|
84
|
+
importance=0.3,
|
|
85
|
+
size_bytes=0,
|
|
86
|
+
))
|
|
87
|
+
edges.append(dict(
|
|
88
|
+
source_id=source_id,
|
|
89
|
+
target_id=target_id,
|
|
90
|
+
relation="imports",
|
|
91
|
+
weight=1.0,
|
|
92
|
+
))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _resolve_import_target(
|
|
96
|
+
module_name: str,
|
|
97
|
+
root_path: Path,
|
|
98
|
+
path_index: Optional[set[str]] = None,
|
|
99
|
+
) -> Optional[str]:
|
|
91
100
|
package_path = module_name.replace(".", "/")
|
|
92
101
|
root_name = root_path.name
|
|
93
102
|
extensions = [".py", ".pyw"]
|
|
@@ -100,16 +109,20 @@ def _resolve_import_target(module_name: str, root_path: Path) -> Optional[str]:
|
|
|
100
109
|
for base in candidates:
|
|
101
110
|
for ext in extensions:
|
|
102
111
|
candidate = f"{base}{ext}"
|
|
103
|
-
if _exists_in_project(candidate, root_path):
|
|
112
|
+
if _exists_in_project(candidate, root_path, path_index):
|
|
104
113
|
return candidate
|
|
105
114
|
init_candidate = f"{base}/__init__{ext}"
|
|
106
|
-
if _exists_in_project(init_candidate, root_path):
|
|
115
|
+
if _exists_in_project(init_candidate, root_path, path_index):
|
|
107
116
|
return init_candidate
|
|
108
117
|
return None
|
|
109
118
|
|
|
110
119
|
|
|
111
120
|
def _resolve_relative_import(
|
|
112
|
-
module_name: str,
|
|
121
|
+
module_name: str,
|
|
122
|
+
level: int,
|
|
123
|
+
source_rel: str,
|
|
124
|
+
root_path: Path,
|
|
125
|
+
path_index: Optional[set[str]] = None,
|
|
113
126
|
) -> Optional[str]:
|
|
114
127
|
parts = Path(source_rel).parts
|
|
115
128
|
if level > len(parts):
|
|
@@ -120,15 +133,21 @@ def _resolve_relative_import(
|
|
|
120
133
|
extensions = [".py", ".pyw"]
|
|
121
134
|
for ext in extensions:
|
|
122
135
|
candidate = f"{base}{ext}"
|
|
123
|
-
if _exists_in_project(candidate, root_path):
|
|
136
|
+
if _exists_in_project(candidate, root_path, path_index):
|
|
124
137
|
return candidate
|
|
125
138
|
init_candidate = f"{base}/__init__{ext}"
|
|
126
|
-
if _exists_in_project(init_candidate, root_path):
|
|
139
|
+
if _exists_in_project(init_candidate, root_path, path_index):
|
|
127
140
|
return init_candidate
|
|
128
141
|
return None
|
|
129
142
|
|
|
130
143
|
|
|
131
|
-
def _exists_in_project(
|
|
144
|
+
def _exists_in_project(
|
|
145
|
+
candidate_path: str,
|
|
146
|
+
root_path: Path,
|
|
147
|
+
path_index: Optional[set[str]] = None,
|
|
148
|
+
) -> bool:
|
|
149
|
+
if path_index is not None:
|
|
150
|
+
return candidate_path in path_index
|
|
132
151
|
full = root_path / candidate_path
|
|
133
152
|
return full.exists()
|
|
134
153
|
|
|
@@ -2,19 +2,27 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import ast
|
|
4
4
|
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
5
6
|
|
|
6
7
|
from ctxgraph_code.graph.models import Node
|
|
7
8
|
|
|
8
9
|
|
|
9
|
-
def enrich_node_summary(
|
|
10
|
+
def enrich_node_summary(
|
|
11
|
+
node: Node,
|
|
12
|
+
file_path: Path,
|
|
13
|
+
*,
|
|
14
|
+
source: Optional[str] = None,
|
|
15
|
+
tree: Optional[ast.AST] = None,
|
|
16
|
+
) -> str:
|
|
10
17
|
if node.summary:
|
|
11
18
|
return node.summary
|
|
12
19
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
20
|
+
if tree is None:
|
|
21
|
+
try:
|
|
22
|
+
source = file_path.read_text(encoding="utf-8", errors="replace")
|
|
23
|
+
tree = ast.parse(source)
|
|
24
|
+
except (SyntaxError, FileNotFoundError):
|
|
25
|
+
return node.summary or ""
|
|
18
26
|
|
|
19
27
|
if node.type == "file":
|
|
20
28
|
return _summarize_file(tree, file_path)
|
|
@@ -4,36 +4,56 @@ import ast
|
|
|
4
4
|
from pathlib import Path
|
|
5
5
|
from typing import Optional
|
|
6
6
|
|
|
7
|
-
from ctxgraph_code.graph.models import
|
|
7
|
+
from ctxgraph_code.graph.models import Graph
|
|
8
8
|
|
|
9
9
|
|
|
10
|
-
def analyze_symbols(
|
|
11
|
-
|
|
10
|
+
def analyze_symbols(
|
|
11
|
+
file_path: Path,
|
|
12
|
+
root_path: Path,
|
|
13
|
+
*,
|
|
14
|
+
source: Optional[str] = None,
|
|
15
|
+
tree: Optional[ast.AST] = None,
|
|
16
|
+
) -> Graph:
|
|
12
17
|
rel_path = _relative_path(file_path, root_path)
|
|
13
18
|
file_node_id = f"file:{rel_path}"
|
|
14
19
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
+
if tree is None:
|
|
21
|
+
try:
|
|
22
|
+
source = file_path.read_text(encoding="utf-8", errors="replace")
|
|
23
|
+
tree = ast.parse(source)
|
|
24
|
+
except SyntaxError:
|
|
25
|
+
return Graph()
|
|
20
26
|
|
|
21
|
-
lines = source.split("\n")
|
|
27
|
+
lines = source.split("\n") if source else []
|
|
28
|
+
nodes: list[dict] = []
|
|
29
|
+
edges: list[dict] = []
|
|
22
30
|
|
|
23
31
|
for node in ast.iter_child_nodes(tree):
|
|
24
32
|
if isinstance(node, ast.ClassDef):
|
|
25
|
-
_process_class(node,
|
|
33
|
+
_process_class(node, nodes, edges, file_node_id, rel_path, lines)
|
|
26
34
|
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
27
|
-
_process_function(node,
|
|
35
|
+
_process_function(node, nodes, edges, file_node_id, rel_path, lines)
|
|
36
|
+
|
|
37
|
+
_process_calls(tree, nodes, edges, rel_path)
|
|
38
|
+
|
|
39
|
+
return Graph.from_batch(nodes, edges)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ── helpers ─────────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
28
44
|
|
|
29
|
-
|
|
45
|
+
def _node_dict(**kw) -> dict:
|
|
46
|
+
return dict(kw)
|
|
30
47
|
|
|
31
|
-
|
|
48
|
+
|
|
49
|
+
def _edge_dict(**kw) -> dict:
|
|
50
|
+
return dict(kw)
|
|
32
51
|
|
|
33
52
|
|
|
34
53
|
def _process_class(
|
|
35
54
|
node: ast.ClassDef,
|
|
36
|
-
|
|
55
|
+
nodes: list[dict],
|
|
56
|
+
edges: list[dict],
|
|
37
57
|
file_node_id: str,
|
|
38
58
|
rel_path: str,
|
|
39
59
|
lines: list[str],
|
|
@@ -42,45 +62,40 @@ def _process_class(
|
|
|
42
62
|
summary = _extract_docstring(node)
|
|
43
63
|
bases = _get_base_names(node)
|
|
44
64
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
relation="defines",
|
|
62
|
-
weight=1.0,
|
|
63
|
-
)
|
|
64
|
-
)
|
|
65
|
+
nodes.append(_node_dict(
|
|
66
|
+
id=class_id,
|
|
67
|
+
type="class",
|
|
68
|
+
name=node.name,
|
|
69
|
+
path=rel_path,
|
|
70
|
+
parent_id=file_node_id,
|
|
71
|
+
summary=summary,
|
|
72
|
+
importance=0.6,
|
|
73
|
+
lineno=node.lineno,
|
|
74
|
+
))
|
|
75
|
+
edges.append(_edge_dict(
|
|
76
|
+
source_id=file_node_id,
|
|
77
|
+
target_id=class_id,
|
|
78
|
+
relation="defines",
|
|
79
|
+
weight=1.0,
|
|
80
|
+
))
|
|
65
81
|
|
|
66
82
|
for base in bases:
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
)
|
|
74
|
-
)
|
|
83
|
+
edges.append(_edge_dict(
|
|
84
|
+
source_id=class_id,
|
|
85
|
+
target_id=base,
|
|
86
|
+
relation="extends",
|
|
87
|
+
weight=0.8,
|
|
88
|
+
))
|
|
75
89
|
|
|
76
90
|
for child in ast.iter_child_nodes(node):
|
|
77
91
|
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
78
|
-
_process_method(child,
|
|
92
|
+
_process_method(child, nodes, edges, class_id, rel_path, lines)
|
|
79
93
|
|
|
80
94
|
|
|
81
95
|
def _process_function(
|
|
82
96
|
node: (ast.FunctionDef | ast.AsyncFunctionDef),
|
|
83
|
-
|
|
97
|
+
nodes: list[dict],
|
|
98
|
+
edges: list[dict],
|
|
84
99
|
file_node_id: str,
|
|
85
100
|
rel_path: str,
|
|
86
101
|
lines: list[str],
|
|
@@ -88,31 +103,28 @@ def _process_function(
|
|
|
88
103
|
func_id = f"func:{rel_path}::{node.name}"
|
|
89
104
|
summary = _extract_docstring(node)
|
|
90
105
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
relation="defines",
|
|
108
|
-
weight=1.0,
|
|
109
|
-
)
|
|
110
|
-
)
|
|
106
|
+
nodes.append(_node_dict(
|
|
107
|
+
id=func_id,
|
|
108
|
+
type="function",
|
|
109
|
+
name=node.name,
|
|
110
|
+
path=rel_path,
|
|
111
|
+
parent_id=file_node_id,
|
|
112
|
+
summary=summary,
|
|
113
|
+
importance=0.5,
|
|
114
|
+
lineno=node.lineno,
|
|
115
|
+
))
|
|
116
|
+
edges.append(_edge_dict(
|
|
117
|
+
source_id=file_node_id,
|
|
118
|
+
target_id=func_id,
|
|
119
|
+
relation="defines",
|
|
120
|
+
weight=1.0,
|
|
121
|
+
))
|
|
111
122
|
|
|
112
123
|
|
|
113
124
|
def _process_method(
|
|
114
125
|
node: (ast.FunctionDef | ast.AsyncFunctionDef),
|
|
115
|
-
|
|
126
|
+
nodes: list[dict],
|
|
127
|
+
edges: list[dict],
|
|
116
128
|
class_id: str,
|
|
117
129
|
rel_path: str,
|
|
118
130
|
lines: list[str],
|
|
@@ -120,45 +132,39 @@ def _process_method(
|
|
|
120
132
|
method_id = f"func:{rel_path}::{node.name}"
|
|
121
133
|
summary = _extract_docstring(node)
|
|
122
134
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
)
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
def _process_calls(tree: ast.AST, graph: Graph, rel_path: str):
|
|
135
|
+
nodes.append(_node_dict(
|
|
136
|
+
id=method_id,
|
|
137
|
+
type="function",
|
|
138
|
+
name=node.name,
|
|
139
|
+
path=rel_path,
|
|
140
|
+
parent_id=class_id,
|
|
141
|
+
summary=summary,
|
|
142
|
+
importance=0.5,
|
|
143
|
+
lineno=node.lineno,
|
|
144
|
+
))
|
|
145
|
+
edges.append(_edge_dict(
|
|
146
|
+
source_id=class_id,
|
|
147
|
+
target_id=method_id,
|
|
148
|
+
relation="defines",
|
|
149
|
+
weight=1.0,
|
|
150
|
+
))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _process_calls(tree: ast.AST, nodes: list[dict], edges: list[dict], rel_path: str):
|
|
146
154
|
for node in ast.walk(tree):
|
|
147
155
|
if isinstance(node, ast.Call):
|
|
148
156
|
func_name = _get_call_name(node.func)
|
|
149
157
|
if func_name:
|
|
150
158
|
caller_id = _find_enclosing_symbol(tree, node, rel_path)
|
|
151
159
|
if caller_id:
|
|
152
|
-
|
|
153
|
-
if
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
)
|
|
161
|
-
)
|
|
160
|
+
callee_id = _find_target_node(func_name, nodes)
|
|
161
|
+
if callee_id:
|
|
162
|
+
edges.append(_edge_dict(
|
|
163
|
+
source_id=caller_id,
|
|
164
|
+
target_id=callee_id,
|
|
165
|
+
relation="calls",
|
|
166
|
+
weight=0.7,
|
|
167
|
+
))
|
|
162
168
|
|
|
163
169
|
|
|
164
170
|
def _get_call_name(node: ast.AST) -> Optional[str]:
|
|
@@ -191,10 +197,10 @@ def _contains_node(container: ast.AST, target: ast.AST) -> bool:
|
|
|
191
197
|
return False
|
|
192
198
|
|
|
193
199
|
|
|
194
|
-
def _find_target_node(name: str,
|
|
195
|
-
for
|
|
196
|
-
if
|
|
197
|
-
return
|
|
200
|
+
def _find_target_node(name: str, nodes: list[dict]) -> Optional[str]:
|
|
201
|
+
for nd in nodes:
|
|
202
|
+
if nd.get("type") in ("function", "class") and nd["name"] == name:
|
|
203
|
+
return nd["id"]
|
|
198
204
|
return None
|
|
199
205
|
|
|
200
206
|
|