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/__init__.py +102 -0
- scry_run/backends/__init__.py +6 -0
- scry_run/backends/base.py +65 -0
- scry_run/backends/claude.py +404 -0
- scry_run/backends/frozen.py +85 -0
- scry_run/backends/registry.py +72 -0
- scry_run/cache.py +441 -0
- scry_run/cli/__init__.py +137 -0
- scry_run/cli/apps.py +396 -0
- scry_run/cli/cache.py +342 -0
- scry_run/cli/config_cmd.py +84 -0
- scry_run/cli/env.py +27 -0
- scry_run/cli/init.py +375 -0
- scry_run/cli/run.py +71 -0
- scry_run/config.py +141 -0
- scry_run/console.py +52 -0
- scry_run/context.py +298 -0
- scry_run/generator.py +698 -0
- scry_run/home.py +60 -0
- scry_run/logging.py +171 -0
- scry_run/meta.py +1852 -0
- scry_run/packages.py +175 -0
- scry_run-0.1.0.dist-info/METADATA +282 -0
- scry_run-0.1.0.dist-info/RECORD +26 -0
- scry_run-0.1.0.dist-info/WHEEL +4 -0
- scry_run-0.1.0.dist-info/entry_points.txt +2 -0
scry_run/generator.py
ADDED
|
@@ -0,0 +1,698 @@
|
|
|
1
|
+
"""Code generator using pluggable LLM backends with structured JSON output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
import random
|
|
10
|
+
import json
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
from scry_run.console import warning
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class GeneratedCode:
|
|
19
|
+
"""Result of code generation."""
|
|
20
|
+
|
|
21
|
+
code: str
|
|
22
|
+
code_type: str # method, property, classmethod, staticmethod
|
|
23
|
+
docstring: str
|
|
24
|
+
dependencies: list[str]
|
|
25
|
+
packages: list[str] = None # PyPI packages to install
|
|
26
|
+
|
|
27
|
+
def __post_init__(self):
|
|
28
|
+
if self.packages is None:
|
|
29
|
+
self.packages = []
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# JSON schema for structured output
|
|
33
|
+
CODE_GENERATION_SCHEMA = {
|
|
34
|
+
"type": "object",
|
|
35
|
+
"properties": {
|
|
36
|
+
"code": {
|
|
37
|
+
"type": "string",
|
|
38
|
+
"description": "The Python code for the requested method/property. Must be valid Python that can be exec'd."
|
|
39
|
+
},
|
|
40
|
+
"code_type": {
|
|
41
|
+
"type": "string",
|
|
42
|
+
"enum": ["method", "property", "classmethod", "staticmethod"],
|
|
43
|
+
"description": "The type of code being generated"
|
|
44
|
+
},
|
|
45
|
+
"docstring": {
|
|
46
|
+
"type": "string",
|
|
47
|
+
"description": "A brief description of what this code does"
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"type": "array",
|
|
51
|
+
"items": {"type": "string"},
|
|
52
|
+
"description": "List of import statements needed (e.g., 'import json', 'from pathlib import Path')"
|
|
53
|
+
},
|
|
54
|
+
"packages": {
|
|
55
|
+
"type": "array",
|
|
56
|
+
"items": {"type": "string"},
|
|
57
|
+
"description": "List of PyPI packages to install (e.g., 'pygame', 'requests>=2.0'). Only include packages not in the standard library."
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"required": ["code", "code_type", "docstring", "dependencies", "packages"]
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# =============================================================================
|
|
65
|
+
# User-Friendly Exception Classes
|
|
66
|
+
# =============================================================================
|
|
67
|
+
|
|
68
|
+
class ScryRunError(Exception):
|
|
69
|
+
"""Base exception for scry-run errors with user-friendly messages."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, message: str, hint: Optional[str] = None):
|
|
72
|
+
self.message = message
|
|
73
|
+
self.hint = hint
|
|
74
|
+
super().__init__(self._format_message())
|
|
75
|
+
|
|
76
|
+
def _format_message(self) -> str:
|
|
77
|
+
msg = f"[scry-run] {self.message}"
|
|
78
|
+
if self.hint:
|
|
79
|
+
msg += f"\n 💡 {self.hint}"
|
|
80
|
+
return msg
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class APIKeyError(ScryRunError):
|
|
84
|
+
"""API key is missing or invalid."""
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class RateLimitError(ScryRunError):
|
|
89
|
+
"""Rate limit exceeded."""
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class QuotaExceededError(ScryRunError):
|
|
94
|
+
"""API quota exhausted."""
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class ModelNotFoundError(ScryRunError):
|
|
99
|
+
"""Requested model doesn't exist or isn't accessible."""
|
|
100
|
+
pass
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class ContentBlockedError(ScryRunError):
|
|
104
|
+
"""Content was blocked by safety filters."""
|
|
105
|
+
pass
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class NetworkError(ScryRunError):
|
|
109
|
+
"""Network connectivity issue."""
|
|
110
|
+
pass
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class CodeGenerationError(ScryRunError):
|
|
114
|
+
"""Code generation failed."""
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class CodeValidationError(ScryRunError):
|
|
119
|
+
"""Generated code failed to parse."""
|
|
120
|
+
pass
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _handle_api_error(error: Exception, model: str) -> ScryRunError:
|
|
124
|
+
"""Convert API exceptions to user-friendly errors.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
error: The original exception from the backend
|
|
128
|
+
model: The model name being used (for error messages)
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
A user-friendly ScryRunError subclass
|
|
132
|
+
"""
|
|
133
|
+
error_str = str(error).lower()
|
|
134
|
+
error_type = type(error).__name__
|
|
135
|
+
|
|
136
|
+
# Authentication errors
|
|
137
|
+
if "api_key" in error_str or "authentication" in error_str or "401" in error_str:
|
|
138
|
+
return APIKeyError(
|
|
139
|
+
"Invalid API key",
|
|
140
|
+
"Check your API key configuration"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# Rate limiting
|
|
144
|
+
if "rate" in error_str and "limit" in error_str or "429" in error_str or "resource_exhausted" in error_str:
|
|
145
|
+
return RateLimitError(
|
|
146
|
+
"Rate limit exceeded",
|
|
147
|
+
"Wait a moment and try again. Consider using a slower request rate."
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# Quota exceeded
|
|
151
|
+
if "quota" in error_str or "billing" in error_str:
|
|
152
|
+
return QuotaExceededError(
|
|
153
|
+
"API quota exhausted",
|
|
154
|
+
"Check your API quota and billing settings"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
# Model not found
|
|
158
|
+
if "model" in error_str and ("not found" in error_str or "not exist" in error_str or "404" in error_str):
|
|
159
|
+
return ModelNotFoundError(
|
|
160
|
+
f"Model '{model}' not found",
|
|
161
|
+
f"Try a different model. Set SCRY_MODEL or backend-specific env var."
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
# Content blocked by safety filters
|
|
165
|
+
if "safety" in error_str or "blocked" in error_str or "harm" in error_str:
|
|
166
|
+
return ContentBlockedError(
|
|
167
|
+
"Content blocked by safety filters",
|
|
168
|
+
"Try rephrasing your class/method names or docstrings"
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
# Network errors
|
|
172
|
+
if any(x in error_str for x in ["connection", "timeout", "network", "dns", "ssl"]):
|
|
173
|
+
return NetworkError(
|
|
174
|
+
"Network error connecting to backend",
|
|
175
|
+
"Check your internet connection and firewall settings"
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# Permission/access errors
|
|
179
|
+
if "permission" in error_str or "403" in error_str or "access" in error_str:
|
|
180
|
+
return APIKeyError(
|
|
181
|
+
"API access denied",
|
|
182
|
+
"Check your API key permissions"
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
# Server errors
|
|
186
|
+
if "500" in error_str or "503" in error_str or "server" in error_str:
|
|
187
|
+
return NetworkError(
|
|
188
|
+
"Backend server error",
|
|
189
|
+
"The API is temporarily unavailable. Try again in a few seconds."
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
# Fallback: wrap unknown errors
|
|
193
|
+
return CodeGenerationError(
|
|
194
|
+
f"API error: {error_type}: {error}",
|
|
195
|
+
"Check the error message above. If persistent, file an issue."
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class GenerationLimitError(ScryRunError):
|
|
200
|
+
"""Global generation limit exceeded."""
|
|
201
|
+
pass
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class CodeGenerator:
|
|
205
|
+
"""Generates code using pluggable backends.
|
|
206
|
+
|
|
207
|
+
The generator maintains a persistent session with the backend.
|
|
208
|
+
Full codebase context is sent once at startup, then each generation
|
|
209
|
+
request sends only the minimal "frame" (class/method info).
|
|
210
|
+
|
|
211
|
+
Configure via SCRY_BACKEND env var or backend parameter.
|
|
212
|
+
"""
|
|
213
|
+
|
|
214
|
+
MAX_RETRIES = 5
|
|
215
|
+
|
|
216
|
+
# Global counter for all generations in this process
|
|
217
|
+
_total_generations_count = 0
|
|
218
|
+
|
|
219
|
+
# Track context per-generator instance
|
|
220
|
+
_context_hash: Optional[str] = None
|
|
221
|
+
|
|
222
|
+
def __init__(
|
|
223
|
+
self,
|
|
224
|
+
api_key: Optional[str] = None,
|
|
225
|
+
model: Optional[str] = None,
|
|
226
|
+
backend: Optional[str] = None,
|
|
227
|
+
):
|
|
228
|
+
"""Initialize the code generator.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
api_key: Deprecated, not used.
|
|
232
|
+
model: Model to use. Defaults to backend-specific default.
|
|
233
|
+
backend: Backend to use: "claude", "frozen", or "auto" (default).
|
|
234
|
+
Can also be set via SCRY_BACKEND env var.
|
|
235
|
+
"""
|
|
236
|
+
from scry_run.backends import get_backend
|
|
237
|
+
|
|
238
|
+
# Pass model through - backends handle their own defaults
|
|
239
|
+
self.model = model or os.environ.get("SCRY_MODEL")
|
|
240
|
+
self._backend = get_backend(name=backend, model=self.model)
|
|
241
|
+
self._context_provided = False
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _format_installed_packages(self, packages: list[str] | None) -> str:
|
|
245
|
+
"""Format installed packages for the prompt."""
|
|
246
|
+
if not packages:
|
|
247
|
+
return "No packages are currently installed in this app's environment."
|
|
248
|
+
|
|
249
|
+
pkg_list = "\n".join(f"- {pkg}" for pkg in packages)
|
|
250
|
+
return f"""The following packages are already installed:
|
|
251
|
+
{pkg_list}
|
|
252
|
+
|
|
253
|
+
Prefer using these before requesting new packages. Only add to "packages" if you need something not listed above."""
|
|
254
|
+
|
|
255
|
+
def _build_context_prompt(
|
|
256
|
+
self,
|
|
257
|
+
context: str,
|
|
258
|
+
installed_packages: list[str] | None = None,
|
|
259
|
+
) -> str:
|
|
260
|
+
"""Build the full context prompt (sent once at session start).
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
context: Full codebase context with hole marker
|
|
264
|
+
installed_packages: Packages already installed
|
|
265
|
+
|
|
266
|
+
Returns:
|
|
267
|
+
The formatted context string for the system prompt
|
|
268
|
+
"""
|
|
269
|
+
return f"""## Codebase Context
|
|
270
|
+
|
|
271
|
+
The following is the full codebase you are working with:
|
|
272
|
+
|
|
273
|
+
```python
|
|
274
|
+
{context}
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
## Installed Packages
|
|
278
|
+
|
|
279
|
+
{self._format_installed_packages(installed_packages)}
|
|
280
|
+
|
|
281
|
+
## Critical Requirements for Code Generation
|
|
282
|
+
|
|
283
|
+
### Code Quality
|
|
284
|
+
1. The code MUST be valid Python that can be executed with `exec()`
|
|
285
|
+
2. Complete method/property definition starting with `def` or `@property`
|
|
286
|
+
3. Use appropriate decorators (@classmethod, @staticmethod, @property) if needed
|
|
287
|
+
4. STRICTLY USE TYPE HINTS for all parameters and return values
|
|
288
|
+
|
|
289
|
+
### Application-Level Thinking
|
|
290
|
+
5. READ THE CLASS DOCSTRING - it describes what the application does
|
|
291
|
+
6. EXTRAPOLATE sensibly based on the application's purpose
|
|
292
|
+
7. Generate code that a HUMAN would write for this application
|
|
293
|
+
|
|
294
|
+
### Dependencies
|
|
295
|
+
8. List ALL imports needed in the dependencies field
|
|
296
|
+
9. Prefer standard library when possible
|
|
297
|
+
"""
|
|
298
|
+
|
|
299
|
+
def _build_frame_prompt(
|
|
300
|
+
self,
|
|
301
|
+
class_name: str,
|
|
302
|
+
attr_name: str,
|
|
303
|
+
is_classmethod: bool = False,
|
|
304
|
+
runtime_context: str = "",
|
|
305
|
+
) -> str:
|
|
306
|
+
"""Build the minimal frame prompt (sent for each generation).
|
|
307
|
+
|
|
308
|
+
This assumes the full codebase has already been provided.
|
|
309
|
+
Runtime context (call stack, variables) is included since it's dynamic.
|
|
310
|
+
|
|
311
|
+
Args:
|
|
312
|
+
class_name: Name of the class needing the attribute
|
|
313
|
+
attr_name: Name of the attribute to generate
|
|
314
|
+
is_classmethod: Whether this is a class-level attribute
|
|
315
|
+
runtime_context: Dynamic context (call stack, local variables)
|
|
316
|
+
|
|
317
|
+
Returns:
|
|
318
|
+
The minimal prompt for this specific generation
|
|
319
|
+
"""
|
|
320
|
+
attr_type = "class method/property" if is_classmethod else "instance method/property"
|
|
321
|
+
|
|
322
|
+
# Include runtime context if provided
|
|
323
|
+
context_section = ""
|
|
324
|
+
if runtime_context:
|
|
325
|
+
context_section = f"\n## Runtime Context\n\n{runtime_context}\n"
|
|
326
|
+
|
|
327
|
+
return f"""Generate the `{attr_name}` {attr_type} for the `{class_name}` class.
|
|
328
|
+
{context_section}
|
|
329
|
+
Respond with a JSON object containing:
|
|
330
|
+
- code: The Python code (complete method/property definition with type hints)
|
|
331
|
+
- code_type: One of "method", "property", "classmethod", "staticmethod"
|
|
332
|
+
- docstring: Brief description of what this code does
|
|
333
|
+
- dependencies: List of import statements needed
|
|
334
|
+
- packages: List of PyPI packages to install (if any external packages needed)
|
|
335
|
+
|
|
336
|
+
IMPORTANT: Output ONLY the JSON object, no markdown, no explanation, no code fences."""
|
|
337
|
+
|
|
338
|
+
def _build_prompt(
|
|
339
|
+
self,
|
|
340
|
+
context: str,
|
|
341
|
+
class_name: str,
|
|
342
|
+
attr_name: str,
|
|
343
|
+
is_classmethod: bool = False,
|
|
344
|
+
installed_packages: list[str] | None = None,
|
|
345
|
+
) -> str:
|
|
346
|
+
"""Build the full prompt for code generation (legacy/fallback).
|
|
347
|
+
|
|
348
|
+
This is used when the backend doesn't support set_context().
|
|
349
|
+
|
|
350
|
+
Args:
|
|
351
|
+
context: Full codebase context with hole marker
|
|
352
|
+
class_name: Name of the class needing the attribute
|
|
353
|
+
attr_name: Name of the attribute to generate
|
|
354
|
+
is_classmethod: Whether this is a class-level attribute
|
|
355
|
+
|
|
356
|
+
Returns:
|
|
357
|
+
The formatted prompt string
|
|
358
|
+
"""
|
|
359
|
+
attr_type = "class method/property" if is_classmethod else "instance method/property"
|
|
360
|
+
|
|
361
|
+
return f"""You are an expert Python code generator for the scry-run dynamic code generation system.
|
|
362
|
+
|
|
363
|
+
## Your Role
|
|
364
|
+
|
|
365
|
+
You are generating code for an APPLICATION, not just isolated functions. Read the class docstring carefully - it describes the PURPOSE of the entire application. Use this to EXTRAPOLATE what this method should do.
|
|
366
|
+
|
|
367
|
+
## Codebase Context
|
|
368
|
+
|
|
369
|
+
The following is the full codebase. There is a marker showing where code is missing:
|
|
370
|
+
|
|
371
|
+
```python
|
|
372
|
+
{context}
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
## Task
|
|
376
|
+
|
|
377
|
+
Generate the `{attr_name}` {attr_type} for the `{class_name}` class.
|
|
378
|
+
|
|
379
|
+
## Critical Requirements
|
|
380
|
+
|
|
381
|
+
### Code Quality
|
|
382
|
+
1. The code MUST be valid Python that can be executed with `exec()`
|
|
383
|
+
2. Complete method/property definition starting with `def` or `@property`
|
|
384
|
+
3. Use appropriate decorators (@classmethod, @staticmethod, @property) if needed
|
|
385
|
+
4. STRICTLY USE TYPE HINTS for all parameters and return values (e.g. `def foo(x: int) -> str:`)
|
|
386
|
+
- Import `typing` types (List, Dict, Optional, Any) as needed and add to dependencies
|
|
387
|
+
|
|
388
|
+
### Rich Contextual Comments
|
|
389
|
+
4. Include a DETAILED docstring explaining:
|
|
390
|
+
- What this method does
|
|
391
|
+
- How it fits into the overall application described in the class docstring
|
|
392
|
+
- Any assumptions made about the application's behavior
|
|
393
|
+
5. Add inline comments for non-obvious logic
|
|
394
|
+
6. Document parameter types and return types
|
|
395
|
+
|
|
396
|
+
### Application-Level Thinking
|
|
397
|
+
7. READ THE CLASS DOCSTRING - it describes what the application does
|
|
398
|
+
8. EXTRAPOLATE sensibly: if the app is a "todo list", a `save()` method should persist todos
|
|
399
|
+
9. Infer reasonable behavior from:
|
|
400
|
+
- The application description (class docstring)
|
|
401
|
+
- The method/property name
|
|
402
|
+
- Other existing methods
|
|
403
|
+
- How similar applications typically work
|
|
404
|
+
10. Generate code that a HUMAN would write for this application, not minimal stubs
|
|
405
|
+
11. Think about what OTHER PARAMETERS this method might need beyond the obvious ones:
|
|
406
|
+
- Configuration options (e.g., format, encoding, verbosity)
|
|
407
|
+
- Optional filters or modifiers
|
|
408
|
+
- Callbacks or hooks for extensibility
|
|
409
|
+
- Make these optional with sensible defaults
|
|
410
|
+
|
|
411
|
+
### Dependencies
|
|
412
|
+
11. List ALL imports needed in the dependencies field
|
|
413
|
+
12. Prefer standard library when possible
|
|
414
|
+
13. The code should work standalone once dependencies are imported
|
|
415
|
+
14. Prefer standard library when possible
|
|
416
|
+
15. The code should work standalone once dependencies are imported
|
|
417
|
+
|
|
418
|
+
### Packages
|
|
419
|
+
|
|
420
|
+
The app has its own virtual environment. If you need external packages:
|
|
421
|
+
1. Add import statements to "dependencies" (e.g., "import pygame")
|
|
422
|
+
2. Add PyPI package names to "packages" (e.g., "pygame")
|
|
423
|
+
|
|
424
|
+
{self._format_installed_packages(installed_packages)}
|
|
425
|
+
|
|
426
|
+
## Output Format
|
|
427
|
+
|
|
428
|
+
Respond with a JSON object containing:
|
|
429
|
+
- code: The Python code (complete method/property definition with rich comments)
|
|
430
|
+
- code_type: One of "method", "property", "classmethod", "staticmethod"
|
|
431
|
+
- docstring: Brief description of what this code does
|
|
432
|
+
- dependencies: List of import statements needed
|
|
433
|
+
- packages: List of PyPI packages to install (if any external packages needed)
|
|
434
|
+
|
|
435
|
+
IMPORTANT: Output ONLY the JSON object, no markdown, no explanation, no code fences."""
|
|
436
|
+
|
|
437
|
+
def _fix_indentation(self, code: str) -> str:
|
|
438
|
+
"""Fix common indentation issues in generated code.
|
|
439
|
+
|
|
440
|
+
The LLM sometimes returns code with leading indentation or
|
|
441
|
+
inconsistent whitespace. This method normalizes it.
|
|
442
|
+
|
|
443
|
+
Args:
|
|
444
|
+
code: Raw generated code
|
|
445
|
+
|
|
446
|
+
Returns:
|
|
447
|
+
Code with fixed indentation
|
|
448
|
+
"""
|
|
449
|
+
import textwrap
|
|
450
|
+
from scry_run.logging import get_logger
|
|
451
|
+
logger = get_logger()
|
|
452
|
+
|
|
453
|
+
original_code = code
|
|
454
|
+
|
|
455
|
+
# Strip leading/trailing empty lines
|
|
456
|
+
lines = code.split('\n')
|
|
457
|
+
while lines and not lines[0].strip():
|
|
458
|
+
lines.pop(0)
|
|
459
|
+
while lines and not lines[-1].strip():
|
|
460
|
+
lines.pop()
|
|
461
|
+
|
|
462
|
+
if not lines:
|
|
463
|
+
return code
|
|
464
|
+
|
|
465
|
+
# Check if first non-empty line is indented
|
|
466
|
+
first_line = lines[0]
|
|
467
|
+
if first_line and first_line[0] in ' \t':
|
|
468
|
+
# Use textwrap.dedent to remove common leading whitespace
|
|
469
|
+
code = textwrap.dedent('\n'.join(lines))
|
|
470
|
+
logger.debug("Fixed indentation in generated code", f"ORIGINAL:\n{original_code}\n\nFIXED:\n{code}")
|
|
471
|
+
else:
|
|
472
|
+
code = '\n'.join(lines)
|
|
473
|
+
|
|
474
|
+
return code
|
|
475
|
+
|
|
476
|
+
def _validate_code(self, code: str) -> None:
|
|
477
|
+
"""Validate that the code parses correctly.
|
|
478
|
+
|
|
479
|
+
Args:
|
|
480
|
+
code: Python code to validate
|
|
481
|
+
|
|
482
|
+
Raises:
|
|
483
|
+
CodeValidationError: If code fails to parse
|
|
484
|
+
"""
|
|
485
|
+
try:
|
|
486
|
+
ast.parse(code)
|
|
487
|
+
except SyntaxError as e:
|
|
488
|
+
raise CodeValidationError(f"Generated code has syntax error: {e}")
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _parse_response(self, response_text: str) -> GeneratedCode:
|
|
492
|
+
"""Parse the JSON response from the LLM.
|
|
493
|
+
|
|
494
|
+
Args:
|
|
495
|
+
response_text: Raw response text from LLM
|
|
496
|
+
|
|
497
|
+
Returns:
|
|
498
|
+
GeneratedCode object
|
|
499
|
+
|
|
500
|
+
Raises:
|
|
501
|
+
CodeGenerationError: If response cannot be parsed
|
|
502
|
+
"""
|
|
503
|
+
try:
|
|
504
|
+
data = json.loads(response_text)
|
|
505
|
+
except json.JSONDecodeError as e:
|
|
506
|
+
raise CodeGenerationError(f"Failed to parse JSON response: {e}")
|
|
507
|
+
|
|
508
|
+
required_fields = ["code", "code_type", "docstring", "dependencies", "packages"]
|
|
509
|
+
for field in required_fields:
|
|
510
|
+
if field not in data:
|
|
511
|
+
raise CodeGenerationError(f"Missing required field: {field}")
|
|
512
|
+
|
|
513
|
+
return GeneratedCode(
|
|
514
|
+
code=data["code"],
|
|
515
|
+
code_type=data["code_type"],
|
|
516
|
+
docstring=data["docstring"],
|
|
517
|
+
dependencies=data["dependencies"],
|
|
518
|
+
packages=data["packages"],
|
|
519
|
+
)
|
|
520
|
+
def generate(
|
|
521
|
+
self,
|
|
522
|
+
context: str,
|
|
523
|
+
class_name: str,
|
|
524
|
+
attr_name: str,
|
|
525
|
+
is_classmethod: bool = False,
|
|
526
|
+
installed_packages: list[str] | None = None,
|
|
527
|
+
) -> GeneratedCode:
|
|
528
|
+
"""Generate code for a missing attribute.
|
|
529
|
+
|
|
530
|
+
Args:
|
|
531
|
+
context: Full codebase context with hole marker
|
|
532
|
+
class_name: Name of the class needing the attribute
|
|
533
|
+
attr_name: Name of the attribute to generate
|
|
534
|
+
is_classmethod: Whether this is a class-level attribute
|
|
535
|
+
installed_packages: List of packages already installed in the app's venv
|
|
536
|
+
|
|
537
|
+
Returns:
|
|
538
|
+
GeneratedCode with the generated code and metadata
|
|
539
|
+
|
|
540
|
+
Raises:
|
|
541
|
+
CodeGenerationError: If generation fails after retries
|
|
542
|
+
CodeValidationError: If generated code doesn't parse
|
|
543
|
+
APIKeyError: If API key is invalid
|
|
544
|
+
RateLimitError: If rate limit is exceeded
|
|
545
|
+
QuotaExceededError: If quota is exhausted
|
|
546
|
+
NetworkError: If network issues occur
|
|
547
|
+
GenerationLimitError: If global generation limit is exceeded
|
|
548
|
+
"""
|
|
549
|
+
# Check global generation limit
|
|
550
|
+
max_limit_str = os.environ.get("SCRY_MAX_GENERATIONS", "100")
|
|
551
|
+
if max_limit_str:
|
|
552
|
+
try:
|
|
553
|
+
limit = int(max_limit_str)
|
|
554
|
+
if CodeGenerator._total_generations_count >= limit:
|
|
555
|
+
raise GenerationLimitError(
|
|
556
|
+
f"Global generation limit of {limit} reached.",
|
|
557
|
+
f"Increase SCRY_MAX_GENERATIONS env var if this is intended."
|
|
558
|
+
)
|
|
559
|
+
except ValueError:
|
|
560
|
+
# Ignore invalid integer values in env var
|
|
561
|
+
pass
|
|
562
|
+
|
|
563
|
+
# Increment counter
|
|
564
|
+
CodeGenerator._total_generations_count += 1
|
|
565
|
+
|
|
566
|
+
# Log generation start
|
|
567
|
+
from scry_run.logging import get_logger
|
|
568
|
+
logger = get_logger()
|
|
569
|
+
|
|
570
|
+
# Set generation context for frozen backend error messages
|
|
571
|
+
if hasattr(self._backend, 'set_generation_context'):
|
|
572
|
+
self._backend.set_generation_context(class_name, attr_name)
|
|
573
|
+
|
|
574
|
+
# Check if backend supports persistent context
|
|
575
|
+
supports_context = hasattr(self._backend, 'set_context')
|
|
576
|
+
|
|
577
|
+
# Extract runtime context from the full context (it's appended at the end)
|
|
578
|
+
# The runtime context is dynamic and must be included in every request
|
|
579
|
+
runtime_context = ""
|
|
580
|
+
if "# === RUNTIME CONTEXT ===" in context:
|
|
581
|
+
idx = context.find("# === RUNTIME CONTEXT ===")
|
|
582
|
+
runtime_context = context[idx:]
|
|
583
|
+
|
|
584
|
+
if supports_context and not self._context_provided:
|
|
585
|
+
# First generation: send full context to backend (without runtime context)
|
|
586
|
+
# Runtime context is dynamic and will be sent with each frame
|
|
587
|
+
static_context = context[:context.find("# === RUNTIME CONTEXT ===")] if runtime_context else context
|
|
588
|
+
context_prompt = self._build_context_prompt(static_context, installed_packages)
|
|
589
|
+
self._backend.set_context(context_prompt)
|
|
590
|
+
self._context_provided = True
|
|
591
|
+
logger.info("Generator: Provided full context to backend session")
|
|
592
|
+
|
|
593
|
+
# For this generation, send frame with runtime context
|
|
594
|
+
prompt = self._build_frame_prompt(class_name, attr_name, is_classmethod, runtime_context)
|
|
595
|
+
elif supports_context:
|
|
596
|
+
# Subsequent generations: send frame with runtime context
|
|
597
|
+
prompt = self._build_frame_prompt(class_name, attr_name, is_classmethod, runtime_context)
|
|
598
|
+
else:
|
|
599
|
+
# Backend doesn't support persistent context, use full prompt
|
|
600
|
+
prompt = self._build_prompt(context, class_name, attr_name, is_classmethod, installed_packages)
|
|
601
|
+
|
|
602
|
+
logger.log_generation_start(class_name, attr_name, prompt)
|
|
603
|
+
|
|
604
|
+
# Delegate to backend with retries
|
|
605
|
+
last_error: Optional[Exception] = None
|
|
606
|
+
|
|
607
|
+
for attempt in range(self.MAX_RETRIES):
|
|
608
|
+
try:
|
|
609
|
+
result = self._backend.generate_code(prompt)
|
|
610
|
+
|
|
611
|
+
# Log raw response
|
|
612
|
+
logger.log_generation_response(result.code if hasattr(result, 'code') else str(result))
|
|
613
|
+
|
|
614
|
+
# Convert backend result to our GeneratedCode type
|
|
615
|
+
from scry_run.backends.base import GeneratedCode as BackendResult
|
|
616
|
+
if isinstance(result, BackendResult):
|
|
617
|
+
result = GeneratedCode(
|
|
618
|
+
code=result.code,
|
|
619
|
+
code_type=result.code_type,
|
|
620
|
+
docstring=result.docstring,
|
|
621
|
+
dependencies=result.dependencies,
|
|
622
|
+
packages=result.packages,
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
# Fix indentation issues before validation
|
|
626
|
+
result = GeneratedCode(
|
|
627
|
+
code=self._fix_indentation(result.code),
|
|
628
|
+
code_type=result.code_type,
|
|
629
|
+
docstring=result.docstring,
|
|
630
|
+
dependencies=result.dependencies,
|
|
631
|
+
packages=result.packages,
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
# Validate the generated code
|
|
635
|
+
self._validate_code(result.code)
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
# Log success
|
|
639
|
+
logger.log_generation_success(class_name, attr_name, result.code)
|
|
640
|
+
|
|
641
|
+
return result
|
|
642
|
+
|
|
643
|
+
except (CodeValidationError, CodeGenerationError) as e:
|
|
644
|
+
last_error = e
|
|
645
|
+
# Log the validation error with full details
|
|
646
|
+
code_str = result.code if 'result' in dir() and hasattr(result, 'code') else None
|
|
647
|
+
logger.log_validation_error(e, code_str)
|
|
648
|
+
|
|
649
|
+
if attempt >= self.MAX_RETRIES - 1:
|
|
650
|
+
raise
|
|
651
|
+
warning(f"Validation error, retrying ({attempt + 1}/{self.MAX_RETRIES})...")
|
|
652
|
+
except Exception as e:
|
|
653
|
+
# Log the error
|
|
654
|
+
logger.error(f"Backend error: {e}", str(e))
|
|
655
|
+
|
|
656
|
+
# Convert to friendly error
|
|
657
|
+
friendly_error = _handle_api_error(e, self.model)
|
|
658
|
+
last_error = friendly_error
|
|
659
|
+
|
|
660
|
+
if isinstance(friendly_error, (RateLimitError, NetworkError)):
|
|
661
|
+
wait_time = 2 ** attempt
|
|
662
|
+
warning(f"{friendly_error.message}. Retrying in {wait_time:.1f}s...")
|
|
663
|
+
time.sleep(wait_time)
|
|
664
|
+
else:
|
|
665
|
+
raise friendly_error
|
|
666
|
+
|
|
667
|
+
raise CodeGenerationError(
|
|
668
|
+
f"Failed to generate valid code after {self.MAX_RETRIES} attempts",
|
|
669
|
+
f"Last error: {last_error}"
|
|
670
|
+
)
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def generate_freeform(
|
|
674
|
+
self,
|
|
675
|
+
prompt: str,
|
|
676
|
+
system_instruction: Optional[str] = "You are a helpful coding assistant.",
|
|
677
|
+
) -> str:
|
|
678
|
+
"""Generate freeform text from a prompt.
|
|
679
|
+
|
|
680
|
+
Args:
|
|
681
|
+
prompt: User prompt
|
|
682
|
+
system_instruction: System instruction to guide the model behavior
|
|
683
|
+
|
|
684
|
+
Returns:
|
|
685
|
+
Generated text content
|
|
686
|
+
|
|
687
|
+
Raises:
|
|
688
|
+
ScryRunError: On API errors
|
|
689
|
+
"""
|
|
690
|
+
# Prepend system instruction if provided
|
|
691
|
+
full_prompt = prompt
|
|
692
|
+
if system_instruction:
|
|
693
|
+
full_prompt = f"{system_instruction}\n\n{prompt}"
|
|
694
|
+
|
|
695
|
+
try:
|
|
696
|
+
return self._backend.generate_text(full_prompt)
|
|
697
|
+
except Exception as e:
|
|
698
|
+
raise _handle_api_error(e, self.model)
|