ctxgraph 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.
@@ -0,0 +1,75 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+
7
+ DEFAULT_EXCLUDE = [
8
+ "__pycache__",
9
+ "*.pyc",
10
+ ".git",
11
+ ".svn",
12
+ ".hg",
13
+ "node_modules",
14
+ "venv",
15
+ ".venv",
16
+ "env",
17
+ ".env",
18
+ "dist",
19
+ "build",
20
+ "*.egg-info",
21
+ ".pytest_cache",
22
+ ".mypy_cache",
23
+ ".ruff_cache",
24
+ ".tox",
25
+ ".nox",
26
+ "migrations",
27
+ "*.min.js",
28
+ "*.min.css",
29
+ ]
30
+
31
+
32
+ def should_exclude(
33
+ file_path: Path,
34
+ root_path: Path,
35
+ user_patterns: Optional[list[str]] = None,
36
+ ) -> bool:
37
+ patterns = list(DEFAULT_EXCLUDE)
38
+ if user_patterns:
39
+ patterns.extend(user_patterns)
40
+
41
+ rel_path = _relative_path(file_path, root_path)
42
+
43
+ for pattern in patterns:
44
+ if _matches_pattern(rel_path, pattern):
45
+ return True
46
+
47
+ return False
48
+
49
+
50
+ def _matches_pattern(path: str, pattern: str) -> bool:
51
+ if pattern.startswith("*."):
52
+ return path.endswith(pattern[1:])
53
+
54
+ if pattern.endswith("/"):
55
+ return pattern.rstrip("/") in path.split("/")
56
+
57
+ if "*" not in pattern:
58
+ return pattern in path.split("/")
59
+
60
+ if pattern.startswith("*") and pattern.endswith("*"):
61
+ mid = pattern[1:-1]
62
+ return mid in path
63
+ elif pattern.startswith("*"):
64
+ return path.endswith(pattern[1:])
65
+ elif pattern.endswith("*"):
66
+ return path.startswith(pattern[:-1])
67
+
68
+ return pattern in path
69
+
70
+
71
+ def _relative_path(file_path: Path, root_path: Path) -> str:
72
+ try:
73
+ return str(file_path.relative_to(root_path)).replace("\\", "/")
74
+ except ValueError:
75
+ return file_path.name
File without changes
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ from ctxgraph.analyzers.python.importer import analyze_imports
8
+ from ctxgraph.analyzers.python.semantic import enrich_node_summary
9
+ from ctxgraph.analyzers.python.symbols import analyze_symbols
10
+ from ctxgraph.exclude.patterns import should_exclude
11
+ from ctxgraph.graph.models import Graph
12
+ from ctxgraph.graph.storage import Storage
13
+
14
+
15
+ def build_graph(
16
+ repo_path: str | Path,
17
+ db_path: Optional[str | Path] = None,
18
+ exclude_patterns: Optional[list[str]] = None,
19
+ ) -> dict:
20
+ repo_path = Path(repo_path).resolve()
21
+ if db_path is None:
22
+ db_path = repo_path / ".ctxgraph" / "graph.db"
23
+
24
+ db_path = Path(db_path)
25
+ start = time.time()
26
+
27
+ storage = Storage(db_path)
28
+ storage.connect()
29
+ combined = Graph()
30
+ stats = {"files_analyzed": 0, "files_skipped": 0, "errors": 0}
31
+
32
+ python_files = list(repo_path.rglob("*.py"))
33
+ for file_path in python_files:
34
+ if should_exclude(file_path, repo_path, exclude_patterns):
35
+ stats["files_skipped"] += 1
36
+ continue
37
+
38
+ try:
39
+ import_graph = analyze_imports(file_path, repo_path)
40
+ combined.merge(import_graph)
41
+
42
+ symbol_graph = analyze_symbols(file_path, repo_path)
43
+ combined.merge(symbol_graph)
44
+
45
+ for node in combined.nodes.values():
46
+ if node.path and node.path in str(file_path):
47
+ if not node.summary:
48
+ summary = enrich_node_summary(node, file_path)
49
+ if summary:
50
+ node.summary = summary
51
+
52
+ stats["files_analyzed"] += 1
53
+ except Exception:
54
+ stats["errors"] += 1
55
+
56
+ storage.save_graph(combined)
57
+ storage.save_metadata("build_time", str(time.time()))
58
+ storage.save_metadata("repo_path", str(repo_path))
59
+ storage.save_metadata("file_count", str(stats["files_analyzed"]))
60
+ storage.close()
61
+
62
+ elapsed = time.time() - start
63
+ stats["elapsed_seconds"] = round(elapsed, 2)
64
+ stats["total_nodes"] = len(combined.nodes)
65
+ stats["total_edges"] = len(combined.edges)
66
+
67
+ return stats
68
+
69
+
70
+ def get_storage(repo_path: str | Path) -> Optional[Storage]:
71
+ db_path = Path(repo_path) / ".ctxgraph" / "graph.db"
72
+ if not db_path.exists():
73
+ return None
74
+ storage = Storage(db_path)
75
+ storage.connect()
76
+ return storage
@@ -0,0 +1,83 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Optional
5
+
6
+
7
+ @dataclass
8
+ class Node:
9
+ id: str
10
+ type: str
11
+ name: str
12
+ path: Optional[str] = None
13
+ parent_id: Optional[str] = None
14
+ summary: Optional[str] = None
15
+ importance: float = 0.5
16
+ size_bytes: int = 0
17
+ lineno: int = 0
18
+
19
+ def __hash__(self):
20
+ return hash(self.id)
21
+
22
+ def __eq__(self, other):
23
+ return isinstance(other, Node) and self.id == other.id
24
+
25
+
26
+ @dataclass
27
+ class Edge:
28
+ source_id: str
29
+ target_id: str
30
+ relation: str
31
+ weight: float = 1.0
32
+
33
+ def __hash__(self):
34
+ return hash((self.source_id, self.target_id, self.relation))
35
+
36
+ def __eq__(self, other):
37
+ return (
38
+ isinstance(other, Edge)
39
+ and self.source_id == other.source_id
40
+ and self.target_id == other.target_id
41
+ and self.relation == other.relation
42
+ )
43
+
44
+
45
+ @dataclass
46
+ class Graph:
47
+ nodes: dict[str, Node] = field(default_factory=dict)
48
+ edges: list[Edge] = field(default_factory=list)
49
+
50
+ def add_node(self, node: Node):
51
+ self.nodes[node.id] = node
52
+
53
+ def add_edge(self, edge: Edge):
54
+ self.edges.append(edge)
55
+
56
+ def get_node(self, node_id: str) -> Optional[Node]:
57
+ return self.nodes.get(node_id)
58
+
59
+ def get_edges_from(self, source_id: str) -> list[Edge]:
60
+ return [e for e in self.edges if e.source_id == source_id]
61
+
62
+ def get_edges_to(self, target_id: str) -> list[Edge]:
63
+ return [e for e in self.edges if e.target_id == target_id]
64
+
65
+ def get_neighbors(self, node_id: str) -> list[str]:
66
+ result = set()
67
+ for e in self.edges:
68
+ if e.source_id == node_id:
69
+ result.add(e.target_id)
70
+ if e.target_id == node_id:
71
+ result.add(e.source_id)
72
+ return list(result)
73
+
74
+ def merge(self, other: Graph):
75
+ for node_id, node in other.nodes.items():
76
+ if node_id not in self.nodes:
77
+ self.nodes[node_id] = node
78
+ existing = {(e.source_id, e.target_id, e.relation) for e in self.edges}
79
+ for e in other.edges:
80
+ key = (e.source_id, e.target_id, e.relation)
81
+ if key not in existing:
82
+ self.edges.append(e)
83
+ existing.add(key)
@@ -0,0 +1,135 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from collections import Counter
5
+ from typing import Optional
6
+
7
+ from ctxgraph.graph.models import Node
8
+ from ctxgraph.graph.storage import Storage
9
+
10
+
11
+ def search_relevant_nodes(
12
+ storage: Storage,
13
+ query: str,
14
+ max_nodes: int = 15,
15
+ max_depth: int = 2,
16
+ ) -> list[tuple[Node, float]]:
17
+ tokens = _tokenize(query)
18
+ if not tokens:
19
+ return []
20
+
21
+ scored: dict[str, float] = {}
22
+ seen_ids: set[str] = set()
23
+
24
+ matched_nodes = storage.search_nodes(query)
25
+ for node in matched_nodes:
26
+ score = _compute_relevance(node, tokens)
27
+ if score > 0:
28
+ scored[node.id] = score
29
+ seen_ids.add(node.id)
30
+
31
+ if not scored:
32
+ for token in tokens:
33
+ token_nodes = storage.search_nodes(token)
34
+ for node in token_nodes:
35
+ if node.id not in seen_ids:
36
+ seen_ids.add(node.id)
37
+ score = _compute_relevance(node, tokens)
38
+ if score > 0:
39
+ scored[node.id] = score
40
+
41
+ if not scored:
42
+ return []
43
+
44
+ seed_ids = set(scored.keys())
45
+ edge_ids = set()
46
+
47
+ for _ in range(max_depth):
48
+ edges = storage.get_edges_for_nodes(seed_ids | edge_ids)
49
+ new_ids = set()
50
+ for e in edges:
51
+ if e.source_id in (seed_ids | edge_ids):
52
+ new_ids.add(e.target_id)
53
+ if e.target_id in (seed_ids | edge_ids):
54
+ new_ids.add(e.source_id)
55
+ edge_ids |= new_ids
56
+
57
+ all_ids = seed_ids | edge_ids
58
+ for nid in edge_ids:
59
+ if nid not in scored:
60
+ node = storage.get_node(nid)
61
+ if node:
62
+ neighbors = _count_matched_neighbors(nid, storage, seed_ids)
63
+ scored[nid] = 0.1 * neighbors
64
+
65
+ ranked = sorted(scored.items(), key=lambda x: x[1], reverse=True)
66
+ ranked = ranked[:max_nodes]
67
+
68
+ result = []
69
+ for nid, score in ranked:
70
+ node = storage.get_node(nid)
71
+ if node:
72
+ result.append((node, round(score, 3)))
73
+
74
+ return result
75
+
76
+
77
+ def generate_context_subgraph(
78
+ storage: Storage,
79
+ query: str,
80
+ max_nodes: int = 15,
81
+ ) -> tuple[list[Node], list[tuple[str, str, str]]]:
82
+ ranked = search_relevant_nodes(storage, query, max_nodes)
83
+ nodes = [n for n, _ in ranked]
84
+ node_ids = {n.id for n in nodes}
85
+
86
+ edges = storage.get_edges_for_nodes(node_ids)
87
+ edge_list = [
88
+ (e.source_id, e.target_id, e.relation)
89
+ for e in edges
90
+ if e.source_id in node_ids and e.target_id in node_ids
91
+ ]
92
+
93
+ return nodes, edge_list
94
+
95
+
96
+ def _tokenize(text: str) -> list[str]:
97
+ text = text.lower()
98
+ tokens = re.findall(r"[a-zA-Z_][a-zA-Z0-9_]*", text)
99
+ stopwords = {
100
+ "the", "a", "an", "in", "on", "at", "to", "for", "of", "is",
101
+ "fix", "bug", "implement", "add", "change", "update", "remove",
102
+ "need", "want", "please", "can", "how", "what", "where", "why",
103
+ "this", "that", "with", "from", "by", "be", "has", "have", "do",
104
+ "does", "did", "will", "would", "could", "should", "may", "might",
105
+ "file", "function", "class", "code", "issue", "problem", "error",
106
+ "work", "make", "get", "set",
107
+ }
108
+ return [t for t in tokens if t not in stopwords and len(t) > 1]
109
+
110
+
111
+ def _compute_relevance(node: Node, tokens: list[str]) -> float:
112
+ score = 0.0
113
+ text = f"{node.name} {node.summary or ''} {node.path or ''}".lower()
114
+
115
+ for token in tokens:
116
+ if token in node.name.lower():
117
+ score += 2.0
118
+ count = text.count(token)
119
+ score += count * 0.5
120
+
121
+ if node.importance:
122
+ score *= (0.5 + node.importance)
123
+
124
+ return score
125
+
126
+
127
+ def _count_matched_neighbors(
128
+ node_id: str, storage: Storage, matched_ids: set[str]
129
+ ) -> int:
130
+ edges = storage.get_edges_for_nodes({node_id})
131
+ count = 0
132
+ for e in edges:
133
+ if e.source_id in matched_ids or e.target_id in matched_ids:
134
+ count += 1
135
+ return count
@@ -0,0 +1,224 @@
1
+ from __future__ import annotations
2
+
3
+ import sqlite3
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ from ctxgraph.graph.models import Edge, Graph, Node
8
+
9
+
10
+ def _table_schema() -> str:
11
+ return """
12
+ CREATE TABLE IF NOT EXISTS nodes (
13
+ id TEXT PRIMARY KEY,
14
+ type TEXT NOT NULL,
15
+ name TEXT NOT NULL,
16
+ path TEXT,
17
+ parent_id TEXT,
18
+ summary TEXT,
19
+ importance REAL DEFAULT 0.5,
20
+ size_bytes INTEGER DEFAULT 0,
21
+ lineno INTEGER DEFAULT 0
22
+ );
23
+
24
+ CREATE TABLE IF NOT EXISTS edges (
25
+ source_id TEXT NOT NULL,
26
+ target_id TEXT NOT NULL,
27
+ relation TEXT NOT NULL,
28
+ weight REAL DEFAULT 1.0,
29
+ PRIMARY KEY (source_id, target_id, relation)
30
+ );
31
+
32
+ CREATE INDEX IF NOT EXISTS idx_edges_source ON edges(source_id);
33
+ CREATE INDEX IF NOT EXISTS idx_edges_target ON edges(target_id);
34
+ CREATE INDEX IF NOT EXISTS idx_nodes_path ON nodes(path);
35
+ CREATE INDEX IF NOT EXISTS idx_nodes_type ON nodes(type);
36
+
37
+ CREATE TABLE IF NOT EXISTS metadata (
38
+ key TEXT PRIMARY KEY,
39
+ value TEXT
40
+ );
41
+ """
42
+
43
+
44
+ class Storage:
45
+ def __init__(self, db_path: str | Path):
46
+ self.db_path = Path(db_path)
47
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
48
+ self._conn: Optional[sqlite3.Connection] = None
49
+
50
+ def connect(self):
51
+ self._conn = sqlite3.connect(str(self.db_path))
52
+ self._conn.execute("PRAGMA journal_mode=WAL")
53
+ self._conn.execute("PRAGMA synchronous=NORMAL")
54
+ self._conn.row_factory = sqlite3.Row
55
+ self._init_schema()
56
+
57
+ def _init_schema(self):
58
+ self._conn.executescript(_table_schema())
59
+ self._conn.commit()
60
+
61
+ def close(self):
62
+ if self._conn:
63
+ self._conn.close()
64
+ self._conn = None
65
+
66
+ @property
67
+ def conn(self) -> sqlite3.Connection:
68
+ if self._conn is None:
69
+ raise RuntimeError("Storage not connected. Call connect() first.")
70
+ return self._conn
71
+
72
+ def update_node_summary(self, node_id: str, summary: str):
73
+ self.conn.execute(
74
+ "UPDATE nodes SET summary = ? WHERE id = ?", (summary, node_id)
75
+ )
76
+ self.conn.commit()
77
+
78
+ def save_node(self, node: Node):
79
+ self.conn.execute(
80
+ """INSERT OR REPLACE INTO nodes
81
+ (id, type, name, path, parent_id, summary, importance, size_bytes, lineno)
82
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
83
+ (
84
+ node.id,
85
+ node.type,
86
+ node.name,
87
+ node.path,
88
+ node.parent_id,
89
+ node.summary,
90
+ node.importance,
91
+ node.size_bytes,
92
+ node.lineno,
93
+ ),
94
+ )
95
+
96
+ def save_edge(self, edge: Edge):
97
+ self.conn.execute(
98
+ """INSERT OR REPLACE INTO edges
99
+ (source_id, target_id, relation, weight)
100
+ VALUES (?, ?, ?, ?)""",
101
+ (edge.source_id, edge.target_id, edge.relation, edge.weight),
102
+ )
103
+
104
+ def save_graph(self, graph: Graph):
105
+ for node in graph.nodes.values():
106
+ self.save_node(node)
107
+ for edge in graph.edges:
108
+ self.save_edge(edge)
109
+ self.conn.commit()
110
+
111
+ def get_node(self, node_id: str) -> Optional[Node]:
112
+ row = self.conn.execute(
113
+ "SELECT * FROM nodes WHERE id = ?", (node_id,)
114
+ ).fetchone()
115
+ if row is None:
116
+ return None
117
+ return Node(
118
+ id=row["id"],
119
+ type=row["type"],
120
+ name=row["name"],
121
+ path=row["path"],
122
+ parent_id=row["parent_id"],
123
+ summary=row["summary"],
124
+ importance=row["importance"],
125
+ size_bytes=row["size_bytes"],
126
+ lineno=row["lineno"],
127
+ )
128
+
129
+ def search_nodes(self, text: str) -> list[Node]:
130
+ query = f"%{text}%"
131
+ rows = self.conn.execute(
132
+ """SELECT * FROM nodes WHERE
133
+ name LIKE ? OR summary LIKE ? OR path LIKE ?
134
+ ORDER BY importance DESC
135
+ LIMIT 50""",
136
+ (query, query, query),
137
+ ).fetchall()
138
+ return [
139
+ Node(
140
+ id=r["id"],
141
+ type=r["type"],
142
+ name=r["name"],
143
+ path=r["path"],
144
+ parent_id=r["parent_id"],
145
+ summary=r["summary"],
146
+ importance=r["importance"],
147
+ size_bytes=r["size_bytes"],
148
+ lineno=r["lineno"],
149
+ )
150
+ for r in rows
151
+ ]
152
+
153
+ def get_edges_for_nodes(self, node_ids: set[str]) -> list[Edge]:
154
+ if not node_ids:
155
+ return []
156
+ placeholders = ",".join("?" for _ in node_ids)
157
+ rows = self.conn.execute(
158
+ f"""SELECT * FROM edges WHERE
159
+ source_id IN ({placeholders}) OR target_id IN ({placeholders})""",
160
+ list(node_ids) + list(node_ids),
161
+ ).fetchall()
162
+ return [
163
+ Edge(
164
+ source_id=r["source_id"],
165
+ target_id=r["target_id"],
166
+ relation=r["relation"],
167
+ weight=r["weight"],
168
+ )
169
+ for r in rows
170
+ ]
171
+
172
+ def get_all_nodes(self) -> list[Node]:
173
+ rows = self.conn.execute("SELECT * FROM nodes").fetchall()
174
+ return [
175
+ Node(
176
+ id=r["id"],
177
+ type=r["type"],
178
+ name=r["name"],
179
+ path=r["path"],
180
+ parent_id=r["parent_id"],
181
+ summary=r["summary"],
182
+ importance=r["importance"],
183
+ size_bytes=r["size_bytes"],
184
+ lineno=r["lineno"],
185
+ )
186
+ for r in rows
187
+ ]
188
+
189
+ def get_all_edges(self) -> list[Edge]:
190
+ rows = self.conn.execute("SELECT * FROM edges").fetchall()
191
+ return [
192
+ Edge(
193
+ source_id=r["source_id"],
194
+ target_id=r["target_id"],
195
+ relation=r["relation"],
196
+ weight=r["weight"],
197
+ )
198
+ for r in rows
199
+ ]
200
+
201
+ def stats(self) -> dict:
202
+ node_count = self.conn.execute("SELECT COUNT(*) FROM nodes").fetchone()[0]
203
+ edge_count = self.conn.execute("SELECT COUNT(*) FROM edges").fetchone()[0]
204
+ type_counts = self.conn.execute(
205
+ "SELECT type, COUNT(*) as cnt FROM nodes GROUP BY type"
206
+ ).fetchall()
207
+ return {
208
+ "nodes": node_count,
209
+ "edges": edge_count,
210
+ "types": {r["type"]: r["cnt"] for r in type_counts},
211
+ }
212
+
213
+ def save_metadata(self, key: str, value: str):
214
+ self.conn.execute(
215
+ "INSERT OR REPLACE INTO metadata (key, value) VALUES (?, ?)",
216
+ (key, value),
217
+ )
218
+ self.conn.commit()
219
+
220
+ def get_metadata(self, key: str) -> Optional[str]:
221
+ row = self.conn.execute(
222
+ "SELECT value FROM metadata WHERE key = ?", (key,)
223
+ ).fetchone()
224
+ return row["value"] if row else None
File without changes