wishful 0.2.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.
- wishful/__init__.py +99 -0
- wishful/__main__.py +72 -0
- wishful/cache/__init__.py +23 -0
- wishful/cache/manager.py +77 -0
- wishful/config.py +175 -0
- wishful/core/__init__.py +6 -0
- wishful/core/discovery.py +264 -0
- wishful/core/finder.py +77 -0
- wishful/core/loader.py +285 -0
- wishful/dynamic/__init__.py +8 -0
- wishful/dynamic/__init__.pyi +7 -0
- wishful/llm/__init__.py +5 -0
- wishful/llm/client.py +98 -0
- wishful/llm/prompts.py +74 -0
- wishful/logging.py +88 -0
- wishful/py.typed +0 -0
- wishful/safety/__init__.py +5 -0
- wishful/safety/validator.py +132 -0
- wishful/static/__init__.py +8 -0
- wishful/static/__init__.pyi +7 -0
- wishful/types/__init__.py +19 -0
- wishful/types/registry.py +333 -0
- wishful/ui.py +26 -0
- wishful-0.2.1.dist-info/METADATA +401 -0
- wishful-0.2.1.dist-info/RECORD +26 -0
- wishful-0.2.1.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import inspect
|
|
5
|
+
import linecache
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from textwrap import dedent
|
|
9
|
+
from typing import Iterable, List, Sequence
|
|
10
|
+
|
|
11
|
+
from wishful.types import get_all_type_schemas, get_output_type_for_function
|
|
12
|
+
|
|
13
|
+
# Default radius for surrounding-context capture; configurable via env + setter.
|
|
14
|
+
_context_radius = int(os.getenv("WISHFUL_CONTEXT_RADIUS", "3"))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ImportContext:
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
functions: Sequence[str],
|
|
21
|
+
context: str | None,
|
|
22
|
+
type_schemas: dict[str, str] | None = None,
|
|
23
|
+
function_output_types: dict[str, str] | None = None,
|
|
24
|
+
):
|
|
25
|
+
self.functions = list(functions)
|
|
26
|
+
self.context = context
|
|
27
|
+
self.type_schemas = type_schemas or {}
|
|
28
|
+
self.function_output_types = function_output_types or {}
|
|
29
|
+
|
|
30
|
+
def __repr__(self) -> str: # pragma: no cover - debug helper
|
|
31
|
+
return (
|
|
32
|
+
f"ImportContext(functions={self.functions}, "
|
|
33
|
+
f"context={self.context!r}, "
|
|
34
|
+
f"type_schemas={list(self.type_schemas.keys())}, "
|
|
35
|
+
f"function_output_types={self.function_output_types})"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _gather_context_lines(filename: str, lineno: int, radius: int = 2) -> str:
|
|
40
|
+
lines = linecache.getlines(filename)
|
|
41
|
+
if not lines:
|
|
42
|
+
return ""
|
|
43
|
+
start = max(lineno - radius, 1) - 1
|
|
44
|
+
end = min(lineno + radius, len(lines))
|
|
45
|
+
snippet = lines[start:end]
|
|
46
|
+
return "".join(snippet).strip()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _parse_imported_names(source_line: str, fullname: str) -> List[str]:
|
|
50
|
+
tree = _safe_parse_line(source_line)
|
|
51
|
+
if tree is None:
|
|
52
|
+
return []
|
|
53
|
+
|
|
54
|
+
names: list[str] = []
|
|
55
|
+
names.extend(_names_from_import_from(tree, fullname))
|
|
56
|
+
names.extend(_names_from_import(tree, fullname))
|
|
57
|
+
return names
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _safe_parse_line(source_line: str) -> ast.AST | None:
|
|
61
|
+
try:
|
|
62
|
+
return ast.parse(dedent(source_line))
|
|
63
|
+
except SyntaxError:
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _names_from_import_from(tree: ast.AST, fullname: str) -> list[str]:
|
|
68
|
+
return [
|
|
69
|
+
alias.name
|
|
70
|
+
for node in ast.walk(tree)
|
|
71
|
+
if isinstance(node, ast.ImportFrom)
|
|
72
|
+
for alias in node.names
|
|
73
|
+
if _matches_import_from(node.module, fullname)
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _names_from_import(tree: ast.AST, fullname: str) -> list[str]:
|
|
78
|
+
matches: list[str] = []
|
|
79
|
+
for node in (n for n in ast.walk(tree) if isinstance(n, ast.Import)):
|
|
80
|
+
matches.extend(_alias_targets(node.names, fullname))
|
|
81
|
+
return matches
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _alias_targets(aliases: Sequence[ast.alias], fullname: str) -> list[str]:
|
|
85
|
+
return [
|
|
86
|
+
alias.asname or alias.name.split(".")[-1]
|
|
87
|
+
for alias in aliases
|
|
88
|
+
if _matches_import(alias.name, fullname)
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _is_plain_import(fullname: str, tree: ast.AST) -> bool:
|
|
93
|
+
"""Return True when the line is an ast.Import of the fullname (not ImportFrom)."""
|
|
94
|
+
for node in ast.walk(tree):
|
|
95
|
+
if isinstance(node, ast.Import):
|
|
96
|
+
for alias in node.names:
|
|
97
|
+
if _matches_import(alias.name, fullname):
|
|
98
|
+
return True
|
|
99
|
+
return False
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _matches_import_from(module: str | None, fullname: str) -> bool:
|
|
103
|
+
return bool(module) and module.startswith("wishful") and fullname.startswith(module)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _matches_import(name: str, fullname: str) -> bool:
|
|
107
|
+
return name.startswith("wishful") and fullname.startswith(name)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def discover(fullname: str, runtime_context: dict | None = None) -> ImportContext:
|
|
111
|
+
"""Attempt to recover requested symbol names and nearby comments.
|
|
112
|
+
|
|
113
|
+
`runtime_context` is an optional dict (e.g., function name/args) that will
|
|
114
|
+
be appended to the textual context sent to the LLM for dynamic calls.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
first_frame: tuple[str, int] | None = None
|
|
118
|
+
|
|
119
|
+
for filename, lineno in _iter_relevant_frames(fullname):
|
|
120
|
+
if first_frame is None:
|
|
121
|
+
first_frame = (filename, lineno)
|
|
122
|
+
code_line = linecache.getline(filename, lineno).strip()
|
|
123
|
+
if not code_line:
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
functions = _parse_imported_names(code_line, fullname)
|
|
127
|
+
if not functions:
|
|
128
|
+
continue
|
|
129
|
+
|
|
130
|
+
tree = _safe_parse_line(code_line)
|
|
131
|
+
if tree and _is_plain_import(fullname, tree):
|
|
132
|
+
functions = []
|
|
133
|
+
|
|
134
|
+
context = _build_context_snippets(filename, lineno, functions)
|
|
135
|
+
|
|
136
|
+
if runtime_context:
|
|
137
|
+
context = _append_runtime_context(context, runtime_context)
|
|
138
|
+
|
|
139
|
+
# Fetch type information from registry
|
|
140
|
+
type_schemas = get_all_type_schemas()
|
|
141
|
+
function_output_types = {}
|
|
142
|
+
for func in functions:
|
|
143
|
+
output_type = get_output_type_for_function(func)
|
|
144
|
+
if output_type:
|
|
145
|
+
function_output_types[func] = output_type
|
|
146
|
+
|
|
147
|
+
return ImportContext(
|
|
148
|
+
functions=functions,
|
|
149
|
+
context=context,
|
|
150
|
+
type_schemas=type_schemas,
|
|
151
|
+
function_output_types=function_output_types,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# Fallback: even if we couldn't parse requested symbols (e.g., module-level
|
|
155
|
+
# imports with later attribute access), still capture nearby context so the
|
|
156
|
+
# LLM gets some hints.
|
|
157
|
+
if first_frame is not None:
|
|
158
|
+
filename, lineno = first_frame
|
|
159
|
+
context = _build_context_snippets(filename, lineno, [])
|
|
160
|
+
else:
|
|
161
|
+
context = None
|
|
162
|
+
|
|
163
|
+
if runtime_context:
|
|
164
|
+
context = _append_runtime_context(context, runtime_context)
|
|
165
|
+
|
|
166
|
+
type_schemas = get_all_type_schemas()
|
|
167
|
+
return ImportContext(functions=[], context=context, type_schemas=type_schemas)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _append_runtime_context(context: str | None, runtime_context: dict) -> str:
|
|
171
|
+
def _safe(val):
|
|
172
|
+
text = repr(val)
|
|
173
|
+
return text[:500] + "…" if len(text) > 500 else text
|
|
174
|
+
|
|
175
|
+
parts = ["Runtime call context:"]
|
|
176
|
+
for key, val in runtime_context.items():
|
|
177
|
+
parts.append(f"- {key}: {_safe(val)}")
|
|
178
|
+
|
|
179
|
+
runtime_block = "\n".join(parts)
|
|
180
|
+
if context:
|
|
181
|
+
return f"{context}\n\n{runtime_block}"
|
|
182
|
+
return runtime_block
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _iter_relevant_frames(fullname: str) -> Iterable[tuple[str, int]]:
|
|
186
|
+
frame = inspect.currentframe()
|
|
187
|
+
if frame:
|
|
188
|
+
frame = frame.f_back
|
|
189
|
+
|
|
190
|
+
while frame:
|
|
191
|
+
filename = frame.f_code.co_filename
|
|
192
|
+
lineno = frame.f_lineno
|
|
193
|
+
|
|
194
|
+
if _is_user_frame(filename):
|
|
195
|
+
yield filename, lineno
|
|
196
|
+
|
|
197
|
+
frame = frame.f_back
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _is_user_frame(filename: str) -> bool:
|
|
201
|
+
if filename.startswith("<"):
|
|
202
|
+
return False
|
|
203
|
+
normalized = filename.replace("\\", "/")
|
|
204
|
+
return not ("/src/wishful/" in normalized and "/tests/" not in normalized)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _gather_usage_context(filename: str, functions: Sequence[str], radius: int) -> list[str]:
|
|
208
|
+
"""Collect context snippets around call sites of the requested functions."""
|
|
209
|
+
if not functions:
|
|
210
|
+
return []
|
|
211
|
+
|
|
212
|
+
tree = _parse_file_safe(filename)
|
|
213
|
+
if tree is None:
|
|
214
|
+
return []
|
|
215
|
+
|
|
216
|
+
linenos = _call_site_lines(tree, set(functions))
|
|
217
|
+
return _snippets_from_lines(filename, linenos, radius)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _call_site_lines(tree: ast.AST, targets: set[str]) -> list[int]:
|
|
221
|
+
return [
|
|
222
|
+
node.lineno
|
|
223
|
+
for node in ast.walk(tree)
|
|
224
|
+
if isinstance(node, ast.Call)
|
|
225
|
+
if isinstance(node.func, ast.Name)
|
|
226
|
+
if node.func.id in targets
|
|
227
|
+
]
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _snippets_from_lines(filename: str, linenos: Sequence[int], radius: int) -> list[str]:
|
|
231
|
+
snippets = [_gather_context_lines(filename, lineno, radius=radius) for lineno in linenos]
|
|
232
|
+
return _dedupe([s for s in snippets if s])
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _parse_file_safe(filename: str) -> ast.AST | None:
|
|
236
|
+
try:
|
|
237
|
+
return ast.parse(Path(filename).read_text())
|
|
238
|
+
except (OSError, SyntaxError):
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _build_context_snippets(filename: str, lineno: int, functions: Sequence[str]) -> str | None:
|
|
243
|
+
snippets = [_gather_context_lines(filename, lineno, radius=_context_radius)]
|
|
244
|
+
snippets += _gather_usage_context(filename, functions, radius=_context_radius)
|
|
245
|
+
combined = "\n\n".join(part for part in snippets if part)
|
|
246
|
+
return combined or None
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _dedupe(items: Sequence[str]) -> list[str]:
|
|
250
|
+
seen = set()
|
|
251
|
+
unique: list[str] = []
|
|
252
|
+
for item in items:
|
|
253
|
+
if item not in seen:
|
|
254
|
+
seen.add(item)
|
|
255
|
+
unique.append(item)
|
|
256
|
+
return unique
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def set_context_radius(radius: int) -> None:
|
|
260
|
+
"""Update the global context radius used for import discovery."""
|
|
261
|
+
global _context_radius
|
|
262
|
+
if radius < 0:
|
|
263
|
+
raise ValueError("context radius must be non-negative")
|
|
264
|
+
_context_radius = radius
|
wishful/core/finder.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib.abc
|
|
4
|
+
import importlib.util
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from wishful.core.loader import MagicLoader, MagicPackageLoader
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
MAGIC_NAMESPACE = "wishful"
|
|
12
|
+
STATIC_NAMESPACE = "wishful.static"
|
|
13
|
+
DYNAMIC_NAMESPACE = "wishful.dynamic"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MagicFinder(importlib.abc.MetaPathFinder):
|
|
17
|
+
"""Intercept imports for the `wishful.static.*` and `wishful.dynamic.*` namespaces."""
|
|
18
|
+
|
|
19
|
+
def find_spec(self, fullname: str, path, target=None): # type: ignore[override]
|
|
20
|
+
if not fullname.startswith(MAGIC_NAMESPACE):
|
|
21
|
+
return None
|
|
22
|
+
|
|
23
|
+
# Allow internal wishful modules (core, cache, llm, etc.)
|
|
24
|
+
if _is_internal_module(fullname):
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
# Handle root namespace packages
|
|
28
|
+
if fullname == MAGIC_NAMESPACE:
|
|
29
|
+
# Let the real installed package handle the root 'wishful'
|
|
30
|
+
return None
|
|
31
|
+
if fullname == STATIC_NAMESPACE:
|
|
32
|
+
return importlib.util.spec_from_loader(fullname, MagicPackageLoader(), is_package=True)
|
|
33
|
+
if fullname == DYNAMIC_NAMESPACE:
|
|
34
|
+
return importlib.util.spec_from_loader(fullname, MagicPackageLoader(), is_package=True)
|
|
35
|
+
|
|
36
|
+
# Determine if this is a static or dynamic import
|
|
37
|
+
is_static = fullname.startswith(STATIC_NAMESPACE + ".")
|
|
38
|
+
is_dynamic = fullname.startswith(DYNAMIC_NAMESPACE + ".")
|
|
39
|
+
|
|
40
|
+
if is_static:
|
|
41
|
+
return importlib.util.spec_from_loader(
|
|
42
|
+
fullname, MagicLoader(fullname, mode="static"), is_package=False
|
|
43
|
+
)
|
|
44
|
+
elif is_dynamic:
|
|
45
|
+
return importlib.util.spec_from_loader(
|
|
46
|
+
fullname, MagicLoader(fullname, mode="dynamic"), is_package=False
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# Reject direct wishful.* imports that aren't static/dynamic
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _is_internal_module(fullname: str) -> bool:
|
|
54
|
+
"""Check if a module is part of the internal wishful package."""
|
|
55
|
+
parts = fullname.split('.')
|
|
56
|
+
if len(parts) < 2:
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
# Skip static/dynamic namespace checks - they're never internal
|
|
60
|
+
if parts[1] in ('static', 'dynamic'):
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
# Check if it's a real internal module
|
|
64
|
+
module_file = Path(__file__).parent.parent / parts[1]
|
|
65
|
+
return module_file.exists() or module_file.with_suffix('.py').exists()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def install() -> None:
|
|
69
|
+
"""Register the finder if it is not already present."""
|
|
70
|
+
|
|
71
|
+
for finder in sys.meta_path:
|
|
72
|
+
if finder.__class__.__name__ == "MagicFinder" and finder.__class__.__module__.endswith(
|
|
73
|
+
"wishful.core.finder"
|
|
74
|
+
):
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
sys.meta_path.insert(0, MagicFinder())
|
wishful/core/loader.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import importlib.abc
|
|
5
|
+
import importlib.util
|
|
6
|
+
import sys
|
|
7
|
+
from types import ModuleType
|
|
8
|
+
|
|
9
|
+
from wishful.cache import manager as cache
|
|
10
|
+
from wishful.config import settings
|
|
11
|
+
from wishful.core.discovery import discover
|
|
12
|
+
from wishful.llm.client import GenerationError, generate_module_code
|
|
13
|
+
from wishful.logging import logger
|
|
14
|
+
from wishful.safety.validator import SecurityError, validate_code
|
|
15
|
+
from wishful.ui import spinner
|
|
16
|
+
|
|
17
|
+
def _resolve_generate_module_code():
|
|
18
|
+
"""Use the generate_module_code function from the live loader module.
|
|
19
|
+
|
|
20
|
+
MagicLoader instances can outlive module reloads during tests; looking up
|
|
21
|
+
the function each time keeps monkeypatches effective while preferring any
|
|
22
|
+
patched version over the original default.
|
|
23
|
+
"""
|
|
24
|
+
default_fn = importlib.import_module("wishful.llm.client").generate_module_code
|
|
25
|
+
|
|
26
|
+
mod = sys.modules.get(__name__)
|
|
27
|
+
candidates = []
|
|
28
|
+
if mod is not None and hasattr(mod, "generate_module_code"):
|
|
29
|
+
candidates.append(getattr(mod, "generate_module_code"))
|
|
30
|
+
# Always consider the function bound in this module too
|
|
31
|
+
candidates.append(generate_module_code)
|
|
32
|
+
|
|
33
|
+
for fn in candidates:
|
|
34
|
+
if fn is not default_fn:
|
|
35
|
+
return fn
|
|
36
|
+
return default_fn
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class DynamicProxyModule(ModuleType):
|
|
40
|
+
"""Module proxy that regenerates the underlying module on each attribute access."""
|
|
41
|
+
|
|
42
|
+
_SAFE_ATTRS = {
|
|
43
|
+
"__class__",
|
|
44
|
+
"__dict__",
|
|
45
|
+
"__doc__",
|
|
46
|
+
"__loader__",
|
|
47
|
+
"__name__",
|
|
48
|
+
"__package__",
|
|
49
|
+
"__path__",
|
|
50
|
+
"__spec__",
|
|
51
|
+
"__file__",
|
|
52
|
+
"__builtins__",
|
|
53
|
+
"_wishful_loader",
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
def __getattribute__(self, name):
|
|
57
|
+
if name.startswith("__") or name in DynamicProxyModule._SAFE_ATTRS:
|
|
58
|
+
return super().__getattribute__(name)
|
|
59
|
+
|
|
60
|
+
loader = super().__getattribute__("_wishful_loader")
|
|
61
|
+
loader._regenerate_for_proxy(self, name)
|
|
62
|
+
|
|
63
|
+
attr = super().__getattribute__(name)
|
|
64
|
+
|
|
65
|
+
if callable(attr):
|
|
66
|
+
def _wrapped(*args, **kwargs):
|
|
67
|
+
return loader._call_with_runtime(self, name, args, kwargs)
|
|
68
|
+
|
|
69
|
+
_wrapped.__name__ = name
|
|
70
|
+
_wrapped.__doc__ = getattr(attr, "__doc__", None)
|
|
71
|
+
return _wrapped
|
|
72
|
+
|
|
73
|
+
return attr
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class MagicLoader(importlib.abc.Loader):
|
|
77
|
+
"""Loader that returns dynamic modules backed by cache + LLM generation.
|
|
78
|
+
|
|
79
|
+
Supports two modes:
|
|
80
|
+
- 'static': Traditional cached behavior (default)
|
|
81
|
+
- 'dynamic': Regenerates with runtime context on every access
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
def __init__(self, fullname: str, mode: str = "static"):
|
|
85
|
+
self.fullname = fullname
|
|
86
|
+
self.mode = mode # 'static' or 'dynamic'
|
|
87
|
+
|
|
88
|
+
def create_module(self, spec): # pragma: no cover - default works
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
def exec_module(self, module: ModuleType) -> None:
|
|
92
|
+
context = discover(self.fullname)
|
|
93
|
+
source, from_cache, file_path = self._load_source(context)
|
|
94
|
+
|
|
95
|
+
logger.debug(
|
|
96
|
+
"exec_module fullname={} mode={} from_cache={} file={} functions={}",
|
|
97
|
+
self.fullname,
|
|
98
|
+
self.mode,
|
|
99
|
+
from_cache,
|
|
100
|
+
file_path,
|
|
101
|
+
context.functions,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
if self.mode == "dynamic":
|
|
105
|
+
self._attach_dynamic_proxy(module)
|
|
106
|
+
|
|
107
|
+
self._exec_source(source, module, context, file_path=file_path)
|
|
108
|
+
self._ensure_symbols(module, context, from_cache)
|
|
109
|
+
if self.mode == "dynamic":
|
|
110
|
+
self._maybe_review(source)
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
self._attach_dynamic_getattr(module)
|
|
114
|
+
self._maybe_review(source)
|
|
115
|
+
|
|
116
|
+
def _generate_and_cache(self, functions, context):
|
|
117
|
+
logger.info("Generating {}", self.fullname)
|
|
118
|
+
logger.debug(
|
|
119
|
+
"generate start fullname={} mode={} functions={}", self.fullname, self.mode, functions
|
|
120
|
+
)
|
|
121
|
+
with spinner(f"Generating {self.fullname}"):
|
|
122
|
+
gen_fn = _resolve_generate_module_code()
|
|
123
|
+
source = gen_fn(
|
|
124
|
+
self.fullname,
|
|
125
|
+
functions,
|
|
126
|
+
context.context,
|
|
127
|
+
type_schemas=context.type_schemas,
|
|
128
|
+
function_output_types=context.function_output_types,
|
|
129
|
+
mode=self.mode,
|
|
130
|
+
)
|
|
131
|
+
if self.mode == "static":
|
|
132
|
+
path = cache.write_cached(self.fullname, source)
|
|
133
|
+
else:
|
|
134
|
+
path = cache.write_dynamic_snapshot(self.fullname, source)
|
|
135
|
+
logger.debug("generate done fullname={} path={}", self.fullname, path)
|
|
136
|
+
return source, path
|
|
137
|
+
|
|
138
|
+
def _exec_source(
|
|
139
|
+
self,
|
|
140
|
+
source: str,
|
|
141
|
+
module: ModuleType,
|
|
142
|
+
context,
|
|
143
|
+
clear_first: bool = False,
|
|
144
|
+
file_path: str | None = None,
|
|
145
|
+
allow_retry: bool = True,
|
|
146
|
+
) -> None:
|
|
147
|
+
try:
|
|
148
|
+
validate_code(source, allow_unsafe=settings.allow_unsafe)
|
|
149
|
+
preserved_loader = module.__dict__.get("_wishful_loader")
|
|
150
|
+
if clear_first:
|
|
151
|
+
module.__dict__.clear()
|
|
152
|
+
if preserved_loader is not None:
|
|
153
|
+
module.__dict__["_wishful_loader"] = preserved_loader
|
|
154
|
+
module.__file__ = file_path or str(cache.module_path(self.fullname))
|
|
155
|
+
module.__package__ = self.fullname.rpartition('.')[0]
|
|
156
|
+
exec(compile(source, module.__file__, "exec"), module.__dict__)
|
|
157
|
+
except SyntaxError:
|
|
158
|
+
logger.warning("SyntaxError while loading {}; retrying once", self.fullname)
|
|
159
|
+
if not allow_retry:
|
|
160
|
+
raise
|
|
161
|
+
# Regenerate once on syntax errors
|
|
162
|
+
if self.mode == "static":
|
|
163
|
+
cache.delete_cached(self.fullname)
|
|
164
|
+
source2, path2 = self._generate_and_cache(context.functions, context)
|
|
165
|
+
self._exec_source(
|
|
166
|
+
source2,
|
|
167
|
+
module,
|
|
168
|
+
context,
|
|
169
|
+
clear_first=True,
|
|
170
|
+
file_path=str(path2),
|
|
171
|
+
allow_retry=False,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
def _attach_dynamic_getattr(self, module: ModuleType) -> None:
|
|
175
|
+
def _dynamic_getattr(name: str):
|
|
176
|
+
if name.startswith("__"):
|
|
177
|
+
raise AttributeError(name)
|
|
178
|
+
|
|
179
|
+
ctx = discover(self.fullname)
|
|
180
|
+
functions = set(ctx.functions or [])
|
|
181
|
+
declared = self._declared_symbols(module)
|
|
182
|
+
desired = sorted(declared | functions | {name})
|
|
183
|
+
|
|
184
|
+
source, path = self._generate_and_cache(desired, ctx)
|
|
185
|
+
self._exec_source(source, module, ctx, clear_first=True, file_path=str(path))
|
|
186
|
+
# Re-attach for future misses after reload
|
|
187
|
+
setattr(module, "__getattr__", _dynamic_getattr) # type: ignore[assignment]
|
|
188
|
+
if name in module.__dict__:
|
|
189
|
+
return module.__dict__[name]
|
|
190
|
+
raise AttributeError(name)
|
|
191
|
+
|
|
192
|
+
setattr(module, "__getattr__", _dynamic_getattr) # type: ignore[assignment]
|
|
193
|
+
|
|
194
|
+
def _attach_dynamic_proxy(self, module: ModuleType) -> None:
|
|
195
|
+
if not isinstance(module, DynamicProxyModule):
|
|
196
|
+
module.__class__ = DynamicProxyModule # type: ignore[misc]
|
|
197
|
+
setattr(module, "_wishful_loader", self)
|
|
198
|
+
|
|
199
|
+
@staticmethod
|
|
200
|
+
def _declared_symbols(module: ModuleType) -> set[str]:
|
|
201
|
+
return {
|
|
202
|
+
k
|
|
203
|
+
for k in module.__dict__
|
|
204
|
+
if not k.startswith("__") and not k.startswith("_wishful_")
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
def _load_source(self, context) -> tuple[str, bool, str]:
|
|
208
|
+
# Dynamic mode: always regenerate, never use cache
|
|
209
|
+
if self.mode == "dynamic":
|
|
210
|
+
source, path = self._generate_and_cache(context.functions, context)
|
|
211
|
+
return source, False, str(path)
|
|
212
|
+
|
|
213
|
+
# Static mode: use cache if available
|
|
214
|
+
source = cache.read_cached(self.fullname)
|
|
215
|
+
if source is not None:
|
|
216
|
+
logger.debug("cache hit fullname={}", self.fullname)
|
|
217
|
+
return source, True, str(cache.module_path(self.fullname))
|
|
218
|
+
logger.debug("cache miss fullname={}", self.fullname)
|
|
219
|
+
source, path = self._generate_and_cache(context.functions, context)
|
|
220
|
+
return source, False, str(path)
|
|
221
|
+
|
|
222
|
+
def _ensure_symbols(self, module: ModuleType, context, from_cache: bool) -> None:
|
|
223
|
+
if not context.functions:
|
|
224
|
+
return
|
|
225
|
+
|
|
226
|
+
missing = self._missing_symbols(module, context.functions)
|
|
227
|
+
if not missing:
|
|
228
|
+
return
|
|
229
|
+
|
|
230
|
+
if not from_cache:
|
|
231
|
+
raise GenerationError(
|
|
232
|
+
f"Generated module for {self.fullname} lacks symbols: {', '.join(missing)}"
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
self._regenerate_with(module, context)
|
|
236
|
+
|
|
237
|
+
def _maybe_review(self, source: str) -> None:
|
|
238
|
+
if not settings.review:
|
|
239
|
+
return
|
|
240
|
+
print(f"Generated code for {self.fullname}:\n{source}\n")
|
|
241
|
+
answer = input("Run this code? [y/N]: ")
|
|
242
|
+
if answer.lower().strip() not in {"y", "yes"}:
|
|
243
|
+
cache.delete_cached(self.fullname)
|
|
244
|
+
raise ImportError("User rejected generated code.")
|
|
245
|
+
|
|
246
|
+
def _missing_symbols(self, module: ModuleType, requested: list[str]) -> list[str]:
|
|
247
|
+
return [name for name in requested if name not in module.__dict__]
|
|
248
|
+
|
|
249
|
+
def _regenerate_with(self, module: ModuleType, context) -> None:
|
|
250
|
+
desired = sorted(set(context.functions) | self._declared_symbols(module))
|
|
251
|
+
cache.delete_cached(self.fullname)
|
|
252
|
+
source, path = self._generate_and_cache(desired, context)
|
|
253
|
+
self._exec_source(source, module, context, clear_first=True, file_path=str(path))
|
|
254
|
+
|
|
255
|
+
def _regenerate_for_proxy(self, module: ModuleType, requested: str) -> None:
|
|
256
|
+
ctx = discover(self.fullname)
|
|
257
|
+
desired = set(ctx.functions or []) | {requested} | self._declared_symbols(module)
|
|
258
|
+
ctx.functions = sorted(desired)
|
|
259
|
+
source, path = self._generate_and_cache(ctx.functions, ctx)
|
|
260
|
+
self._exec_source(source, module, ctx, clear_first=True, file_path=str(path))
|
|
261
|
+
|
|
262
|
+
def _call_with_runtime(self, module: ModuleType, func_name: str, args, kwargs):
|
|
263
|
+
ctx = discover(self.fullname, runtime_context={"function": func_name, "args": args, "kwargs": kwargs})
|
|
264
|
+
desired = set(ctx.functions or []) | {func_name} | self._declared_symbols(module)
|
|
265
|
+
ctx.functions = sorted(desired)
|
|
266
|
+
|
|
267
|
+
source, path = self._generate_and_cache(ctx.functions, ctx)
|
|
268
|
+
self._exec_source(source, module, ctx, clear_first=True, file_path=str(path))
|
|
269
|
+
|
|
270
|
+
target = module.__dict__.get(func_name)
|
|
271
|
+
if callable(target):
|
|
272
|
+
return target(*args, **kwargs)
|
|
273
|
+
raise AttributeError(func_name)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class MagicPackageLoader(importlib.abc.Loader):
|
|
277
|
+
"""Loader for the root 'wishful' package to enable namespace imports."""
|
|
278
|
+
|
|
279
|
+
def create_module(self, spec): # pragma: no cover - default create
|
|
280
|
+
return None
|
|
281
|
+
|
|
282
|
+
def exec_module(self, module: ModuleType) -> None:
|
|
283
|
+
module.__path__ = [str(cache.ensure_cache_dir())]
|
|
284
|
+
module.__package__ = "wishful"
|
|
285
|
+
module.__file__ = str(cache.ensure_cache_dir() / "__init__.py")
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Wishful dynamic namespace - runtime-context-aware code generation.
|
|
2
|
+
|
|
3
|
+
Modules imported from wishful.dynamic.* are regenerated on every import,
|
|
4
|
+
capturing runtime context and values for context-sensitive code generation.
|
|
5
|
+
|
|
6
|
+
Example:
|
|
7
|
+
from wishful.dynamic.stories import create_opening
|
|
8
|
+
"""
|