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
wishful/llm/client.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Sequence
|
|
5
|
+
|
|
6
|
+
import litellm
|
|
7
|
+
|
|
8
|
+
from wishful.config import settings
|
|
9
|
+
from wishful.llm.prompts import build_messages, strip_code_fences
|
|
10
|
+
from wishful.logging import logger
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class GenerationError(ImportError):
|
|
14
|
+
"""Raised when the LLM call fails or returns empty output."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
_FAKE_MODE = os.getenv("WISHFUL_FAKE_LLM", "0") == "1"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _fake_response(functions: Sequence[str]) -> str:
|
|
21
|
+
body = []
|
|
22
|
+
for name in functions or ("generated_helper",):
|
|
23
|
+
body.append(
|
|
24
|
+
f"def {name}(*args, **kwargs):\n \"\"\"Auto-generated placeholder. Replace with real logic.\"\"\"\n return {{'args': args, 'kwargs': kwargs}}\n"
|
|
25
|
+
)
|
|
26
|
+
return "\n\n".join(body)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def generate_module_code(
|
|
30
|
+
module: str,
|
|
31
|
+
functions: Sequence[str],
|
|
32
|
+
context: str | None,
|
|
33
|
+
type_schemas: dict[str, str] | None = None,
|
|
34
|
+
function_output_types: dict[str, str] | None = None,
|
|
35
|
+
mode: str | None = None,
|
|
36
|
+
) -> str:
|
|
37
|
+
"""Call the LLM (or fake stub) to generate module source code."""
|
|
38
|
+
|
|
39
|
+
if _FAKE_MODE:
|
|
40
|
+
return _fake_response(functions)
|
|
41
|
+
|
|
42
|
+
response = _call_llm(
|
|
43
|
+
module, functions, context, type_schemas, function_output_types, mode
|
|
44
|
+
)
|
|
45
|
+
content = _extract_content(response)
|
|
46
|
+
return strip_code_fences(content).strip()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _call_llm(
|
|
50
|
+
module: str,
|
|
51
|
+
functions: Sequence[str],
|
|
52
|
+
context: str | None,
|
|
53
|
+
type_schemas: dict[str, str] | None = None,
|
|
54
|
+
function_output_types: dict[str, str] | None = None,
|
|
55
|
+
mode: str | None = None,
|
|
56
|
+
):
|
|
57
|
+
messages = build_messages(
|
|
58
|
+
module, functions, context, type_schemas, function_output_types, mode
|
|
59
|
+
)
|
|
60
|
+
logger.debug(
|
|
61
|
+
"LLM call module={} mode={} model={} temp={} max_tokens={} functions={} context_len={} type_schemas={} output_types={} preview={}",
|
|
62
|
+
module,
|
|
63
|
+
mode,
|
|
64
|
+
settings.model,
|
|
65
|
+
settings.temperature,
|
|
66
|
+
settings.max_tokens,
|
|
67
|
+
list(functions),
|
|
68
|
+
len(context) if context else 0,
|
|
69
|
+
list((type_schemas or {}).keys()),
|
|
70
|
+
list((function_output_types or {}).keys()),
|
|
71
|
+
(context[:500] + "…" if context and len(context) > 500 else (context or "")),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# Log the actual prompt messages (truncated for safety)
|
|
75
|
+
prompt_text = "\n".join(f"{m['role'].upper()}: {m['content']}" for m in messages)
|
|
76
|
+
if len(prompt_text) > 4000:
|
|
77
|
+
prompt_text = prompt_text[:4000] + "…"
|
|
78
|
+
logger.debug("LLM prompt for {}:\n{}", module, prompt_text)
|
|
79
|
+
try:
|
|
80
|
+
return litellm.completion(
|
|
81
|
+
model=settings.model,
|
|
82
|
+
messages=messages,
|
|
83
|
+
temperature=settings.temperature,
|
|
84
|
+
max_tokens=settings.max_tokens,
|
|
85
|
+
)
|
|
86
|
+
except Exception as exc: # pragma: no cover - network path not executed in tests
|
|
87
|
+
raise GenerationError(f"LLM call failed: {exc}") from exc
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _extract_content(response) -> str:
|
|
91
|
+
try:
|
|
92
|
+
content = response["choices"][0]["message"]["content"]
|
|
93
|
+
except Exception as exc: # pragma: no cover
|
|
94
|
+
raise GenerationError("Unexpected LLM response structure") from exc
|
|
95
|
+
|
|
96
|
+
if not content or not content.strip():
|
|
97
|
+
raise GenerationError("LLM returned empty content")
|
|
98
|
+
return content
|
wishful/llm/prompts.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import List, Sequence
|
|
4
|
+
|
|
5
|
+
from wishful.config import settings
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def build_messages(
|
|
9
|
+
module: str,
|
|
10
|
+
functions: Sequence[str],
|
|
11
|
+
context: str | None,
|
|
12
|
+
type_schemas: dict[str, str] | None = None,
|
|
13
|
+
function_output_types: dict[str, str] | None = None,
|
|
14
|
+
mode: str | None = None,
|
|
15
|
+
) -> List[dict]:
|
|
16
|
+
user_parts = [f"Module: {module}"]
|
|
17
|
+
|
|
18
|
+
# Include type schemas if available
|
|
19
|
+
if type_schemas:
|
|
20
|
+
user_parts.append(
|
|
21
|
+
"Type definitions to include in the module:\n"
|
|
22
|
+
"(Copy these type definitions directly into the generated code. "
|
|
23
|
+
"Do NOT import them from other modules.)\n"
|
|
24
|
+
)
|
|
25
|
+
for type_name, schema in type_schemas.items():
|
|
26
|
+
user_parts.append(f"\n{schema}\n")
|
|
27
|
+
|
|
28
|
+
# Include function signatures with output types
|
|
29
|
+
if functions:
|
|
30
|
+
if function_output_types:
|
|
31
|
+
func_list = []
|
|
32
|
+
for func in functions:
|
|
33
|
+
if func in function_output_types:
|
|
34
|
+
output_type = function_output_types[func]
|
|
35
|
+
func_list.append(f"{func}(...) -> {output_type}")
|
|
36
|
+
else:
|
|
37
|
+
func_list.append(func)
|
|
38
|
+
user_parts.append(
|
|
39
|
+
"Functions to implement:\n" + "\n".join(f"- {f}" for f in func_list)
|
|
40
|
+
)
|
|
41
|
+
else:
|
|
42
|
+
user_parts.append(f"Functions to implement: {', '.join(functions)}")
|
|
43
|
+
|
|
44
|
+
if context:
|
|
45
|
+
user_parts.append("Context:\n" + context.strip())
|
|
46
|
+
|
|
47
|
+
if mode == "dynamic":
|
|
48
|
+
user_parts.append(
|
|
49
|
+
"Dynamic mode guidance:\n"
|
|
50
|
+
"- Treat the call-site context as one-off. Return a single, fully baked result.\n"
|
|
51
|
+
"- Do NOT build strings with templates, f-strings, or .format; write the final prose directly.\n"
|
|
52
|
+
"- Use contextual values (like settings, style, length hints) naturally in the narrative instead of inserting them verbatim at the start.\n"
|
|
53
|
+
"- Keep the function signature, but it's fine if the body ignores parameters after using them as creative guidance."
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
user_prompt = "\n\n".join(user_parts)
|
|
57
|
+
|
|
58
|
+
return [
|
|
59
|
+
{"role": "system", "content": settings.system_prompt},
|
|
60
|
+
{"role": "user", "content": user_prompt},
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def strip_code_fences(text: str) -> str:
|
|
65
|
+
"""Remove Markdown code fences if present."""
|
|
66
|
+
|
|
67
|
+
if "```" not in text:
|
|
68
|
+
return text
|
|
69
|
+
|
|
70
|
+
parts = text.split("```")
|
|
71
|
+
if len(parts) >= 3:
|
|
72
|
+
# content between first and second fence
|
|
73
|
+
return parts[1].strip('\n') if parts[0].strip() == "" else parts[1]+parts[2]
|
|
74
|
+
return text
|
wishful/logging.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from loguru import logger
|
|
8
|
+
from rich.logging import RichHandler
|
|
9
|
+
|
|
10
|
+
from wishful.config import settings
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
_configured = False
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _log_dir() -> Path:
|
|
17
|
+
return settings.cache_dir / "_logs"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _configure_rich_console(level: str):
|
|
21
|
+
# Configure a dedicated logger using RichHandler
|
|
22
|
+
handler = RichHandler(
|
|
23
|
+
show_time=True,
|
|
24
|
+
show_level=True,
|
|
25
|
+
show_path=True,
|
|
26
|
+
enable_link_path=True,
|
|
27
|
+
rich_tracebacks=True,
|
|
28
|
+
markup=True,
|
|
29
|
+
log_time_format="%H:%M:%S.%f",
|
|
30
|
+
)
|
|
31
|
+
handler.setLevel(level)
|
|
32
|
+
|
|
33
|
+
import logging
|
|
34
|
+
|
|
35
|
+
rich_logger = logging.getLogger("wishful.rich")
|
|
36
|
+
rich_logger.handlers.clear()
|
|
37
|
+
rich_logger.propagate = False
|
|
38
|
+
rich_logger.setLevel(level)
|
|
39
|
+
rich_logger.addHandler(handler)
|
|
40
|
+
return rich_logger
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def configure_logging(force: bool = False) -> None:
|
|
44
|
+
global _configured
|
|
45
|
+
|
|
46
|
+
# Avoid repeated reconfiguration unless forced
|
|
47
|
+
if _configured and not force:
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
logger.remove()
|
|
51
|
+
|
|
52
|
+
level = (settings.log_level or ("DEBUG" if settings.debug else "WARNING")).upper()
|
|
53
|
+
|
|
54
|
+
# Console sink: only when debug or level <= INFO
|
|
55
|
+
# Console via RichHandler for nicer TTY output
|
|
56
|
+
rich_logger = _configure_rich_console(level)
|
|
57
|
+
logger.add(
|
|
58
|
+
lambda m: rich_logger.log(
|
|
59
|
+
m.record["level"].no,
|
|
60
|
+
f"{m.record['module']}:{m.record['function']}:{m.record['line']} | {m.record['message']}",
|
|
61
|
+
),
|
|
62
|
+
level=level,
|
|
63
|
+
enqueue=False,
|
|
64
|
+
backtrace=False,
|
|
65
|
+
diagnose=False,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
if settings.log_to_file:
|
|
69
|
+
log_dir = _log_dir()
|
|
70
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
logfile = log_dir / f"{datetime.now():%Y-%m-%d}.log"
|
|
72
|
+
logfile.touch(exist_ok=True)
|
|
73
|
+
logger.add(
|
|
74
|
+
str(logfile),
|
|
75
|
+
level=level,
|
|
76
|
+
enqueue=False,
|
|
77
|
+
backtrace=False,
|
|
78
|
+
diagnose=False,
|
|
79
|
+
rotation=None,
|
|
80
|
+
retention=None,
|
|
81
|
+
format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level} | {message}",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
_configured = True
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# Configure once at import time with defaults
|
|
88
|
+
configure_logging()
|
wishful/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from typing import Iterable
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class SecurityError(ImportError):
|
|
8
|
+
"""Raised when generated code violates safety policy."""
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
_FORBIDDEN_IMPORTS = {"os", "subprocess", "sys"}
|
|
12
|
+
_FORBIDDEN_CALLS = {"eval", "exec"}
|
|
13
|
+
_WRITE_MODES = {"w", "a", "+"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _parse_source(source: str) -> ast.AST:
|
|
17
|
+
try:
|
|
18
|
+
return ast.parse(source)
|
|
19
|
+
except SyntaxError as exc:
|
|
20
|
+
raise ImportError(f"Generated code has syntax error: {exc}") from exc
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _check_imports(tree: ast.AST) -> None:
|
|
24
|
+
_validate_import_names(list(_iter_import_names(tree)))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _iter_import_names(tree: ast.AST):
|
|
28
|
+
yield from _import_names(tree)
|
|
29
|
+
yield from _importfrom_names(tree)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _import_names(tree: ast.AST):
|
|
33
|
+
for node in ast.walk(tree):
|
|
34
|
+
if isinstance(node, ast.Import):
|
|
35
|
+
for alias in node.names:
|
|
36
|
+
yield alias.name
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _importfrom_names(tree: ast.AST):
|
|
40
|
+
for node in ast.walk(tree):
|
|
41
|
+
if isinstance(node, ast.ImportFrom) and node.module:
|
|
42
|
+
yield node.module
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _validate_import_names(names: Iterable[str]) -> None:
|
|
46
|
+
for name in names:
|
|
47
|
+
if name.split(".")[0] in _FORBIDDEN_IMPORTS:
|
|
48
|
+
raise SecurityError(f"Forbidden import: {name}")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _check_calls(tree: ast.AST) -> None:
|
|
52
|
+
for node in ast.walk(tree):
|
|
53
|
+
if not isinstance(node, ast.Call):
|
|
54
|
+
continue
|
|
55
|
+
if isinstance(node.func, ast.Name):
|
|
56
|
+
_check_named_call(node.func.id, node)
|
|
57
|
+
elif isinstance(node.func, ast.Attribute):
|
|
58
|
+
_check_attribute_call(node.func)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _check_named_call(func_name: str, call: ast.Call) -> None:
|
|
62
|
+
if func_name in _FORBIDDEN_CALLS:
|
|
63
|
+
raise SecurityError(f"Forbidden call: {func_name}()")
|
|
64
|
+
if func_name == "open":
|
|
65
|
+
_validate_open_call(call)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _check_attribute_call(attr: ast.Attribute) -> None:
|
|
69
|
+
dotted = _resolve_attribute_name(attr)
|
|
70
|
+
if dotted.startswith("os.") or dotted.startswith("subprocess."):
|
|
71
|
+
raise SecurityError(f"Forbidden call: {dotted}()")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _resolve_attribute_name(attr: ast.Attribute) -> str:
|
|
75
|
+
parts = []
|
|
76
|
+
current: ast.AST | None = attr
|
|
77
|
+
while isinstance(current, ast.Attribute):
|
|
78
|
+
parts.append(current.attr)
|
|
79
|
+
current = current.value
|
|
80
|
+
if isinstance(current, ast.Name):
|
|
81
|
+
parts.append(current.id)
|
|
82
|
+
return ".".join(reversed(parts))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _validate_open_call(call: ast.Call) -> None:
|
|
86
|
+
mode_arg = _extract_open_mode(call)
|
|
87
|
+
if mode_arg and any(ch in mode_arg for ch in _WRITE_MODES):
|
|
88
|
+
raise SecurityError("open() in write/append mode is blocked")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _extract_open_mode(call: ast.Call) -> str | None:
|
|
92
|
+
positional = _extract_positional_mode(call)
|
|
93
|
+
if positional is not None:
|
|
94
|
+
return positional
|
|
95
|
+
return _extract_keyword_mode(call)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _extract_positional_mode(call: ast.Call) -> str | None:
|
|
99
|
+
if len(call.args) > 1 and isinstance(call.args[1], ast.Constant):
|
|
100
|
+
return str(call.args[1].value)
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _extract_keyword_mode(call: ast.Call) -> str | None:
|
|
105
|
+
for kw in call.keywords:
|
|
106
|
+
if kw.arg == "mode" and isinstance(kw.value, ast.Constant):
|
|
107
|
+
return str(kw.value.value)
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _check_forbidden_builtins(tree: ast.AST) -> None:
|
|
112
|
+
names = {node.id for node in ast.walk(tree) if isinstance(node, ast.Name)}
|
|
113
|
+
forbidden = names & _FORBIDDEN_CALLS
|
|
114
|
+
if forbidden:
|
|
115
|
+
joined = ", ".join(sorted(forbidden))
|
|
116
|
+
raise SecurityError(f"Forbidden builtins present: {joined}")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def validate_code(source: str, *, allow_unsafe: bool = False) -> None:
|
|
120
|
+
"""Perform light-weight static checks on generated code.
|
|
121
|
+
|
|
122
|
+
The goal is to block obviously dangerous constructs without being overly
|
|
123
|
+
restrictive. Users can opt-out by setting `allow_unsafe=True`.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
if allow_unsafe:
|
|
127
|
+
return
|
|
128
|
+
|
|
129
|
+
tree = _parse_source(source)
|
|
130
|
+
_check_imports(tree)
|
|
131
|
+
_check_calls(tree)
|
|
132
|
+
_check_forbidden_builtins(tree)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Type registry for complex type hints in wishful."""
|
|
2
|
+
|
|
3
|
+
from wishful.types.registry import (
|
|
4
|
+
TypeRegistry,
|
|
5
|
+
clear_type_registry,
|
|
6
|
+
get_all_type_schemas,
|
|
7
|
+
get_output_type_for_function,
|
|
8
|
+
get_type_schema,
|
|
9
|
+
type,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"type",
|
|
14
|
+
"TypeRegistry",
|
|
15
|
+
"get_type_schema",
|
|
16
|
+
"get_all_type_schemas",
|
|
17
|
+
"get_output_type_for_function",
|
|
18
|
+
"clear_type_registry",
|
|
19
|
+
]
|