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.
@@ -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
+ """
@@ -0,0 +1,7 @@
1
+ """Type stub for wishful.dynamic namespace.
2
+
3
+ This stub helps IDEs and type checkers understand that wishful.dynamic.*
4
+ is a valid namespace for dynamically generated modules.
5
+ """
6
+
7
+ def __getattr__(name: str) -> object: ...
@@ -0,0 +1,5 @@
1
+ """LLM integration layer."""
2
+
3
+ from .client import generate_module_code, GenerationError
4
+
5
+ __all__ = ["generate_module_code", "GenerationError"]