diffcontext 0.5.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.
- diffcontext/__init__.py +233 -0
- diffcontext/_warn_once.py +112 -0
- diffcontext/cache.py +216 -0
- diffcontext/cli/__init__.py +655 -0
- diffcontext/context/__init__.py +1 -0
- diffcontext/context/compiler.py +643 -0
- diffcontext/context/selector.py +258 -0
- diffcontext/diff/__init__.py +1 -0
- diffcontext/diff/git_diff.py +298 -0
- diffcontext/diff/state_manager.py +75 -0
- diffcontext/graph_builder.py +1026 -0
- diffcontext/history.py +154 -0
- diffcontext/impact/__init__.py +1 -0
- diffcontext/impact/blast_radius.py +58 -0
- diffcontext/impact/scoring.py +223 -0
- diffcontext/impact/traversal.py +58 -0
- diffcontext/impact/visualizer.py +338 -0
- diffcontext/languages/__init__.py +80 -0
- diffcontext/languages/typescript.py +960 -0
- diffcontext/lexical.py +108 -0
- diffcontext/models.py +180 -0
- diffcontext/parser.py +183 -0
- diffcontext/pipeline.py +887 -0
- diffcontext/py.typed +0 -0
- diffcontext/rerank/__init__.py +17 -0
- diffcontext/rerank/features.py +356 -0
- diffcontext/rerank/model.py +175 -0
- diffcontext/resolver.py +288 -0
- diffcontext/scanner.py +153 -0
- diffcontext/symbols.py +254 -0
- diffcontext/verify/__init__.py +68 -0
- diffcontext/verify/cases.py +631 -0
- diffcontext/verify/history.py +396 -0
- diffcontext/verify/sufficiency.py +324 -0
- diffcontext-0.5.1.dist-info/METADATA +219 -0
- diffcontext-0.5.1.dist-info/RECORD +40 -0
- diffcontext-0.5.1.dist-info/WHEEL +5 -0
- diffcontext-0.5.1.dist-info/entry_points.txt +2 -0
- diffcontext-0.5.1.dist-info/licenses/LICENSE +21 -0
- diffcontext-0.5.1.dist-info/top_level.txt +1 -0
diffcontext/lexical.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""
|
|
2
|
+
lexical.py — BM25 lexical retrieval signal.
|
|
3
|
+
|
|
4
|
+
Pure-stdlib BM25Okapi over symbol source code, used as the lexical leg of
|
|
5
|
+
hybrid retrieval. The eval_v2 benchmark showed the call graph alone loses
|
|
6
|
+
to full-code BM25 on most measures, while the graph+BM25+same-file blend
|
|
7
|
+
beats every individual signal on 4/5 repos (see benchmarks/EVAL_V2_REPORT.md);
|
|
8
|
+
this module is that lexical leg, with no third-party dependency.
|
|
9
|
+
|
|
10
|
+
The math replicates rank_bm25's BM25Okapi (k1=1.5, b=0.75, and negative-idf
|
|
11
|
+
flooring at epsilon * average_idf) so product scores match the benchmarked
|
|
12
|
+
implementation. Scoring uses an inverted index, so a query only touches
|
|
13
|
+
documents that share at least one term with it — indexing a ~9k-symbol repo
|
|
14
|
+
takes ~1s and a query a few milliseconds.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import math
|
|
18
|
+
import re
|
|
19
|
+
from collections import Counter, defaultdict
|
|
20
|
+
from typing import Dict, List
|
|
21
|
+
|
|
22
|
+
from .models import Symbol
|
|
23
|
+
|
|
24
|
+
K1 = 1.5
|
|
25
|
+
B = 0.75
|
|
26
|
+
EPSILON = 0.25 # floor for negative idf, as a fraction of average idf
|
|
27
|
+
|
|
28
|
+
_TOKEN_RE = re.compile(r"[a-zA-Z_][a-zA-Z0-9_]*")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def tokenize(code: str) -> List[str]:
|
|
32
|
+
"""Identifier tokens, lowercased, single-char tokens dropped."""
|
|
33
|
+
return [t.lower() for t in _TOKEN_RE.findall(code) if len(t) > 1]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class LexicalIndex:
|
|
37
|
+
"""BM25 index over every symbol's source code."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, symbols: Dict[str, Symbol]):
|
|
40
|
+
self.ids: List[str] = list(symbols.keys())
|
|
41
|
+
n = len(self.ids)
|
|
42
|
+
|
|
43
|
+
doc_len: List[int] = []
|
|
44
|
+
term_doc_freq: Counter = Counter() # term -> #docs containing it
|
|
45
|
+
postings: Dict[str, List] = defaultdict(list) # term -> [(doc_idx, tf)]
|
|
46
|
+
|
|
47
|
+
for i, sid in enumerate(self.ids):
|
|
48
|
+
tf = Counter(tokenize(symbols[sid].code))
|
|
49
|
+
doc_len.append(sum(tf.values()))
|
|
50
|
+
for term, count in tf.items():
|
|
51
|
+
term_doc_freq[term] += 1
|
|
52
|
+
postings[term].append((i, count))
|
|
53
|
+
|
|
54
|
+
self.doc_len = doc_len
|
|
55
|
+
self.avgdl = sum(doc_len) / n if n else 0.0
|
|
56
|
+
self.postings = postings
|
|
57
|
+
|
|
58
|
+
# idf with rank_bm25's negative-idf flooring
|
|
59
|
+
idf: Dict[str, float] = {}
|
|
60
|
+
idf_sum, negatives = 0.0, []
|
|
61
|
+
for term, df in term_doc_freq.items():
|
|
62
|
+
v = math.log(n - df + 0.5) - math.log(df + 0.5)
|
|
63
|
+
idf[term] = v
|
|
64
|
+
idf_sum += v
|
|
65
|
+
if v < 0:
|
|
66
|
+
negatives.append(term)
|
|
67
|
+
if idf:
|
|
68
|
+
# rank_bm25 floors negative idf at EPSILON * average_idf, but on
|
|
69
|
+
# tiny corpora the average itself can be negative, which would
|
|
70
|
+
# make the floor negative and silently zero out every match.
|
|
71
|
+
# Clamp the floor to a small positive value instead.
|
|
72
|
+
floor = EPSILON * (idf_sum / len(idf))
|
|
73
|
+
if floor <= 0:
|
|
74
|
+
floor = EPSILON
|
|
75
|
+
for term in negatives:
|
|
76
|
+
idf[term] = floor
|
|
77
|
+
self.idf = idf
|
|
78
|
+
|
|
79
|
+
def scores_for(self, query_code: str) -> Dict[str, float]:
|
|
80
|
+
"""
|
|
81
|
+
BM25 scores of every symbol against `query_code`.
|
|
82
|
+
|
|
83
|
+
Returns only symbols with a positive score. Duplicate query terms
|
|
84
|
+
contribute once per occurrence (same as BM25Okapi.get_scores).
|
|
85
|
+
"""
|
|
86
|
+
if not self.ids or self.avgdl == 0:
|
|
87
|
+
return {}
|
|
88
|
+
query_tf = Counter(tokenize(query_code))
|
|
89
|
+
scores: Dict[int, float] = defaultdict(float)
|
|
90
|
+
for term, q_count in query_tf.items():
|
|
91
|
+
idf = self.idf.get(term)
|
|
92
|
+
if idf is None:
|
|
93
|
+
continue
|
|
94
|
+
for doc_idx, f in self.postings[term]:
|
|
95
|
+
denom = f + K1 * (1 - B + B * self.doc_len[doc_idx] / self.avgdl)
|
|
96
|
+
scores[doc_idx] += q_count * idf * f * (K1 + 1) / denom
|
|
97
|
+
return {self.ids[i]: s for i, s in scores.items() if s > 0}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def get_lexical_index(index) -> LexicalIndex:
|
|
101
|
+
"""
|
|
102
|
+
Return the RepositoryIndex's lexical index, building and caching it on
|
|
103
|
+
first use. pipeline.update_index() invalidates the cache when symbols
|
|
104
|
+
change, so a stale index is never served.
|
|
105
|
+
"""
|
|
106
|
+
if index._lexical is None:
|
|
107
|
+
index._lexical = LexicalIndex(index.symbols)
|
|
108
|
+
return index._lexical
|
diffcontext/models.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""
|
|
2
|
+
models.py — Data classes used across the pipeline.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Dict, List, Optional, Set
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Symbol:
|
|
11
|
+
"""A single extracted symbol (function/method)."""
|
|
12
|
+
id: str # "./path.py:ClassName.method" or "./path.py:func"
|
|
13
|
+
file: str # absolute path to source file
|
|
14
|
+
name: str # bare name like "ClassName.method" or "func"
|
|
15
|
+
code: str # source code text
|
|
16
|
+
lineno: int = 0 # start line number
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class RepositoryIndex:
|
|
21
|
+
"""Complete index of a repository."""
|
|
22
|
+
symbols: Dict[str, Symbol] = field(default_factory=dict) # id -> Symbol
|
|
23
|
+
graph: Dict[str, List[str]] = field(default_factory=dict) # id -> [dependency ids]
|
|
24
|
+
broken_files: List[str] = field(default_factory=list)
|
|
25
|
+
|
|
26
|
+
# Incremental-update state, populated by pipeline.index_repository().
|
|
27
|
+
# Private: not part of the public API, excluded from repr/comparison.
|
|
28
|
+
_repo_path: Optional[str] = field(default=None, repr=False, compare=False)
|
|
29
|
+
_file_trees: Optional[Dict] = field(default=None, repr=False, compare=False)
|
|
30
|
+
_import_maps: Optional[Dict] = field(default=None, repr=False, compare=False)
|
|
31
|
+
_warn_state: Optional[object] = field(default=None, repr=False, compare=False)
|
|
32
|
+
# Lazily built BM25 index (see lexical.get_lexical_index); invalidated
|
|
33
|
+
# by update_index() whenever symbols change.
|
|
34
|
+
_lexical: Optional[object] = field(default=None, repr=False, compare=False)
|
|
35
|
+
# Lazily built reverse call graph; invalidated by update_index()
|
|
36
|
+
# whenever the forward graph is rebuilt.
|
|
37
|
+
_reverse_graph: Optional[Dict[str, Set[str]]] = field(default=None, repr=False, compare=False)
|
|
38
|
+
# Per-language-adapter edge sets (adapter name -> {id: [dep ids]}),
|
|
39
|
+
# kept separately so update_index() can rebuild the Python part
|
|
40
|
+
# without re-running unchanged language adapters. None on a
|
|
41
|
+
# graph-cache warm start (recomputed on first update that needs it).
|
|
42
|
+
_lang_graphs: Optional[Dict[str, Dict[str, List[str]]]] = field(default=None, repr=False, compare=False)
|
|
43
|
+
|
|
44
|
+
def update(self, changed_files: List[str]) -> "RepositoryIndex":
|
|
45
|
+
"""
|
|
46
|
+
Incrementally re-index after `changed_files` were edited, created,
|
|
47
|
+
or deleted. Only those files are re-read and re-parsed; the graph
|
|
48
|
+
is rebuilt from in-memory ASTs. Mutates and returns this index.
|
|
49
|
+
|
|
50
|
+
Only available on indexes created by pipeline.index_repository().
|
|
51
|
+
"""
|
|
52
|
+
from .pipeline import update_index
|
|
53
|
+
return update_index(self, changed_files)
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def reverse_graph(self) -> Dict[str, Set[str]]:
|
|
57
|
+
"""
|
|
58
|
+
Reverse graph (callers of each symbol), computed once per index
|
|
59
|
+
state and cached. Treat the returned dict as read-only: it is
|
|
60
|
+
shared across callers and only invalidated by update().
|
|
61
|
+
"""
|
|
62
|
+
if self._reverse_graph is None:
|
|
63
|
+
rev: Dict[str, Set[str]] = {}
|
|
64
|
+
for caller, callees in self.graph.items():
|
|
65
|
+
for callee in callees:
|
|
66
|
+
rev.setdefault(callee, set()).add(caller)
|
|
67
|
+
self._reverse_graph = rev
|
|
68
|
+
return self._reverse_graph
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def total_edges(self) -> int:
|
|
72
|
+
return sum(len(deps) for deps in self.graph.values())
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class DiffResult:
|
|
77
|
+
"""Result of comparing two states."""
|
|
78
|
+
modified: List[str] = field(default_factory=list)
|
|
79
|
+
added: List[str] = field(default_factory=list)
|
|
80
|
+
deleted: List[str] = field(default_factory=list)
|
|
81
|
+
broken_files: List[str] = field(default_factory=list)
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def all_changed(self) -> List[str]:
|
|
85
|
+
return self.modified + self.added
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class ImpactResult:
|
|
90
|
+
"""Result of impact analysis."""
|
|
91
|
+
changed: List[str] = field(default_factory=list)
|
|
92
|
+
blast_radius: List[str] = field(default_factory=list)
|
|
93
|
+
dependencies: List[str] = field(default_factory=list)
|
|
94
|
+
scores: Dict[str, float] = field(default_factory=dict)
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def all_relevant(self) -> List[str]:
|
|
98
|
+
"""All symbols that should be in context, deduplicated, ordered by score."""
|
|
99
|
+
seen = set()
|
|
100
|
+
result = []
|
|
101
|
+
# Score-ordered
|
|
102
|
+
scored = sorted(self.scores.items(), key=lambda x: x[1], reverse=True)
|
|
103
|
+
for sym_id, _ in scored:
|
|
104
|
+
if sym_id not in seen:
|
|
105
|
+
seen.add(sym_id)
|
|
106
|
+
result.append(sym_id)
|
|
107
|
+
# Any remaining that weren't scored
|
|
108
|
+
for sym_id in self.changed + self.blast_radius + self.dependencies:
|
|
109
|
+
if sym_id not in seen:
|
|
110
|
+
seen.add(sym_id)
|
|
111
|
+
result.append(sym_id)
|
|
112
|
+
return result
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass
|
|
116
|
+
class ContextItem:
|
|
117
|
+
"""
|
|
118
|
+
One selected symbol, in structured form. The base representation of a
|
|
119
|
+
compiled context: a harness can filter, reorder, and re-budget these
|
|
120
|
+
itself instead of consuming the pre-rendered text.
|
|
121
|
+
"""
|
|
122
|
+
symbol_id: str # "./path.py:ClassName.method"
|
|
123
|
+
code: str # full source of the symbol
|
|
124
|
+
score: float # impact score (higher = more relevant)
|
|
125
|
+
role: str # "changed" | "impacted" | "dependency"
|
|
126
|
+
callers: List[str] = field(default_factory=list) # full list, untruncated
|
|
127
|
+
callees: List[str] = field(default_factory=list) # full list, untruncated
|
|
128
|
+
token_estimate: int = 0
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass
|
|
132
|
+
class ContextPackage:
|
|
133
|
+
"""
|
|
134
|
+
Final compiled context for an LLM.
|
|
135
|
+
|
|
136
|
+
`items` is the structured base representation; `text` is one renderer
|
|
137
|
+
over it (meta-header + sections + suggestions) for direct LLM pasting.
|
|
138
|
+
"""
|
|
139
|
+
text: str
|
|
140
|
+
symbol_count: int
|
|
141
|
+
token_estimate: int
|
|
142
|
+
total_repo_tokens: int
|
|
143
|
+
# Structured selection — the machine-consumable form of `text`'s body.
|
|
144
|
+
items: List[ContextItem] = field(default_factory=list)
|
|
145
|
+
# LLM self-awareness fields
|
|
146
|
+
dropped_symbols: List[str] = field(default_factory=list) # scored but cut by budget
|
|
147
|
+
skipped_files: List[str] = field(default_factory=list) # SyntaxError'd files
|
|
148
|
+
graph_confidence: float = 1.0 # fraction of edges that resolved
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def reduction_pct(self) -> float:
|
|
152
|
+
if self.total_repo_tokens == 0:
|
|
153
|
+
return 0.0
|
|
154
|
+
return (1 - self.token_estimate / self.total_repo_tokens) * 100
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass
|
|
158
|
+
class BenchmarkResult:
|
|
159
|
+
"""Result of a single benchmark run."""
|
|
160
|
+
name: str
|
|
161
|
+
repo_path: str
|
|
162
|
+
changed_functions: List[str]
|
|
163
|
+
# Graph stats
|
|
164
|
+
total_symbols: int = 0
|
|
165
|
+
total_edges: int = 0
|
|
166
|
+
graph_build_ms: float = 0.0
|
|
167
|
+
# Retrieval stats
|
|
168
|
+
retrieved_count: int = 0
|
|
169
|
+
retrieved_ids: List[str] = field(default_factory=list)
|
|
170
|
+
# Token stats
|
|
171
|
+
total_tokens: int = 0
|
|
172
|
+
context_tokens: int = 0
|
|
173
|
+
token_reduction_pct: float = 0.0
|
|
174
|
+
function_reduction_pct: float = 0.0
|
|
175
|
+
# Precision/Recall (when ground truth available)
|
|
176
|
+
precision: Optional[float] = None
|
|
177
|
+
recall: Optional[float] = None
|
|
178
|
+
f1: Optional[float] = None
|
|
179
|
+
# Timing
|
|
180
|
+
pipeline_ms: float = 0.0
|
diffcontext/parser.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""
|
|
2
|
+
parser.py — AST-based symbol extraction from Python source files.
|
|
3
|
+
|
|
4
|
+
Extracts functions, methods (including async), with class-aware naming.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import ast
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
from typing import Dict, List, Optional
|
|
11
|
+
|
|
12
|
+
from .models import Symbol
|
|
13
|
+
from ._warn_once import warn_syntax_error_once, check_and_warn_encoding
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# `def`/`class` are statements, so collection only needs to descend
|
|
19
|
+
# through statement blocks — expression subtrees (the majority of AST
|
|
20
|
+
# nodes) can never contain a definition. Field order mirrors the AST's
|
|
21
|
+
# own field order so collection order matches a full NodeVisitor walk.
|
|
22
|
+
_STMT_BLOCK_FIELDS = ("body", "handlers", "orelse", "finalbody", "cases")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _collect_functions(tree: "ast.Module") -> "List[tuple]":
|
|
26
|
+
"""
|
|
27
|
+
Collect (qualified_name, node) for every function/method definition,
|
|
28
|
+
including nested functions, methods of classes defined inside
|
|
29
|
+
functions, and definitions under conditional blocks (`if
|
|
30
|
+
TYPE_CHECKING:`, `try/except ImportError`, `match`).
|
|
31
|
+
"""
|
|
32
|
+
collected: "List[tuple]" = []
|
|
33
|
+
class_stack: "List[str]" = []
|
|
34
|
+
|
|
35
|
+
def _walk(stmts):
|
|
36
|
+
for node in stmts:
|
|
37
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
38
|
+
if class_stack:
|
|
39
|
+
name = ".".join(class_stack) + "." + node.name
|
|
40
|
+
else:
|
|
41
|
+
name = node.name
|
|
42
|
+
collected.append((name, node))
|
|
43
|
+
_walk(node.body)
|
|
44
|
+
elif isinstance(node, ast.ClassDef):
|
|
45
|
+
class_stack.append(node.name)
|
|
46
|
+
_walk(node.body)
|
|
47
|
+
class_stack.pop()
|
|
48
|
+
else:
|
|
49
|
+
for field in _STMT_BLOCK_FIELDS:
|
|
50
|
+
block = getattr(node, field, None)
|
|
51
|
+
if block:
|
|
52
|
+
_walk(block)
|
|
53
|
+
|
|
54
|
+
_walk(tree.body)
|
|
55
|
+
return collected
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _segment_lines(source: str) -> "Optional[List[str]]":
|
|
59
|
+
"""
|
|
60
|
+
Pre-split source for fast per-symbol segment slicing.
|
|
61
|
+
|
|
62
|
+
`ast.get_source_segment` re-splits the ENTIRE file for every symbol —
|
|
63
|
+
on a large repo that is the single biggest cold-index cost. Splitting
|
|
64
|
+
once per file and slicing per symbol is equivalent, but only when the
|
|
65
|
+
file has no `\\r` or `\\f` characters (the parser's line accounting
|
|
66
|
+
treats those specially); return None then, and the caller falls back
|
|
67
|
+
to `ast.get_source_segment` for that file.
|
|
68
|
+
"""
|
|
69
|
+
if "\r" in source or "\f" in source:
|
|
70
|
+
return None
|
|
71
|
+
return source.split("\n")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _fast_segment(lines: "List[str]", node) -> "Optional[str]":
|
|
75
|
+
"""Slice a node's source from pre-split lines. AST column offsets are
|
|
76
|
+
UTF-8 byte offsets, so non-ASCII boundary lines go through bytes."""
|
|
77
|
+
end_lineno = getattr(node, "end_lineno", None)
|
|
78
|
+
end_col = getattr(node, "end_col_offset", None)
|
|
79
|
+
if end_lineno is None or end_col is None:
|
|
80
|
+
return None
|
|
81
|
+
lineno = node.lineno - 1
|
|
82
|
+
end_lineno -= 1
|
|
83
|
+
col = node.col_offset
|
|
84
|
+
|
|
85
|
+
def _cols(line: str, start: "Optional[int]", end: "Optional[int]") -> str:
|
|
86
|
+
if line.isascii():
|
|
87
|
+
return line[start:end]
|
|
88
|
+
return line.encode("utf-8")[start:end].decode("utf-8")
|
|
89
|
+
|
|
90
|
+
if end_lineno == lineno:
|
|
91
|
+
return _cols(lines[lineno], col, end_col)
|
|
92
|
+
first = _cols(lines[lineno], col, None)
|
|
93
|
+
last = _cols(lines[end_lineno], None, end_col)
|
|
94
|
+
return "\n".join([first, *lines[lineno + 1 : end_lineno], last])
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def extract_symbols(
|
|
98
|
+
filename: str,
|
|
99
|
+
repo_path: str,
|
|
100
|
+
broken_files: "Optional[List[str]]" = None,
|
|
101
|
+
source: "Optional[str]" = None,
|
|
102
|
+
tree: "Optional[ast.Module]" = None,
|
|
103
|
+
) -> Dict[str, Symbol]:
|
|
104
|
+
"""
|
|
105
|
+
Parse a single Python file, return dict of symbol_id -> Symbol.
|
|
106
|
+
|
|
107
|
+
Symbol IDs look like: "./relative/path.py:ClassName.method_name"
|
|
108
|
+
|
|
109
|
+
If parsing fails and `broken_files` is provided (a list), the file's
|
|
110
|
+
relative path is appended to it so callers can distinguish "file failed
|
|
111
|
+
to parse" from "file legitimately has no functions."
|
|
112
|
+
|
|
113
|
+
`source` and `tree` may be supplied together to reuse an already-read,
|
|
114
|
+
already-parsed file (the pipeline parses each file exactly once and
|
|
115
|
+
shares the result); both must correspond to the same file contents.
|
|
116
|
+
"""
|
|
117
|
+
relative_file = "./" + os.path.relpath(filename, repo_path)
|
|
118
|
+
|
|
119
|
+
if source is None or tree is None:
|
|
120
|
+
with open(filename, "rb") as f:
|
|
121
|
+
raw = f.read()
|
|
122
|
+
check_and_warn_encoding(logger, filename, raw)
|
|
123
|
+
source = raw.decode("utf-8", errors="ignore")
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
tree = ast.parse(source)
|
|
127
|
+
except SyntaxError as e:
|
|
128
|
+
warn_syntax_error_once(logger, filename, e)
|
|
129
|
+
if broken_files is not None:
|
|
130
|
+
broken_files.append(relative_file)
|
|
131
|
+
return {}
|
|
132
|
+
|
|
133
|
+
seg_lines = _segment_lines(source)
|
|
134
|
+
|
|
135
|
+
symbols = {}
|
|
136
|
+
for name, node in _collect_functions(tree):
|
|
137
|
+
symbol_id = f"{relative_file}:{name}"
|
|
138
|
+
if seg_lines is not None:
|
|
139
|
+
try:
|
|
140
|
+
code = _fast_segment(seg_lines, node)
|
|
141
|
+
except (IndexError, UnicodeError):
|
|
142
|
+
code = ast.get_source_segment(source, node)
|
|
143
|
+
else:
|
|
144
|
+
code = ast.get_source_segment(source, node)
|
|
145
|
+
if code is None:
|
|
146
|
+
continue
|
|
147
|
+
symbols[symbol_id] = Symbol(
|
|
148
|
+
id=symbol_id,
|
|
149
|
+
file=filename,
|
|
150
|
+
name=name,
|
|
151
|
+
code=code,
|
|
152
|
+
lineno=node.lineno,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
return symbols
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def extract_all_symbols(
|
|
159
|
+
repo_path: str,
|
|
160
|
+
broken_files: "Optional[List[str]]" = None,
|
|
161
|
+
) -> Dict[str, Symbol]:
|
|
162
|
+
"""
|
|
163
|
+
Extract symbols from all Python files in a repository.
|
|
164
|
+
|
|
165
|
+
If `broken_files` is provided (a list), relative paths of any files
|
|
166
|
+
that failed to parse (SyntaxError) are appended to it.
|
|
167
|
+
"""
|
|
168
|
+
from .scanner import find_python_files
|
|
169
|
+
from .cache import SymbolCache
|
|
170
|
+
|
|
171
|
+
repo_path = os.path.abspath(repo_path)
|
|
172
|
+
all_symbols: Dict[str, Symbol] = {}
|
|
173
|
+
|
|
174
|
+
db_path = os.path.join(repo_path, ".diffcontext_cache.db")
|
|
175
|
+
|
|
176
|
+
with SymbolCache(db_path) as cache:
|
|
177
|
+
for filepath in find_python_files(repo_path):
|
|
178
|
+
def _parse(path: str) -> Dict[str, Symbol]:
|
|
179
|
+
return extract_symbols(path, repo_path, broken_files=broken_files)
|
|
180
|
+
|
|
181
|
+
all_symbols.update(cache.get_or_parse(filepath, _parse))
|
|
182
|
+
|
|
183
|
+
return all_symbols
|