codepulse-engine 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.
- codepulse/__init__.py +5 -0
- codepulse/api/app.py +1336 -0
- codepulse/api/errors.py +172 -0
- codepulse/api/security.py +189 -0
- codepulse/auth.py +406 -0
- codepulse/cli.py +1052 -0
- codepulse/config.py +475 -0
- codepulse/data_location.py +78 -0
- codepulse/embeddings/chunker.py +138 -0
- codepulse/embeddings/embedder.py +120 -0
- codepulse/git_diff.py +201 -0
- codepulse/indexer/engine.py +2655 -0
- codepulse/indexer/ignore.py +120 -0
- codepulse/ingestion_safety.py +169 -0
- codepulse/jobs.py +257 -0
- codepulse/mcp_server.py +269 -0
- codepulse/models.py +140 -0
- codepulse/observability.py +130 -0
- codepulse/parser/__init__.py +8 -0
- codepulse/parser/base.py +58 -0
- codepulse/parser/factory.py +37 -0
- codepulse/parser/graph.py +502 -0
- codepulse/parser/javascript.py +428 -0
- codepulse/parser/module_graph.py +232 -0
- codepulse/parser/python.py +331 -0
- codepulse/rate_limit.py +142 -0
- codepulse/repository_identity.py +83 -0
- codepulse/search/bm25.py +342 -0
- codepulse/search/ranker.py +382 -0
- codepulse/search/tokenizer.py +80 -0
- codepulse/search/vector.py +357 -0
- codepulse/storage/db.py +526 -0
- codepulse/storage/migrations.py +347 -0
- codepulse/storage/schema_migrations/README +1 -0
- codepulse/storage/schema_migrations/env.py +70 -0
- codepulse/storage/schema_migrations/script.py.mako +28 -0
- codepulse/storage/schema_migrations/versions/135a7b064ea7_add_base_classes_and_relationship_type.py +82 -0
- codepulse/storage/schema_migrations/versions/20fc2d0607e2_add_file_content_hash_and_natural_key_.py +106 -0
- codepulse/storage/schema_migrations/versions/21a7cb7f1a85_initial_schema.py +105 -0
- codepulse/storage/schema_migrations/versions/2cd38eb12a7e_add_call_edge_repo_id_isolation_fix.py +112 -0
- codepulse/storage/schema_migrations/versions/3ba396e9c78f_add_symbol_natural_key_uniqueness.py +80 -0
- codepulse/storage/schema_migrations/versions/49572f834366_add_embedding_persistence_fields.py +102 -0
- codepulse/storage/schema_migrations/versions/7c3e5a91d2f4_add_module_edges.py +61 -0
- codepulse/storage/schema_migrations/versions/a41f0d2c6b8e_canonicalize_repository_urls.py +94 -0
- codepulse/storage/schema_migrations/versions/b0bdd63949f4_add_api_key_model.py +71 -0
- codepulse/storage/schema_migrations/versions/b9014c049e33_add_api_key_repository_role.py +65 -0
- codepulse/storage/schema_migrations/versions/c286d277ea21_add_repository_snapshot_model.py +62 -0
- codepulse/storage/schema_migrations/versions/de22df2fc3ce_add_index_job_model.py +59 -0
- codepulse/storage/schema_migrations/versions/f6e481353553_rename_dependency_to_call_edge_add_confidence.py +80 -0
- codepulse/symbol_identity.py +29 -0
- codepulse_engine-0.1.0.dist-info/METADATA +191 -0
- codepulse_engine-0.1.0.dist-info/RECORD +56 -0
- codepulse_engine-0.1.0.dist-info/WHEEL +5 -0
- codepulse_engine-0.1.0.dist-info/entry_points.txt +2 -0
- codepulse_engine-0.1.0.dist-info/licenses/LICENSE +21 -0
- codepulse_engine-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
from typing import Any
|
|
5
|
+
from tree_sitter_languages import get_parser
|
|
6
|
+
|
|
7
|
+
from codepulse.models import CallReference, ImportItem, ImportStatement, Symbol, SymbolType
|
|
8
|
+
from codepulse.parser.base import LanguageParser
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PythonParser(LanguageParser):
|
|
12
|
+
"""AST parser for Python source code using Tree-sitter."""
|
|
13
|
+
|
|
14
|
+
def __init__(self) -> None:
|
|
15
|
+
self._parser = get_parser("python")
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def language(self) -> str:
|
|
19
|
+
return "python"
|
|
20
|
+
|
|
21
|
+
def extract_symbols(self, source_code: str, file_path: str = "") -> list[Symbol]:
|
|
22
|
+
source_bytes = source_code.encode("utf-8")
|
|
23
|
+
tree = self._parser.parse(source_bytes)
|
|
24
|
+
symbols: list[Symbol] = []
|
|
25
|
+
|
|
26
|
+
def walk(node: Any, parent_class: str | None = None) -> None:
|
|
27
|
+
if node.type in ("function_definition", "async_function_definition"):
|
|
28
|
+
sym = self._parse_function(node, source_bytes, file_path, parent_class)
|
|
29
|
+
if sym:
|
|
30
|
+
symbols.append(sym)
|
|
31
|
+
# Continue walking into function body to support nested functions/classes
|
|
32
|
+
body_node = node.child_by_field_name("body")
|
|
33
|
+
if body_node:
|
|
34
|
+
for child in body_node.children:
|
|
35
|
+
walk(child, parent_class=sym.name if sym else parent_class)
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
elif node.type == "class_definition":
|
|
39
|
+
sym = self._parse_class(node, source_bytes, file_path, parent_class)
|
|
40
|
+
if sym:
|
|
41
|
+
symbols.append(sym)
|
|
42
|
+
body_node = node.child_by_field_name("body")
|
|
43
|
+
if body_node:
|
|
44
|
+
for child in body_node.children:
|
|
45
|
+
walk(child, parent_class=sym.name if sym else parent_class)
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
for child in node.children:
|
|
49
|
+
walk(child, parent_class)
|
|
50
|
+
|
|
51
|
+
walk(tree.root_node)
|
|
52
|
+
return symbols
|
|
53
|
+
|
|
54
|
+
def _parse_function(
|
|
55
|
+
self, node: Any, source_bytes: bytes, file_path: str, parent_class: str | None
|
|
56
|
+
) -> Symbol | None:
|
|
57
|
+
name_node = node.child_by_field_name("name")
|
|
58
|
+
if not name_node:
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
name = source_bytes[name_node.start_byte:name_node.end_byte].decode("utf-8", errors="replace")
|
|
62
|
+
start_line = node.start_point[0] + 1
|
|
63
|
+
end_line = node.end_point[0] + 1
|
|
64
|
+
body_text = source_bytes[node.start_byte:node.end_byte].decode("utf-8", errors="replace")
|
|
65
|
+
docstring = self._extract_docstring(node, source_bytes)
|
|
66
|
+
signature = self._extract_signature(node, source_bytes)
|
|
67
|
+
complexity = self._calculate_complexity(node)
|
|
68
|
+
|
|
69
|
+
symbol_type = SymbolType.METHOD if parent_class else SymbolType.FUNCTION
|
|
70
|
+
|
|
71
|
+
return Symbol(
|
|
72
|
+
name=name,
|
|
73
|
+
type=symbol_type,
|
|
74
|
+
file_path=file_path,
|
|
75
|
+
start_line=start_line,
|
|
76
|
+
end_line=end_line,
|
|
77
|
+
signature=signature,
|
|
78
|
+
docstring=docstring,
|
|
79
|
+
body=body_text,
|
|
80
|
+
body_hash=hashlib.sha256(body_text.strip().encode("utf-8")).hexdigest(),
|
|
81
|
+
language="python",
|
|
82
|
+
parent_symbol=parent_class,
|
|
83
|
+
complexity=complexity,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def _parse_class(
|
|
87
|
+
self, node: Any, source_bytes: bytes, file_path: str, parent_class: str | None
|
|
88
|
+
) -> Symbol | None:
|
|
89
|
+
name_node = node.child_by_field_name("name")
|
|
90
|
+
if not name_node:
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
name = source_bytes[name_node.start_byte:name_node.end_byte].decode("utf-8", errors="replace")
|
|
94
|
+
start_line = node.start_point[0] + 1
|
|
95
|
+
end_line = node.end_point[0] + 1
|
|
96
|
+
body_text = source_bytes[node.start_byte:node.end_byte].decode("utf-8", errors="replace")
|
|
97
|
+
docstring = self._extract_docstring(node, source_bytes)
|
|
98
|
+
signature = self._extract_signature(node, source_bytes)
|
|
99
|
+
base_classes = self._extract_base_classes(node, source_bytes)
|
|
100
|
+
|
|
101
|
+
return Symbol(
|
|
102
|
+
name=name,
|
|
103
|
+
type=SymbolType.CLASS,
|
|
104
|
+
file_path=file_path,
|
|
105
|
+
start_line=start_line,
|
|
106
|
+
end_line=end_line,
|
|
107
|
+
signature=signature,
|
|
108
|
+
docstring=docstring,
|
|
109
|
+
body=body_text,
|
|
110
|
+
body_hash=hashlib.sha256(body_text.strip().encode("utf-8")).hexdigest(),
|
|
111
|
+
language="python",
|
|
112
|
+
parent_symbol=parent_class,
|
|
113
|
+
complexity=1,
|
|
114
|
+
base_classes=base_classes,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
def _extract_base_classes(self, node: Any, source_bytes: bytes) -> list[str]:
|
|
118
|
+
"""
|
|
119
|
+
Phase 4.1 (roadmap §37 item 4.1: "Base-class/inheritance
|
|
120
|
+
extraction (Python + JS/TS)"). `class_definition`'s
|
|
121
|
+
`superclasses` field is the `argument_list` in `class Foo(Base,
|
|
122
|
+
pkg.Mixin, metaclass=Meta):` -- confirmed via a direct tree-
|
|
123
|
+
sitter grammar probe during this milestone's own design audit.
|
|
124
|
+
|
|
125
|
+
Keeps `identifier` (`Base`) and `attribute` (`pkg.Mixin`, a
|
|
126
|
+
dotted/qualified name) nodes -- the only two node types that can
|
|
127
|
+
actually name a base class in this grammar. Deliberately
|
|
128
|
+
EXCLUDES `keyword_argument` (e.g. `metaclass=Meta`) -- not a
|
|
129
|
+
base class -- and punctuation/commas. Returns the raw text
|
|
130
|
+
exactly as written; RESOLVING each name to a real symbol is a
|
|
131
|
+
separate step performed by `CallGraphBuilder`, not here (see
|
|
132
|
+
`Symbol.base_classes`'s own docstring for why raw and resolved
|
|
133
|
+
are deliberately kept apart).
|
|
134
|
+
"""
|
|
135
|
+
superclasses = node.child_by_field_name("superclasses")
|
|
136
|
+
if not superclasses:
|
|
137
|
+
return []
|
|
138
|
+
|
|
139
|
+
bases: list[str] = []
|
|
140
|
+
for child in superclasses.children:
|
|
141
|
+
if child.type in ("identifier", "attribute"):
|
|
142
|
+
bases.append(
|
|
143
|
+
source_bytes[child.start_byte:child.end_byte].decode("utf-8", errors="replace")
|
|
144
|
+
)
|
|
145
|
+
return bases
|
|
146
|
+
|
|
147
|
+
def _extract_docstring(self, node: Any, source_bytes: bytes) -> str | None:
|
|
148
|
+
body_node = node.child_by_field_name("body")
|
|
149
|
+
if not body_node or not body_node.children:
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
first_stmt = body_node.children[0]
|
|
153
|
+
if first_stmt.type == "expression_statement" and first_stmt.children:
|
|
154
|
+
expr = first_stmt.children[0]
|
|
155
|
+
if expr.type == "string":
|
|
156
|
+
doc = source_bytes[expr.start_byte:expr.end_byte].decode("utf-8", errors="replace")
|
|
157
|
+
# Strip quotes (triple or single)
|
|
158
|
+
for q in ('"""', "'''", '"', "'"):
|
|
159
|
+
if doc.startswith(q) and doc.endswith(q) and len(doc) >= 2 * len(q):
|
|
160
|
+
return doc[len(q):-len(q)].strip()
|
|
161
|
+
return doc.strip()
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
def _extract_signature(self, node: Any, source_bytes: bytes) -> str:
|
|
165
|
+
body_node = node.child_by_field_name("body")
|
|
166
|
+
if body_node:
|
|
167
|
+
sig_bytes = source_bytes[node.start_byte:body_node.start_byte]
|
|
168
|
+
sig_text = sig_bytes.decode("utf-8", errors="replace").strip()
|
|
169
|
+
if sig_text.endswith(":"):
|
|
170
|
+
sig_text = sig_text[:-1].strip()
|
|
171
|
+
return " ".join(sig_text.split())
|
|
172
|
+
|
|
173
|
+
# Fallback to first line
|
|
174
|
+
full_text = source_bytes[node.start_byte:node.end_byte].decode("utf-8", errors="replace")
|
|
175
|
+
return full_text.splitlines()[0].strip().rstrip(":")
|
|
176
|
+
|
|
177
|
+
def _calculate_complexity(self, node: Any) -> int:
|
|
178
|
+
complexity = 1
|
|
179
|
+
branching_types = {
|
|
180
|
+
"if_statement", "elif_clause", "for_statement", "while_statement",
|
|
181
|
+
"except_clause", "with_statement", "boolean_operator", "conditional_expression"
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
def count_branches(n: Any) -> None:
|
|
185
|
+
nonlocal complexity
|
|
186
|
+
if n.type in branching_types:
|
|
187
|
+
complexity += 1
|
|
188
|
+
for child in n.children:
|
|
189
|
+
count_branches(child)
|
|
190
|
+
|
|
191
|
+
count_branches(node)
|
|
192
|
+
return complexity
|
|
193
|
+
|
|
194
|
+
def extract_imports(self, source_code: str, file_path: str = "") -> list[ImportStatement]:
|
|
195
|
+
source_bytes = source_code.encode("utf-8")
|
|
196
|
+
tree = self._parser.parse(source_bytes)
|
|
197
|
+
imports: list[ImportStatement] = []
|
|
198
|
+
|
|
199
|
+
def walk(node: Any) -> None:
|
|
200
|
+
if node.type == "import_statement":
|
|
201
|
+
# e.g., import math, sys as s
|
|
202
|
+
line_num = node.start_point[0] + 1
|
|
203
|
+
for child in node.children:
|
|
204
|
+
if child.type == "dotted_name":
|
|
205
|
+
mod_name = source_bytes[child.start_byte:child.end_byte].decode("utf-8")
|
|
206
|
+
imports.append(
|
|
207
|
+
ImportStatement(
|
|
208
|
+
module=mod_name,
|
|
209
|
+
items=[],
|
|
210
|
+
is_relative=False,
|
|
211
|
+
level=0,
|
|
212
|
+
line_number=line_num,
|
|
213
|
+
file_path=file_path,
|
|
214
|
+
)
|
|
215
|
+
)
|
|
216
|
+
elif child.type == "aliased_import":
|
|
217
|
+
name_n = child.child_by_field_name("name")
|
|
218
|
+
alias_n = child.child_by_field_name("alias")
|
|
219
|
+
if name_n:
|
|
220
|
+
mod_name = source_bytes[name_n.start_byte:name_n.end_byte].decode("utf-8")
|
|
221
|
+
alias = source_bytes[alias_n.start_byte:alias_n.end_byte].decode("utf-8") if alias_n else None
|
|
222
|
+
imports.append(
|
|
223
|
+
ImportStatement(
|
|
224
|
+
module=mod_name,
|
|
225
|
+
items=[ImportItem(name=mod_name, alias=alias)],
|
|
226
|
+
is_relative=False,
|
|
227
|
+
level=0,
|
|
228
|
+
line_number=line_num,
|
|
229
|
+
file_path=file_path,
|
|
230
|
+
)
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
elif node.type == "import_from_statement":
|
|
234
|
+
# e.g., from os.path import join as j, exists
|
|
235
|
+
# e.g., from ..utils import helper
|
|
236
|
+
line_num = node.start_point[0] + 1
|
|
237
|
+
module_name = ""
|
|
238
|
+
level = 0
|
|
239
|
+
is_relative = False
|
|
240
|
+
|
|
241
|
+
module_node = node.child_by_field_name("module_name")
|
|
242
|
+
if module_node:
|
|
243
|
+
if module_node.type == "relative_import":
|
|
244
|
+
is_relative = True
|
|
245
|
+
# Count dots
|
|
246
|
+
dot_str = ""
|
|
247
|
+
for sub in module_node.children:
|
|
248
|
+
if sub.type == "import_prefix":
|
|
249
|
+
dot_str += source_bytes[sub.start_byte:sub.end_byte].decode("utf-8")
|
|
250
|
+
elif sub.type == "dotted_name":
|
|
251
|
+
module_name = source_bytes[sub.start_byte:sub.end_byte].decode("utf-8")
|
|
252
|
+
level = dot_str.count(".")
|
|
253
|
+
else:
|
|
254
|
+
module_name = source_bytes[module_node.start_byte:module_node.end_byte].decode("utf-8")
|
|
255
|
+
|
|
256
|
+
items: list[ImportItem] = []
|
|
257
|
+
for child in node.children:
|
|
258
|
+
if child.type == "dotted_name" and child != module_node:
|
|
259
|
+
item_name = source_bytes[child.start_byte:child.end_byte].decode("utf-8")
|
|
260
|
+
items.append(ImportItem(name=item_name))
|
|
261
|
+
elif child.type == "aliased_import":
|
|
262
|
+
name_n = child.child_by_field_name("name")
|
|
263
|
+
alias_n = child.child_by_field_name("alias")
|
|
264
|
+
if name_n:
|
|
265
|
+
item_name = source_bytes[name_n.start_byte:name_n.end_byte].decode("utf-8")
|
|
266
|
+
alias = source_bytes[alias_n.start_byte:alias_n.end_byte].decode("utf-8") if alias_n else None
|
|
267
|
+
items.append(ImportItem(name=item_name, alias=alias))
|
|
268
|
+
|
|
269
|
+
imports.append(
|
|
270
|
+
ImportStatement(
|
|
271
|
+
module=module_name,
|
|
272
|
+
items=items,
|
|
273
|
+
is_relative=is_relative,
|
|
274
|
+
level=level,
|
|
275
|
+
line_number=line_num,
|
|
276
|
+
file_path=file_path,
|
|
277
|
+
)
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
for child in node.children:
|
|
281
|
+
walk(child)
|
|
282
|
+
|
|
283
|
+
walk(tree.root_node)
|
|
284
|
+
return imports
|
|
285
|
+
|
|
286
|
+
def extract_calls(
|
|
287
|
+
self, source_code: str, file_path: str = "", symbols: list[Symbol] | None = None
|
|
288
|
+
) -> list[CallReference]:
|
|
289
|
+
if symbols is None:
|
|
290
|
+
symbols = self.extract_symbols(source_code, file_path=file_path)
|
|
291
|
+
|
|
292
|
+
source_bytes = source_code.encode("utf-8")
|
|
293
|
+
tree = self._parser.parse(source_bytes)
|
|
294
|
+
calls: list[CallReference] = []
|
|
295
|
+
|
|
296
|
+
def find_enclosing_symbol(line: int) -> str:
|
|
297
|
+
# Match most specific / deepest symbol containing line
|
|
298
|
+
matching = [s for s in symbols if s.start_line <= line <= s.end_line]
|
|
299
|
+
if not matching:
|
|
300
|
+
return "<module>"
|
|
301
|
+
# Sort by smallest line span
|
|
302
|
+
matching.sort(key=lambda s: (s.end_line - s.start_line))
|
|
303
|
+
return matching[0].name
|
|
304
|
+
|
|
305
|
+
def walk(node: Any) -> None:
|
|
306
|
+
if node.type == "call":
|
|
307
|
+
func_node = node.child_by_field_name("function")
|
|
308
|
+
if func_node:
|
|
309
|
+
callee_name = source_bytes[func_node.start_byte:func_node.end_byte].decode("utf-8", errors="replace")
|
|
310
|
+
# If attribute call like obj.method(), extract method name
|
|
311
|
+
if func_node.type == "attribute":
|
|
312
|
+
attr_node = func_node.child_by_field_name("attribute")
|
|
313
|
+
if attr_node:
|
|
314
|
+
callee_name = source_bytes[attr_node.start_byte:attr_node.end_byte].decode("utf-8", errors="replace")
|
|
315
|
+
|
|
316
|
+
line_num = node.start_point[0] + 1
|
|
317
|
+
caller = find_enclosing_symbol(line_num)
|
|
318
|
+
calls.append(
|
|
319
|
+
CallReference(
|
|
320
|
+
caller_symbol=caller,
|
|
321
|
+
callee_name=callee_name,
|
|
322
|
+
line_number=line_num,
|
|
323
|
+
file_path=file_path,
|
|
324
|
+
)
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
for child in node.children:
|
|
328
|
+
walk(child)
|
|
329
|
+
|
|
330
|
+
walk(tree.root_node)
|
|
331
|
+
return calls
|
codepulse/rate_limit.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Phase 1.4 — In-process token-bucket rate limiter (roadmap §37 item 1.4;
|
|
3
|
+
§6 "Recommended Architecture": rate limits live in the control plane /
|
|
4
|
+
API Gateway layer, alongside auth and central authorization; §35's
|
|
5
|
+
architecture diagram places "Rate limits" directly beside "Auth (API
|
|
6
|
+
keys)" and "Central authz"; backlog B11).
|
|
7
|
+
|
|
8
|
+
Scope, precisely (do not infer requirements from later milestones):
|
|
9
|
+
- A classic token-bucket algorithm (capacity + refill rate), entirely
|
|
10
|
+
in-process (a plain in-memory dict) -- no Redis, no database
|
|
11
|
+
persistence, no distributed state. State resets on process restart;
|
|
12
|
+
this project runs as one process, matching "in-process" literally
|
|
13
|
+
(roadmap §37/§45: "rate limiting (in-process token bucket, no Redis
|
|
14
|
+
yet)").
|
|
15
|
+
- One bucket per client identity: the raw `X-API-Key` header value if
|
|
16
|
+
present (and non-empty), else the client's IP address as a fallback
|
|
17
|
+
for unauthenticated requests -- so an unauthenticated flood doesn't
|
|
18
|
+
get an unlimited exemption, but distinct callers don't share one
|
|
19
|
+
bucket either. This does NOT verify the key (no DB lookup, no import
|
|
20
|
+
of `codepulse.auth`/`codepulse.api.security` here) -- the bucket is
|
|
21
|
+
keyed on the raw string alone, kept deliberately cheap and
|
|
22
|
+
independent; validity checking remains exactly `require_api_key`'s
|
|
23
|
+
job (Phase 1.2), unchanged and un-duplicated.
|
|
24
|
+
- Exceeding the bucket's capacity is reported as HTTP 429 with a real
|
|
25
|
+
`Retry-After` value (backlog B11's literal DoD: "Excess requests
|
|
26
|
+
receive 429 with retry-after"), computed as the actual number of
|
|
27
|
+
seconds until at least one token will be available -- never a
|
|
28
|
+
fabricated/estimated value.
|
|
29
|
+
|
|
30
|
+
Deliberately NOT in scope for Phase 1.4 (do not infer these belong here):
|
|
31
|
+
- Per-role/per-plan differentiated limits (roadmap §15's RBAC roles are
|
|
32
|
+
Phase 7 / B25 scope, not modeled at all yet).
|
|
33
|
+
- Distributed/shared rate limiting across multiple processes or
|
|
34
|
+
workers (explicitly deferred -- "no Redis yet").
|
|
35
|
+
- A structured `{"error": {...}}` response envelope for the 429 body --
|
|
36
|
+
Phase 1.6. The middleware in `codepulse/api/app.py` that uses this
|
|
37
|
+
module returns a plain `{"detail": "..."}` body, matching FastAPI's
|
|
38
|
+
own default `HTTPException` shape and every other error path already
|
|
39
|
+
in `api/app.py`.
|
|
40
|
+
- Ingestion-specific safety limits (path allow-listing, SSRF checks,
|
|
41
|
+
size caps) -- Phase 1.5 / B10, an entirely separate concern from
|
|
42
|
+
request-rate limiting.
|
|
43
|
+
|
|
44
|
+
Default parameters (`CodePulseConfig.rate_limit_requests_per_second`,
|
|
45
|
+
`.rate_limit_burst`) are a reasonable *starting* configuration for
|
|
46
|
+
local-first, single-developer use -- NOT a benchmarked/measured figure,
|
|
47
|
+
matching roadmap §19's own caution against presenting unvalidated
|
|
48
|
+
numbers as fact. Tune via config for a given workload.
|
|
49
|
+
|
|
50
|
+
This module lives outside `codepulse/indexer/` and `codepulse/api/`
|
|
51
|
+
deliberately -- a top-level module, mirroring `codepulse/auth.py` and
|
|
52
|
+
`codepulse/jobs.py`'s placement, per the roadmap's own "control plane"
|
|
53
|
+
grouping (§6) of auth/rate-limits/job-tracking as one layer, reusable
|
|
54
|
+
identically by a future CLI or MCP surface (though today only the REST
|
|
55
|
+
app installs it, as middleware).
|
|
56
|
+
"""
|
|
57
|
+
from __future__ import annotations
|
|
58
|
+
|
|
59
|
+
import threading
|
|
60
|
+
import time
|
|
61
|
+
from dataclasses import dataclass
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class _Bucket:
|
|
66
|
+
tokens: float
|
|
67
|
+
last_refill: float
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class TokenBucketLimiter:
|
|
71
|
+
"""
|
|
72
|
+
A plain in-memory token-bucket rate limiter, one bucket per identity
|
|
73
|
+
string. Thread-safe via a single lock guarding the bucket dict --
|
|
74
|
+
cheap and sufficient for this project's single-process, in-process
|
|
75
|
+
scale (no Redis, no multi-process coordination).
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def __init__(self, requests_per_second: float, burst: int) -> None:
|
|
79
|
+
self.requests_per_second = requests_per_second
|
|
80
|
+
self.burst = burst
|
|
81
|
+
self._buckets: dict[str, _Bucket] = {}
|
|
82
|
+
self._lock = threading.Lock()
|
|
83
|
+
|
|
84
|
+
def check(self, identity: str) -> tuple[bool, float]:
|
|
85
|
+
"""
|
|
86
|
+
Attempts to consume one token for `identity`. Returns
|
|
87
|
+
`(allowed, retry_after_seconds)`: `retry_after_seconds` is `0.0`
|
|
88
|
+
when `allowed` is True, or the real number of seconds until at
|
|
89
|
+
least one token will be available when it is False.
|
|
90
|
+
"""
|
|
91
|
+
now = time.monotonic()
|
|
92
|
+
with self._lock:
|
|
93
|
+
bucket = self._buckets.get(identity)
|
|
94
|
+
if bucket is None:
|
|
95
|
+
bucket = _Bucket(tokens=float(self.burst), last_refill=now)
|
|
96
|
+
self._buckets[identity] = bucket
|
|
97
|
+
|
|
98
|
+
elapsed = max(0.0, now - bucket.last_refill)
|
|
99
|
+
bucket.tokens = min(float(self.burst), bucket.tokens + elapsed * self.requests_per_second)
|
|
100
|
+
bucket.last_refill = now
|
|
101
|
+
|
|
102
|
+
if bucket.tokens >= 1.0:
|
|
103
|
+
bucket.tokens -= 1.0
|
|
104
|
+
return True, 0.0
|
|
105
|
+
|
|
106
|
+
missing = 1.0 - bucket.tokens
|
|
107
|
+
retry_after = missing / self.requests_per_second if self.requests_per_second > 0 else float("inf")
|
|
108
|
+
return False, retry_after
|
|
109
|
+
|
|
110
|
+
def reset(self) -> None:
|
|
111
|
+
"""Clears all bucket state -- primarily for test isolation."""
|
|
112
|
+
with self._lock:
|
|
113
|
+
self._buckets.clear()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
_DEFAULT_LIMITER: TokenBucketLimiter | None = None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def get_limiter() -> TokenBucketLimiter:
|
|
120
|
+
"""
|
|
121
|
+
Returns the process-wide default `TokenBucketLimiter`, constructed
|
|
122
|
+
from the currently active `CodePulseConfig` on first use (and cached
|
|
123
|
+
-- call `reset_limiter()` after changing rate-limit settings, e.g. in
|
|
124
|
+
tests, matching the same pattern `codepulse.config.reset_settings()`
|
|
125
|
+
already establishes for the settings singleton itself).
|
|
126
|
+
"""
|
|
127
|
+
global _DEFAULT_LIMITER
|
|
128
|
+
if _DEFAULT_LIMITER is None:
|
|
129
|
+
from codepulse.config import get_settings
|
|
130
|
+
|
|
131
|
+
settings = get_settings()
|
|
132
|
+
_DEFAULT_LIMITER = TokenBucketLimiter(
|
|
133
|
+
requests_per_second=settings.rate_limit_requests_per_second,
|
|
134
|
+
burst=settings.rate_limit_burst,
|
|
135
|
+
)
|
|
136
|
+
return _DEFAULT_LIMITER
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def reset_limiter() -> None:
|
|
140
|
+
"""Resets the module-level default limiter -- primarily for test isolation."""
|
|
141
|
+
global _DEFAULT_LIMITER
|
|
142
|
+
_DEFAULT_LIMITER = None
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""
|
|
2
|
+
One repository, one identity, however its path is spelled
|
|
3
|
+
(CODEPULSE_FUTURE_CHANGES.md item 2; user-approved 2026-10-14).
|
|
4
|
+
|
|
5
|
+
A repository is identified by `RepositoryModel.url` (unique). For a repository
|
|
6
|
+
indexed from a local directory that string was whatever the caller typed, so the
|
|
7
|
+
same directory spelled with backslashes vs forward slashes, a trailing
|
|
8
|
+
separator, `..` segments, or a lowercase vs uppercase Windows drive letter
|
|
9
|
+
(VS Code reports `c:\\x`, a CLI index stores `C:\\x`) became two repositories.
|
|
10
|
+
Re-indexing then created a duplicate with a new id and, because a differently
|
|
11
|
+
spelled path looked brand-new to the "re-indexing needs `indexer`" check, a
|
|
12
|
+
viewer-only key could bypass it.
|
|
13
|
+
|
|
14
|
+
`canonical_repository_url` is the one definition of identity:
|
|
15
|
+
- a LOCAL PATH becomes absolute, with symlinks and `..` resolved
|
|
16
|
+
(`os.path.realpath`, which on Windows also returns the true case of every
|
|
17
|
+
component and normalizes slashes), an uppercase drive letter on Windows (also
|
|
18
|
+
for a path that no longer exists, where realpath cannot fix the case), and no
|
|
19
|
+
trailing separator;
|
|
20
|
+
- anything that is not a local path -- a git URL, `https://...`, the synthetic
|
|
21
|
+
`file:///...` ids some tests use -- is returned exactly as given.
|
|
22
|
+
|
|
23
|
+
`find_repository` is the one lookup: it matches the canonical form first, then
|
|
24
|
+
any row stored under another spelling (a database not yet migrated, or a
|
|
25
|
+
duplicate that predates this fix), so a lookup never creates a duplicate just
|
|
26
|
+
because an old row has an old spelling. When several rows match it returns the
|
|
27
|
+
lowest id, deterministically; it never merges or deletes anything.
|
|
28
|
+
|
|
29
|
+
Limit: identity follows the filesystem. A mapped network drive resolves to its
|
|
30
|
+
UNC path while the mapping exists; a path on a filesystem that is
|
|
31
|
+
case-insensitive but where `realpath` cannot report the true case (a deleted
|
|
32
|
+
directory, macOS) can still be spelled in more than one way.
|
|
33
|
+
"""
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import os
|
|
37
|
+
import re
|
|
38
|
+
|
|
39
|
+
from sqlalchemy.orm import Session
|
|
40
|
+
|
|
41
|
+
from codepulse.storage.db import RepositoryModel
|
|
42
|
+
|
|
43
|
+
# `scheme://...` is a URL, not a path. A Windows drive (`C:\\x`, `C:/x`) has no `//` right after the colon.
|
|
44
|
+
_URL_SCHEME = re.compile(r"^[A-Za-z][A-Za-z0-9+.\-]*://")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def is_local_path(url: str) -> bool:
|
|
48
|
+
return bool(url) and not _URL_SCHEME.match(url)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def canonical_repository_url(url: str) -> str:
|
|
52
|
+
"""The one canonical identity for `url`. Non-paths are returned unchanged; see the module docstring."""
|
|
53
|
+
if not is_local_path(url):
|
|
54
|
+
return url
|
|
55
|
+
resolved = os.path.realpath(os.path.abspath(url))
|
|
56
|
+
if os.name == "nt" and len(resolved) >= 2 and resolved[1] == ":":
|
|
57
|
+
resolved = resolved[0].upper() + resolved[1:]
|
|
58
|
+
return resolved
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def identity_key(url: str) -> str:
|
|
62
|
+
"""The canonical form, case-folded where the filesystem is case-insensitive, for comparing two spellings."""
|
|
63
|
+
return os.path.normcase(canonical_repository_url(url)) if is_local_path(url) else url
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def find_repository(session: Session, url: str) -> RepositoryModel | None:
|
|
67
|
+
"""
|
|
68
|
+
The repository `url` names, under any spelling of the same directory, or None.
|
|
69
|
+
Prefers an exact canonical row, then an exact row, then any row whose canonical
|
|
70
|
+
identity matches (lowest id first).
|
|
71
|
+
"""
|
|
72
|
+
canonical = canonical_repository_url(url)
|
|
73
|
+
exact = session.query(RepositoryModel).filter(RepositoryModel.url == canonical).first()
|
|
74
|
+
if exact is not None or not is_local_path(url):
|
|
75
|
+
return exact
|
|
76
|
+
raw = session.query(RepositoryModel).filter(RepositoryModel.url == url).first()
|
|
77
|
+
if raw is not None:
|
|
78
|
+
return raw
|
|
79
|
+
wanted = identity_key(url)
|
|
80
|
+
for row in session.query(RepositoryModel).order_by(RepositoryModel.id).all():
|
|
81
|
+
if is_local_path(row.url) and identity_key(row.url) == wanted:
|
|
82
|
+
return row
|
|
83
|
+
return None
|