ctxgraph-code 0.1.3__py3-none-any.whl → 0.2.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.
@@ -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 Edge, Graph, Node
8
-
9
-
10
- def analyze_imports(file_path: Path, root_path: Path) -> Graph:
11
- graph = Graph()
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
- graph.add_node(
16
- Node(
17
- id=file_node_id,
18
- type="file",
19
- name=file_path.name,
20
- path=rel_path,
21
- summary=None,
22
- importance=0.5,
23
- size_bytes=file_path.stat().st_size,
24
- )
25
- )
26
-
27
- try:
28
- tree = ast.parse(file_path.read_text(encoding="utf-8", errors="replace"))
29
- except SyntaxError:
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(graph, rel_path, file_node_id, target, alias.name)
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
- graph, rel_path, file_node_id, target, edge_label
61
+ nodes, edges, rel_path, file_node_id, target, edge_label
55
62
  )
56
63
 
57
- return graph
64
+ return Graph.from_batch(nodes, edges)
58
65
 
59
66
 
60
67
  def _add_import_edge(
61
- graph: Graph,
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 target_id not in graph.nodes:
69
- graph.add_node(
70
- Node(
71
- id=target_id,
72
- type="file",
73
- name=Path(target_rel).name,
74
- path=target_rel,
75
- summary=None,
76
- importance=0.3,
77
- size_bytes=0,
78
- )
79
- )
80
- graph.add_edge(
81
- Edge(
82
- source_id=source_id,
83
- target_id=target_id,
84
- relation="imports",
85
- weight=1.0,
86
- )
87
- )
88
-
89
-
90
- def _resolve_import_target(module_name: str, root_path: Path) -> Optional[str]:
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, level: int, source_rel: str, root_path: Path
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(candidate_path: str, root_path: Path) -> bool:
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(node: Node, file_path: Path) -> str:
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
- try:
14
- source = file_path.read_text(encoding="utf-8", errors="replace")
15
- tree = ast.parse(source)
16
- except (SyntaxError, FileNotFoundError):
17
- return node.summary or ""
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 Edge, Graph, Node
7
+ from ctxgraph_code.graph.models import Graph
8
8
 
9
9
 
10
- def analyze_symbols(file_path: Path, root_path: Path) -> Graph:
11
- graph = Graph()
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
- try:
16
- source = file_path.read_text(encoding="utf-8", errors="replace")
17
- tree = ast.parse(source)
18
- except SyntaxError:
19
- return graph
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, graph, file_node_id, rel_path, lines)
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, graph, file_node_id, rel_path, lines)
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
- _process_calls(tree, graph, rel_path)
45
+ def _node_dict(**kw) -> dict:
46
+ return dict(kw)
30
47
 
31
- return graph
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
- graph: Graph,
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
- graph.add_node(
46
- Node(
47
- id=class_id,
48
- type="class",
49
- name=node.name,
50
- path=rel_path,
51
- parent_id=file_node_id,
52
- summary=summary,
53
- importance=0.6,
54
- lineno=node.lineno,
55
- )
56
- )
57
- graph.add_edge(
58
- Edge(
59
- source_id=file_node_id,
60
- target_id=class_id,
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
- graph.add_edge(
68
- Edge(
69
- source_id=class_id,
70
- target_id=base,
71
- relation="extends",
72
- weight=0.8,
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, graph, class_id, rel_path, lines)
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
- graph: Graph,
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
- graph.add_node(
92
- Node(
93
- id=func_id,
94
- type="function",
95
- name=node.name,
96
- path=rel_path,
97
- parent_id=file_node_id,
98
- summary=summary,
99
- importance=0.5,
100
- lineno=node.lineno,
101
- )
102
- )
103
- graph.add_edge(
104
- Edge(
105
- source_id=file_node_id,
106
- target_id=func_id,
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
- graph: Graph,
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
- graph.add_node(
124
- Node(
125
- id=method_id,
126
- type="function",
127
- name=node.name,
128
- path=rel_path,
129
- parent_id=class_id,
130
- summary=summary,
131
- importance=0.5,
132
- lineno=node.lineno,
133
- )
134
- )
135
- graph.add_edge(
136
- Edge(
137
- source_id=class_id,
138
- target_id=method_id,
139
- relation="defines",
140
- weight=1.0,
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
- callee_node = _find_target_node(func_name, graph)
153
- if callee_node:
154
- graph.add_edge(
155
- Edge(
156
- source_id=caller_id,
157
- target_id=callee_node.id,
158
- relation="calls",
159
- weight=0.7,
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, graph: Graph) -> Optional[Node]:
195
- for node in graph.nodes.values():
196
- if node.type in ("function", "class") and node.name == name:
197
- return node
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