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/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,5 @@
1
+ """Safety checks for generated code."""
2
+
3
+ from .validator import validate_code, SecurityError
4
+
5
+ __all__ = ["validate_code", "SecurityError"]
@@ -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,8 @@
1
+ """Wishful static namespace - cached code generation.
2
+
3
+ Modules imported from wishful.static.* are generated once, cached to disk,
4
+ and reused on subsequent imports for optimal performance.
5
+
6
+ Example:
7
+ from wishful.static.text import extract_emails
8
+ """
@@ -0,0 +1,7 @@
1
+ """Type stub for wishful.static namespace.
2
+
3
+ This stub helps IDEs and type checkers understand that wishful.static.*
4
+ is a valid namespace for dynamically generated modules.
5
+ """
6
+
7
+ def __getattr__(name: str) -> object: ...
@@ -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
+ ]