scry-run 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.
scry_run/context.py ADDED
@@ -0,0 +1,298 @@
1
+ """Context builder for collecting codebase and inserting hole markers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+
9
+ class ContextBuilder:
10
+ """Builds context strings from the codebase for LLM prompts.
11
+
12
+ Collects all Python files in a project and formats them as a single
13
+ context string, with a "hole" marker indicating where code is missing.
14
+ """
15
+
16
+ # Directories to always skip
17
+ SKIP_DIRS = {
18
+ "__pycache__",
19
+ ".git",
20
+ ".venv",
21
+ "venv",
22
+ ".env",
23
+ "node_modules",
24
+ ".pytest_cache",
25
+ ".mypy_cache",
26
+ ".ruff_cache",
27
+ "dist",
28
+ "build",
29
+ "*.egg-info",
30
+ ".scry-run-cache",
31
+ }
32
+
33
+ # File patterns to skip
34
+ SKIP_FILES = {
35
+ "*.pyc",
36
+ "*.pyo",
37
+ "*.pyd",
38
+ }
39
+
40
+ def __init__(self, root_dir: str | Path | None = None):
41
+ """Initialize context builder.
42
+
43
+ Args:
44
+ root_dir: Root directory of the project. Defaults to current directory.
45
+ """
46
+ self.root_dir = Path(root_dir) if root_dir else Path.cwd()
47
+
48
+ def _should_skip_dir(self, dir_path: Path) -> bool:
49
+ """Check if a directory should be skipped."""
50
+ name = dir_path.name
51
+ return name in self.SKIP_DIRS or name.startswith(".")
52
+
53
+ def _should_skip_file(self, file_path: Path) -> bool:
54
+ """Check if a file should be skipped."""
55
+ name = file_path.name
56
+ return any(
57
+ name.endswith(pattern.lstrip("*"))
58
+ for pattern in self.SKIP_FILES
59
+ )
60
+
61
+ def collect_python_files(self) -> list[Path]:
62
+ """Collect all Python files in the project.
63
+
64
+ Returns:
65
+ List of paths to Python files, relative to root_dir
66
+ """
67
+ files = []
68
+
69
+ def walk(directory: Path) -> None:
70
+ try:
71
+ for item in sorted(directory.iterdir()):
72
+ if item.is_dir():
73
+ if not self._should_skip_dir(item):
74
+ walk(item)
75
+ elif item.is_file() and item.suffix == ".py":
76
+ if not self._should_skip_file(item):
77
+ files.append(item)
78
+ except PermissionError:
79
+ pass
80
+
81
+ walk(self.root_dir)
82
+ return files
83
+
84
+ def read_file_content(self, file_path: Path) -> str:
85
+ """Read content of a file.
86
+
87
+ Args:
88
+ file_path: Path to the file
89
+
90
+ Returns:
91
+ File content as string
92
+ """
93
+ try:
94
+ return file_path.read_text(encoding="utf-8")
95
+ except (UnicodeDecodeError, PermissionError, FileNotFoundError):
96
+ return ""
97
+
98
+ def build_context(
99
+ self,
100
+ class_name: str,
101
+ attr_name: str,
102
+ class_source: Optional[str] = None,
103
+ ) -> str:
104
+ """Build full context with hole marker.
105
+
106
+ Args:
107
+ class_name: Name of the class needing the attribute
108
+ attr_name: Name of the missing attribute
109
+ class_source: Optional source code of the class itself
110
+
111
+ Returns:
112
+ Formatted context string with all files and hole marker
113
+ """
114
+ files = self.collect_python_files()
115
+
116
+ sections = []
117
+
118
+ # Add header
119
+ sections.append(f"# Project: {self.root_dir.name}")
120
+ sections.append("")
121
+
122
+ # Add each file
123
+ for file_path in files:
124
+ relative_path = file_path.relative_to(self.root_dir)
125
+ content = self.read_file_content(file_path)
126
+
127
+ if content:
128
+ sections.append(f"# --- {relative_path} ---")
129
+ sections.append(content)
130
+ sections.append("")
131
+
132
+ # Add hole marker
133
+ sections.append("# --- MISSING CODE ---")
134
+ sections.append(f"# Class: {class_name}")
135
+ sections.append(f"# Missing attribute: {attr_name}")
136
+ sections.append("#")
137
+ sections.append("# Generate the code for this attribute based on:")
138
+ sections.append("# 1. The class name and its docstring")
139
+ sections.append("# 2. Existing methods in the class")
140
+ sections.append("# 3. How the class is used in the codebase")
141
+ sections.append("# 4. The attribute name (infer purpose from naming)")
142
+
143
+ if class_source:
144
+ sections.append("")
145
+ sections.append("# Current class definition:")
146
+ sections.append(class_source)
147
+
148
+ sections.append("# --- END MISSING CODE ---")
149
+
150
+ return "\n".join(sections)
151
+
152
+ def build_minimal_context(
153
+ self,
154
+ class_source: str,
155
+ class_name: str,
156
+ attr_name: str,
157
+ additional_context: Optional[str] = None,
158
+ ) -> str:
159
+ """Build minimal context with just the class and hole marker.
160
+
161
+ Useful for smaller, faster generation when full codebase isn't needed.
162
+
163
+ Args:
164
+ class_source: Source code of the class
165
+ class_name: Name of the class
166
+ attr_name: Name of the missing attribute
167
+ additional_context: Optional additional context to include
168
+
169
+ Returns:
170
+ Formatted context string
171
+ """
172
+ sections = [
173
+ "# Class definition:",
174
+ class_source,
175
+ "",
176
+ "# --- MISSING CODE ---",
177
+ f"# Class: {class_name}",
178
+ f"# Missing attribute: {attr_name}",
179
+ ]
180
+
181
+ if additional_context:
182
+ sections.extend([
183
+ "",
184
+ "# Additional context:",
185
+ additional_context,
186
+ ])
187
+
188
+ sections.append("# --- END MISSING CODE ---")
189
+
190
+ return "\n".join(sections)
191
+
192
+ @staticmethod
193
+ def build_runtime_context() -> str:
194
+ """Capture runtime context from the call stack.
195
+
196
+ Captures the call stack and local variables to give the LLM
197
+ context about how the missing method is being called.
198
+
199
+ Returns:
200
+ Formatted string with call stack and variable info
201
+ """
202
+ import sys
203
+ import traceback
204
+
205
+ lines = ["# === RUNTIME CONTEXT ===", ""]
206
+
207
+ # Get the current frame and walk up the stack
208
+ frame = sys._getframe()
209
+
210
+ # Collect relevant frames (skip scry_run internals)
211
+ relevant_frames = []
212
+ current = frame
213
+ while current and len(relevant_frames) < 50:
214
+ filename = current.f_code.co_filename
215
+ # Skip scry_run internal frames (but include test files)
216
+ if "scry_run" not in filename or "test" in filename:
217
+ relevant_frames.append(current)
218
+ current = current.f_back
219
+
220
+ if relevant_frames:
221
+ lines.append("# Call stack (most recent call first):")
222
+ lines.append("")
223
+
224
+ for i, f in enumerate(relevant_frames[:50]): # Limit to 50 frames
225
+ filename = f.f_code.co_filename
226
+ lineno = f.f_lineno
227
+ funcname = f.f_code.co_name
228
+
229
+ # Try to get the source line
230
+ try:
231
+ import linecache
232
+ source_line = linecache.getline(filename, lineno).strip()
233
+ except Exception:
234
+ source_line = "<source unavailable>"
235
+
236
+ lines.append(f"# Frame {i}: {funcname}() at {filename}:{lineno}")
237
+ lines.append(f"# {source_line}")
238
+
239
+ # Capture local variables (but not too many)
240
+ locals_dict = f.f_locals
241
+ if locals_dict:
242
+ # Filter out internal/large objects
243
+ interesting_vars = {}
244
+ for name, value in locals_dict.items():
245
+ if name.startswith("_"):
246
+ continue
247
+ # Skip self/cls as they're obvious
248
+ if name in ("self", "cls"):
249
+ continue
250
+ # Try to get a reasonable repr
251
+ try:
252
+ value_repr = repr(value)
253
+ if len(value_repr) > 200:
254
+ value_repr = value_repr[:200] + "..."
255
+ interesting_vars[name] = value_repr
256
+ except Exception:
257
+ interesting_vars[name] = "<unable to repr>"
258
+
259
+ if interesting_vars:
260
+ lines.append(f"# Local variables:")
261
+ for name, val in list(interesting_vars.items())[:5]:
262
+ lines.append(f"# {name} = {val}")
263
+
264
+ lines.append("")
265
+
266
+ # Add the immediate caller's arguments if this is a method call
267
+ if relevant_frames:
268
+ caller = relevant_frames[0]
269
+ caller_locals = caller.f_locals
270
+
271
+ # Get function arguments from the frame
272
+ code = caller.f_code
273
+ arg_names = code.co_varnames[:code.co_argcount]
274
+
275
+ args_info = {}
276
+ for arg_name in arg_names:
277
+ if arg_name in ("self", "cls"):
278
+ continue
279
+ if arg_name in caller_locals:
280
+ try:
281
+ val = repr(caller_locals[arg_name])
282
+ if len(val) > 200:
283
+ val = val[:200] + "..."
284
+ args_info[arg_name] = val
285
+ except Exception:
286
+ pass
287
+
288
+ if args_info:
289
+ lines.append("# === CALL ARGUMENTS ===")
290
+ lines.append("# The method was called with these arguments:")
291
+ for name, val in args_info.items():
292
+ lines.append(f"# {name} = {val}")
293
+ lines.append("")
294
+
295
+ lines.append("# === END RUNTIME CONTEXT ===")
296
+
297
+ return "\n".join(lines)
298
+