wishful 0.1.0__tar.gz

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-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,233 @@
1
+ Metadata-Version: 2.3
2
+ Name: wishful
3
+ Version: 0.1.0
4
+ Summary: Wishful thinking for Python
5
+ Author: Pyro
6
+ Author-email: Pyro <pyros.sd.models@gmail.com>
7
+ Requires-Dist: litellm>=1.40.0
8
+ Requires-Dist: rich>=13.7.0
9
+ Requires-Dist: python-dotenv>=1.0.1
10
+ Requires-Dist: pytest>=8.3.0 ; extra == 'test'
11
+ Requires-Python: >=3.12
12
+ Provides-Extra: test
13
+ Description-Content-Type: text/markdown
14
+
15
+ # wishful 🪄
16
+
17
+ > _"Code so good, you'd think it was wishful thinking"_
18
+
19
+ Stop writing boilerplate. Start wishing for it instead.
20
+
21
+ **wishful** turns your wildest import dreams into reality. Just write the import you _wish_ existed, and an LLM conjures up the code on the spot. The first run? Pure magic. Every run after? Blazing fast, because it's cached like real Python.
22
+
23
+ Think of it as **wishful thinking, but for imports**. The kind that actually works.
24
+
25
+ ## ✨ Quick Wish
26
+
27
+ **1. Install the dream**
28
+
29
+ ```bash
30
+ pip install wishful
31
+ ```
32
+
33
+ **2. Set your credentials** (litellm reads the usual suspects)
34
+
35
+ Export them or toss them in a `.env` file—we'll find them:
36
+
37
+
38
+ ```bash
39
+ export OPENAI_API_KEY=...
40
+ export DEFAULT_MODEL=azure/gpt-4.1
41
+ ``
42
+
43
+ or
44
+
45
+ ```bash
46
+ export AZURE_API_KEY=...
47
+ export AZURE_API_BASE=https://<your-endpoint>.openai.azure.com/
48
+ export AZURE_API_VERSION=2025-04-01-preview
49
+ export DEFAULT_MODEL=azure/gpt-4.1
50
+ ```
51
+
52
+ or any provider else supported by litellm
53
+
54
+
55
+
56
+
57
+
58
+ **3. Import your wildest fantasies**
59
+
60
+ ```python
61
+ from wishful.text import extract_emails
62
+ from wishful.dates import to_yyyy_mm_dd
63
+
64
+ raw = "Contact us at team@example.com or sales@demo.dev"
65
+ print(extract_emails(raw)) # ['team@example.com', 'sales@demo.dev']
66
+ print(to_yyyy_mm_dd("31.12.2025")) # '2025-12-31'
67
+ ```
68
+
69
+ **What just happened?**
70
+
71
+ - **First import**: wishful waves its wand 🪄, asks the LLM to write `extract_emails` and `to_yyyy_mm_dd`, validates the code for safety, and caches it to `.wishful/text.py` and `.wishful/dates.py`.
72
+ - **Every subsequent run**: instant. Just regular Python imports. No latency, no drama, no API calls.
73
+
74
+ It's like having a junior dev who never sleeps and always delivers exactly what you asked for (well, _almost_ always).
75
+
76
+ ---
77
+
78
+ ## 🎯 Wishful Guidance: Help the AI Read Your Mind
79
+
80
+ Want better results? Drop hints. Literal comments. wishful reads the code _around_ your import and forwards that context to the LLM.
81
+
82
+ ```python
83
+ # desired: parse standard nginx combined logs into list of dicts
84
+ from wishful.logs import parse_nginx_logs
85
+
86
+ records = parse_nginx_logs(Path("/var/log/nginx/access.log").read_text())
87
+ ```
88
+
89
+ The AI sees your comment and knows _exactly_ what you're after. It's like pair programming, but your partner is a disembodied intelligence with questionable opinions about semicolons.
90
+
91
+ ---
92
+
93
+ ## 🗄️ Cache Ops: Because Sometimes Wishes Need Revising
94
+
95
+ ```python
96
+ import wishful
97
+
98
+ # See what you've wished for
99
+ wishful.inspect_cache() # ['.wishful/text.py', '.wishful/dates.py']
100
+
101
+ # Regret a wish? Regenerate it
102
+ wishful.regenerate("wishful.text") # Next import re-generates from scratch
103
+
104
+ # Nuclear option: forget everything
105
+ wishful.clear_cache() # Deletes the entire .wishful/ directory
106
+ ```
107
+
108
+ The cache is just regular Python files in `.wishful/`. Want to tweak the generated code? Edit it directly. It's your wish, after all.
109
+
110
+ ---
111
+
112
+ ## ⚙️ Configuration: Fine-Tune Your Wishes
113
+
114
+ ```python
115
+ import wishful
116
+
117
+ wishful.configure(
118
+ model="gpt-4o-mini", # Switch models like changing channels
119
+ cache_dir="/tmp/.wishful", # Hide your wishes somewhere else
120
+ spinner=False, # Silence the "generating..." spinner
121
+ review=True, # Paranoid? Review code before it runs
122
+ allow_unsafe=False, # Keep the safety rails ON (recommended)
123
+ )
124
+ ```
125
+
126
+ ### Environment Variables (for the env-obsessed)
127
+
128
+ Set these in your shell or `.env` file:
129
+
130
+ - `WISHFUL_MODEL` / `DEFAULT_MODEL` — which AI overlord to summon
131
+ - `WISHFUL_CACHE_DIR` — where to stash generated wishes (default: `.wishful`)
132
+ - `WISHFUL_REVIEW` — set to `1` to manually approve every wish (trust issues?)
133
+ - `WISHFUL_DEBUG` — verbose logging for when things go sideways
134
+ - `WISHFUL_UNSAFE` — set to `1` to disable safety checks (⚠️ danger zone)
135
+ - `WISHFUL_SPINNER` — set to `0` to disable the fancy spinner
136
+ - `WISHFUL_MAX_TOKENS` — cap the LLM's verbosity (default: 800)
137
+ - `WISHFUL_TEMPERATURE` — creativity dial (default: 0 = boring but safe)
138
+
139
+ ---
140
+
141
+ ## 🛡️ Safety Rails: Wishful Isn't _That_ Reckless
142
+
143
+ We're not complete anarchists here. Generated code gets AST-scanned to block obviously dangerous patterns:
144
+
145
+ - ❌ Imports like `os`, `subprocess`, `sys`
146
+ - ❌ Calls to `eval()` or `exec()`
147
+ - ❌ `open()` in write/append mode
148
+ - ❌ Shenanigans like `os.system()` or `subprocess.call()`
149
+
150
+ **Override at your own peril**: `WISHFUL_UNSAFE=1` or `allow_unsafe=True` turns off the guardrails. We won't judge. (We will _totally_ judge.)
151
+
152
+ ---
153
+
154
+ ## 🧪 Testing: Wishes Without Consequences
155
+
156
+ Need deterministic, offline behavior? Set `WISHFUL_FAKE_LLM=1` and wishful will generate placeholder stub functions instead of hitting the network.
157
+
158
+ Perfect for CI, unit tests, or when your Wi-Fi is acting up.
159
+
160
+ ```bash
161
+ export WISHFUL_FAKE_LLM=1
162
+ python my_tests.py # No API calls, just predictable stubs
163
+ ```
164
+
165
+ ---
166
+
167
+ ## 🔮 How the Magic Actually Works
168
+
169
+ Here's the 30-second version:
170
+
171
+ 1. **Import hook**: wishful installs a `MagicFinder` on `sys.meta_path` that intercepts `wishful.*` imports.
172
+ 2. **Cache check**: If `.wishful/<module>.py` exists, it loads instantly. No AI needed.
173
+ 3. **LLM generation**: If not cached, wishful calls the LLM (via `litellm`) to generate the code based on your import and surrounding context.
174
+ 4. **Validation**: The generated code is AST-parsed and safety-checked (unless you disabled that like a madman).
175
+ 5. **Execution**: Code is written to `.wishful/`, compiled, and executed as the import result.
176
+ 6. **Transparency**: The cache is just plain Python files. Edit them. Commit them. They're yours.
177
+
178
+ It's import hooks meets LLMs meets "why didn't this exist already?"
179
+
180
+ ---
181
+
182
+ ## 🎭 Fun with Wishful Thinking
183
+
184
+ ```python
185
+ # Need some cosmic horror? Just wish for it.
186
+ from wishful.story import cosmic_horror_intro
187
+
188
+ intro = cosmic_horror_intro(
189
+ setting="a deserted amusement park",
190
+ word_count_at_least=100
191
+ )
192
+ print(intro) # 🎢👻
193
+
194
+ # Math that writes itself
195
+ from wishful.numbers import primes_from_to, sum_list
196
+
197
+ total = sum_list(list=primes_from_to(1, 100))
198
+ print(total) # 1060 (probably)
199
+
200
+ # Because who has time to write date parsers?
201
+ from wishful.dates import parse_fuzzy_date
202
+
203
+ print(parse_fuzzy_date("next Tuesday")) # Your guess is as good as mine
204
+ ```
205
+
206
+ ---
207
+
208
+ ## 🤔 FAQ (Frequently Asked Wishes)
209
+
210
+ **Q: Is this production-ready?**
211
+ A: Define "production." 🙃
212
+
213
+ **Q: What if the LLM generates bad code?**
214
+ A: That's what the cache is for. Check `.wishful/`, tweak it, commit it, and it's locked in.
215
+
216
+ **Q: Can I use this with OpenAI/Claude/local models?**
217
+ A: Yep! We use `litellm`, so anything it supports, we support.
218
+
219
+ **Q: What if I import something that doesn't make sense?**
220
+ A: The LLM will do its best. Results may vary. Hilarity may ensue.
221
+
222
+ **Q: Is this just lazy programming?**
223
+ A: It's not lazy. It's _efficient wishful thinking_. 😎
224
+
225
+ ---
226
+
227
+ ## 📜 License
228
+
229
+ MIT. Wish responsibly.
230
+
231
+ **Go forth and wish.** ✨
232
+
233
+ Your imports will never be the same.
@@ -0,0 +1,219 @@
1
+ # wishful 🪄
2
+
3
+ > _"Code so good, you'd think it was wishful thinking"_
4
+
5
+ Stop writing boilerplate. Start wishing for it instead.
6
+
7
+ **wishful** turns your wildest import dreams into reality. Just write the import you _wish_ existed, and an LLM conjures up the code on the spot. The first run? Pure magic. Every run after? Blazing fast, because it's cached like real Python.
8
+
9
+ Think of it as **wishful thinking, but for imports**. The kind that actually works.
10
+
11
+ ## ✨ Quick Wish
12
+
13
+ **1. Install the dream**
14
+
15
+ ```bash
16
+ pip install wishful
17
+ ```
18
+
19
+ **2. Set your credentials** (litellm reads the usual suspects)
20
+
21
+ Export them or toss them in a `.env` file—we'll find them:
22
+
23
+
24
+ ```bash
25
+ export OPENAI_API_KEY=...
26
+ export DEFAULT_MODEL=azure/gpt-4.1
27
+ ``
28
+
29
+ or
30
+
31
+ ```bash
32
+ export AZURE_API_KEY=...
33
+ export AZURE_API_BASE=https://<your-endpoint>.openai.azure.com/
34
+ export AZURE_API_VERSION=2025-04-01-preview
35
+ export DEFAULT_MODEL=azure/gpt-4.1
36
+ ```
37
+
38
+ or any provider else supported by litellm
39
+
40
+
41
+
42
+
43
+
44
+ **3. Import your wildest fantasies**
45
+
46
+ ```python
47
+ from wishful.text import extract_emails
48
+ from wishful.dates import to_yyyy_mm_dd
49
+
50
+ raw = "Contact us at team@example.com or sales@demo.dev"
51
+ print(extract_emails(raw)) # ['team@example.com', 'sales@demo.dev']
52
+ print(to_yyyy_mm_dd("31.12.2025")) # '2025-12-31'
53
+ ```
54
+
55
+ **What just happened?**
56
+
57
+ - **First import**: wishful waves its wand 🪄, asks the LLM to write `extract_emails` and `to_yyyy_mm_dd`, validates the code for safety, and caches it to `.wishful/text.py` and `.wishful/dates.py`.
58
+ - **Every subsequent run**: instant. Just regular Python imports. No latency, no drama, no API calls.
59
+
60
+ It's like having a junior dev who never sleeps and always delivers exactly what you asked for (well, _almost_ always).
61
+
62
+ ---
63
+
64
+ ## 🎯 Wishful Guidance: Help the AI Read Your Mind
65
+
66
+ Want better results? Drop hints. Literal comments. wishful reads the code _around_ your import and forwards that context to the LLM.
67
+
68
+ ```python
69
+ # desired: parse standard nginx combined logs into list of dicts
70
+ from wishful.logs import parse_nginx_logs
71
+
72
+ records = parse_nginx_logs(Path("/var/log/nginx/access.log").read_text())
73
+ ```
74
+
75
+ The AI sees your comment and knows _exactly_ what you're after. It's like pair programming, but your partner is a disembodied intelligence with questionable opinions about semicolons.
76
+
77
+ ---
78
+
79
+ ## 🗄️ Cache Ops: Because Sometimes Wishes Need Revising
80
+
81
+ ```python
82
+ import wishful
83
+
84
+ # See what you've wished for
85
+ wishful.inspect_cache() # ['.wishful/text.py', '.wishful/dates.py']
86
+
87
+ # Regret a wish? Regenerate it
88
+ wishful.regenerate("wishful.text") # Next import re-generates from scratch
89
+
90
+ # Nuclear option: forget everything
91
+ wishful.clear_cache() # Deletes the entire .wishful/ directory
92
+ ```
93
+
94
+ The cache is just regular Python files in `.wishful/`. Want to tweak the generated code? Edit it directly. It's your wish, after all.
95
+
96
+ ---
97
+
98
+ ## ⚙️ Configuration: Fine-Tune Your Wishes
99
+
100
+ ```python
101
+ import wishful
102
+
103
+ wishful.configure(
104
+ model="gpt-4o-mini", # Switch models like changing channels
105
+ cache_dir="/tmp/.wishful", # Hide your wishes somewhere else
106
+ spinner=False, # Silence the "generating..." spinner
107
+ review=True, # Paranoid? Review code before it runs
108
+ allow_unsafe=False, # Keep the safety rails ON (recommended)
109
+ )
110
+ ```
111
+
112
+ ### Environment Variables (for the env-obsessed)
113
+
114
+ Set these in your shell or `.env` file:
115
+
116
+ - `WISHFUL_MODEL` / `DEFAULT_MODEL` — which AI overlord to summon
117
+ - `WISHFUL_CACHE_DIR` — where to stash generated wishes (default: `.wishful`)
118
+ - `WISHFUL_REVIEW` — set to `1` to manually approve every wish (trust issues?)
119
+ - `WISHFUL_DEBUG` — verbose logging for when things go sideways
120
+ - `WISHFUL_UNSAFE` — set to `1` to disable safety checks (⚠️ danger zone)
121
+ - `WISHFUL_SPINNER` — set to `0` to disable the fancy spinner
122
+ - `WISHFUL_MAX_TOKENS` — cap the LLM's verbosity (default: 800)
123
+ - `WISHFUL_TEMPERATURE` — creativity dial (default: 0 = boring but safe)
124
+
125
+ ---
126
+
127
+ ## 🛡️ Safety Rails: Wishful Isn't _That_ Reckless
128
+
129
+ We're not complete anarchists here. Generated code gets AST-scanned to block obviously dangerous patterns:
130
+
131
+ - ❌ Imports like `os`, `subprocess`, `sys`
132
+ - ❌ Calls to `eval()` or `exec()`
133
+ - ❌ `open()` in write/append mode
134
+ - ❌ Shenanigans like `os.system()` or `subprocess.call()`
135
+
136
+ **Override at your own peril**: `WISHFUL_UNSAFE=1` or `allow_unsafe=True` turns off the guardrails. We won't judge. (We will _totally_ judge.)
137
+
138
+ ---
139
+
140
+ ## 🧪 Testing: Wishes Without Consequences
141
+
142
+ Need deterministic, offline behavior? Set `WISHFUL_FAKE_LLM=1` and wishful will generate placeholder stub functions instead of hitting the network.
143
+
144
+ Perfect for CI, unit tests, or when your Wi-Fi is acting up.
145
+
146
+ ```bash
147
+ export WISHFUL_FAKE_LLM=1
148
+ python my_tests.py # No API calls, just predictable stubs
149
+ ```
150
+
151
+ ---
152
+
153
+ ## 🔮 How the Magic Actually Works
154
+
155
+ Here's the 30-second version:
156
+
157
+ 1. **Import hook**: wishful installs a `MagicFinder` on `sys.meta_path` that intercepts `wishful.*` imports.
158
+ 2. **Cache check**: If `.wishful/<module>.py` exists, it loads instantly. No AI needed.
159
+ 3. **LLM generation**: If not cached, wishful calls the LLM (via `litellm`) to generate the code based on your import and surrounding context.
160
+ 4. **Validation**: The generated code is AST-parsed and safety-checked (unless you disabled that like a madman).
161
+ 5. **Execution**: Code is written to `.wishful/`, compiled, and executed as the import result.
162
+ 6. **Transparency**: The cache is just plain Python files. Edit them. Commit them. They're yours.
163
+
164
+ It's import hooks meets LLMs meets "why didn't this exist already?"
165
+
166
+ ---
167
+
168
+ ## 🎭 Fun with Wishful Thinking
169
+
170
+ ```python
171
+ # Need some cosmic horror? Just wish for it.
172
+ from wishful.story import cosmic_horror_intro
173
+
174
+ intro = cosmic_horror_intro(
175
+ setting="a deserted amusement park",
176
+ word_count_at_least=100
177
+ )
178
+ print(intro) # 🎢👻
179
+
180
+ # Math that writes itself
181
+ from wishful.numbers import primes_from_to, sum_list
182
+
183
+ total = sum_list(list=primes_from_to(1, 100))
184
+ print(total) # 1060 (probably)
185
+
186
+ # Because who has time to write date parsers?
187
+ from wishful.dates import parse_fuzzy_date
188
+
189
+ print(parse_fuzzy_date("next Tuesday")) # Your guess is as good as mine
190
+ ```
191
+
192
+ ---
193
+
194
+ ## 🤔 FAQ (Frequently Asked Wishes)
195
+
196
+ **Q: Is this production-ready?**
197
+ A: Define "production." 🙃
198
+
199
+ **Q: What if the LLM generates bad code?**
200
+ A: That's what the cache is for. Check `.wishful/`, tweak it, commit it, and it's locked in.
201
+
202
+ **Q: Can I use this with OpenAI/Claude/local models?**
203
+ A: Yep! We use `litellm`, so anything it supports, we support.
204
+
205
+ **Q: What if I import something that doesn't make sense?**
206
+ A: The LLM will do its best. Results may vary. Hilarity may ensue.
207
+
208
+ **Q: Is this just lazy programming?**
209
+ A: It's not lazy. It's _efficient wishful thinking_. 😎
210
+
211
+ ---
212
+
213
+ ## 📜 License
214
+
215
+ MIT. Wish responsibly.
216
+
217
+ **Go forth and wish.** ✨
218
+
219
+ Your imports will never be the same.
@@ -0,0 +1,23 @@
1
+ [project]
2
+ name = "wishful"
3
+ version = "0.1.0"
4
+ description = "Wishful thinking for Python"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Pyro", email = "pyros.sd.models@gmail.com" }
8
+ ]
9
+ requires-python = ">=3.12"
10
+ dependencies = [
11
+ "litellm>=1.40.0",
12
+ "rich>=13.7.0",
13
+ "python-dotenv>=1.0.1",
14
+ ]
15
+
16
+ [project.optional-dependencies]
17
+ test = [
18
+ "pytest>=8.3.0",
19
+ ]
20
+
21
+ [build-system]
22
+ requires = ["uv_build>=0.9.1,<0.10.0"]
23
+ build-backend = "uv_build"
@@ -0,0 +1,54 @@
1
+ """wishful - Just-in-Time code generation via import hooks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import sys
7
+ from typing import List
8
+
9
+ from wishful.cache import manager as cache
10
+ from wishful.config import configure, reset_defaults, settings
11
+ from wishful.core.finder import install as install_finder
12
+ from wishful.llm.client import GenerationError
13
+ from wishful.safety.validator import SecurityError
14
+
15
+ # Install on import so `import magic.xyz` is intercepted immediately.
16
+ install_finder()
17
+
18
+ __all__ = [
19
+ "configure",
20
+ "clear_cache",
21
+ "inspect_cache",
22
+ "regenerate",
23
+ "SecurityError",
24
+ "GenerationError",
25
+ ]
26
+
27
+
28
+ def clear_cache() -> None:
29
+ """Delete all generated files from the cache directory."""
30
+
31
+ cache.clear_cache()
32
+ # Remove any loaded wishful modules so they regenerate on next import.
33
+ for name in list(sys.modules):
34
+ if name.startswith("wishful."):
35
+ sys.modules.pop(name, None)
36
+
37
+
38
+ def inspect_cache() -> List[str]:
39
+ """Return a list of cached module file paths as strings."""
40
+
41
+ return [str(p) for p in cache.inspect_cache()]
42
+
43
+
44
+ def regenerate(module_name: str) -> None:
45
+ """Force regeneration of a module on next import."""
46
+
47
+ if not module_name.startswith("wishful"):
48
+ module_name = f"wishful.{module_name}"
49
+ cache.delete_cached(module_name)
50
+ sys.modules.pop(module_name, None)
51
+ importlib.invalidate_caches()
52
+
53
+
54
+ __version__ = "0.1.0"
@@ -0,0 +1,23 @@
1
+ """Cache utilities for wishful."""
2
+
3
+ from .manager import (
4
+ clear_cache,
5
+ delete_cached,
6
+ ensure_cache_dir,
7
+ has_cached,
8
+ inspect_cache,
9
+ module_path,
10
+ read_cached,
11
+ write_cached,
12
+ )
13
+
14
+ __all__ = [
15
+ "read_cached",
16
+ "write_cached",
17
+ "clear_cache",
18
+ "inspect_cache",
19
+ "module_path",
20
+ "ensure_cache_dir",
21
+ "delete_cached",
22
+ "has_cached",
23
+ ]
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ from pathlib import Path
5
+ from typing import Iterable, List, Optional
6
+
7
+ from wishful.config import settings
8
+
9
+
10
+ def module_path(fullname: str) -> Path:
11
+ # Strip leading namespace "wishful" and map dots to directories.
12
+ parts = fullname.split(".")
13
+ if parts[0] == "wishful":
14
+ parts = parts[1:]
15
+ relative = Path(*parts) if parts else Path("__init__")
16
+ return settings.cache_dir / relative.with_suffix(".py")
17
+
18
+
19
+ def ensure_cache_dir() -> Path:
20
+ settings.cache_dir.mkdir(parents=True, exist_ok=True)
21
+ return settings.cache_dir
22
+
23
+
24
+ def read_cached(fullname: str) -> Optional[str]:
25
+ path = module_path(fullname)
26
+ if path.exists():
27
+ return path.read_text()
28
+ return None
29
+
30
+
31
+ def write_cached(fullname: str, source: str) -> Path:
32
+ path = module_path(fullname)
33
+ path.parent.mkdir(parents=True, exist_ok=True)
34
+ path.write_text(source)
35
+ return path
36
+
37
+
38
+ def delete_cached(fullname: str) -> None:
39
+ path = module_path(fullname)
40
+ if path.exists():
41
+ path.unlink()
42
+
43
+
44
+ def clear_cache() -> None:
45
+ if settings.cache_dir.exists():
46
+ shutil.rmtree(settings.cache_dir)
47
+
48
+
49
+ def inspect_cache() -> List[Path]:
50
+ if not settings.cache_dir.exists():
51
+ return []
52
+ return sorted(settings.cache_dir.rglob("*.py"))
53
+
54
+
55
+ def has_cached(fullname: str) -> bool:
56
+ return module_path(fullname).exists()
@@ -0,0 +1,90 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ from dotenv import load_dotenv
9
+
10
+ # Load environment variables from a local .env if present so users don't need to
11
+ # export them manually when running examples.
12
+ load_dotenv()
13
+
14
+
15
+ _DEFAULT_MODEL = os.getenv("DEFAULT_MODEL", os.getenv("WISHFUL_MODEL", "azure/gpt-4.1"))
16
+
17
+
18
+ @dataclass
19
+ class Settings:
20
+ """Runtime configuration for wishful.
21
+
22
+ Values are mutable at runtime via :func:`configure` to make tests and user
23
+ code ergonomics-friendly. Defaults are sourced from environment variables.
24
+ """
25
+
26
+ model: str = _DEFAULT_MODEL
27
+ cache_dir: Path = field(default_factory=lambda: Path(os.getenv("WISHFUL_CACHE_DIR", ".wishful")))
28
+ review: bool = os.getenv("WISHFUL_REVIEW", "0") == "1"
29
+ debug: bool = os.getenv("WISHFUL_DEBUG", "0") == "1"
30
+ allow_unsafe: bool = os.getenv("WISHFUL_UNSAFE", "0") == "1"
31
+ spinner: bool = os.getenv("WISHFUL_SPINNER", "1") != "0"
32
+ max_tokens: int = int(os.getenv("WISHFUL_MAX_TOKENS", "800"))
33
+ temperature: float = float(os.getenv("WISHFUL_TEMPERATURE", "0"))
34
+
35
+ def copy(self) -> "Settings":
36
+ return Settings(
37
+ model=self.model,
38
+ cache_dir=self.cache_dir,
39
+ review=self.review,
40
+ debug=self.debug,
41
+ allow_unsafe=self.allow_unsafe,
42
+ spinner=self.spinner,
43
+ max_tokens=self.max_tokens,
44
+ temperature=self.temperature,
45
+ )
46
+
47
+
48
+ settings = Settings()
49
+
50
+
51
+ def configure(
52
+ *,
53
+ model: Optional[str] = None,
54
+ cache_dir: Optional[str | Path] = None,
55
+ review: Optional[bool] = None,
56
+ debug: Optional[bool] = None,
57
+ allow_unsafe: Optional[bool] = None,
58
+ spinner: Optional[bool] = None,
59
+ temperature: Optional[float] = None,
60
+ max_tokens: Optional[int] = None,
61
+ ) -> None:
62
+ """Update global settings in-place.
63
+
64
+ All parameters are optional; only provided values overwrite current
65
+ settings. Accepts both strings and :class:`pathlib.Path` for `cache_dir`.
66
+ """
67
+
68
+ if model is not None:
69
+ settings.model = model
70
+ if cache_dir is not None:
71
+ settings.cache_dir = Path(cache_dir)
72
+ if review is not None:
73
+ settings.review = review
74
+ if debug is not None:
75
+ settings.debug = debug
76
+ if allow_unsafe is not None:
77
+ settings.allow_unsafe = allow_unsafe
78
+ if spinner is not None:
79
+ settings.spinner = spinner
80
+ if temperature is not None:
81
+ settings.temperature = temperature
82
+ if max_tokens is not None:
83
+ settings.max_tokens = max_tokens
84
+
85
+
86
+ def reset_defaults() -> None:
87
+ """Reset settings to environment-driven defaults (useful for tests)."""
88
+
89
+ global settings
90
+ settings = Settings()
@@ -0,0 +1,6 @@
1
+ """Core import machinery for wishful."""
2
+
3
+ from .finder import MagicFinder, install
4
+ from .loader import MagicLoader
5
+
6
+ __all__ = ["MagicFinder", "MagicLoader", "install"]
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ import inspect
5
+ import linecache
6
+ from pathlib import Path
7
+ from textwrap import dedent
8
+ from typing import List, Optional, Sequence, Tuple
9
+
10
+
11
+ class ImportContext:
12
+ def __init__(self, functions: Sequence[str], context: str | None):
13
+ self.functions = list(functions)
14
+ self.context = context
15
+
16
+ def __repr__(self) -> str: # pragma: no cover - debug helper
17
+ return f"ImportContext(functions={self.functions}, context={self.context!r})"
18
+
19
+
20
+ def _gather_context_lines(filename: str, lineno: int, radius: int = 2) -> str:
21
+ lines = linecache.getlines(filename)
22
+ if not lines:
23
+ return ""
24
+ start = max(lineno, 1) - 1
25
+ end = min(lineno + radius, len(lines))
26
+ snippet = lines[start:end]
27
+ return "".join(snippet).strip()
28
+
29
+
30
+ def _parse_imported_names(source_line: str, fullname: str) -> List[str]:
31
+ try:
32
+ tree = ast.parse(dedent(source_line))
33
+ except SyntaxError:
34
+ return []
35
+
36
+ names: List[str] = []
37
+ for node in ast.walk(tree):
38
+ if isinstance(node, ast.ImportFrom):
39
+ if node.module and node.module.startswith("wishful") and fullname.startswith(node.module):
40
+ for alias in node.names:
41
+ # Use original name (not alias) because that's what the module must define.
42
+ names.append(alias.name)
43
+ elif isinstance(node, ast.Import):
44
+ for alias in node.names:
45
+ if alias.name.startswith("wishful") and fullname.startswith(alias.name):
46
+ # For `import wishful.foo as bar`, the target is bar (module).
47
+ target = alias.asname or alias.name.split(".")[-1]
48
+ names.append(target)
49
+ return names
50
+
51
+
52
+ def discover(fullname: str) -> ImportContext:
53
+ """Attempt to recover requested symbol names and nearby comments.
54
+
55
+ This uses stack inspection heuristics. It is best-effort; absence of
56
+ signals simply results in empty context.
57
+ """
58
+
59
+ frame = inspect.currentframe()
60
+ # Skip the discover() frame itself.
61
+ if frame:
62
+ frame = frame.f_back
63
+
64
+ while frame:
65
+ filename = frame.f_code.co_filename
66
+ lineno = frame.f_lineno
67
+
68
+ if filename.startswith("<"):
69
+ frame = frame.f_back
70
+ continue
71
+ normalized = filename.replace("\\", "/")
72
+ if "/src/wishful/" in normalized and "/tests/" not in normalized:
73
+ frame = frame.f_back
74
+ continue
75
+
76
+ code_line = linecache.getline(filename, lineno).strip()
77
+ if code_line:
78
+ functions = _parse_imported_names(code_line, fullname)
79
+ if functions:
80
+ context = _gather_context_lines(filename, lineno + 1, radius=3)
81
+ return ImportContext(functions=functions, context=context)
82
+
83
+ frame = frame.f_back
84
+
85
+ return ImportContext(functions=[], context=None)
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib.abc
4
+ import importlib.util
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ from wishful.core.loader import MagicLoader, MagicPackageLoader
10
+
11
+
12
+ MAGIC_NAMESPACE = "wishful"
13
+
14
+
15
+ class MagicFinder(importlib.abc.MetaPathFinder):
16
+ """Intercept imports for the `wishful.*` namespace."""
17
+
18
+ def find_spec(self, fullname: str, path, target=None):
19
+ if not fullname.startswith(MAGIC_NAMESPACE):
20
+ return None
21
+
22
+ # Check if this module actually exists on disk as part of our package
23
+ # If it does, let the default import mechanism handle it
24
+ parts = fullname.split('.')
25
+ if len(parts) >= 2:
26
+ # Check for our internal package modules
27
+ module_file = Path(__file__).parent.parent / parts[1]
28
+ if module_file.exists() or (module_file.with_suffix('.py')).exists():
29
+ return None
30
+
31
+ if fullname == MAGIC_NAMESPACE:
32
+ return importlib.util.spec_from_loader(fullname, MagicPackageLoader(), is_package=True)
33
+
34
+ loader = MagicLoader(fullname)
35
+ return importlib.util.spec_from_loader(fullname, loader, is_package=False)
36
+
37
+
38
+ def install() -> None:
39
+ """Register the finder if it is not already present."""
40
+
41
+ for finder in list(__import__("sys").meta_path): # type: ignore
42
+ if isinstance(finder, MagicFinder):
43
+ return
44
+ __import__("sys").meta_path.insert(0, MagicFinder())
@@ -0,0 +1,112 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib.abc
4
+ import importlib.util
5
+ import sys
6
+ from types import ModuleType
7
+ from typing import Optional, Sequence
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.safety.validator import SecurityError, validate_code
14
+ from wishful.ui import spinner
15
+
16
+
17
+ class MagicLoader(importlib.abc.Loader):
18
+ """Loader that returns dynamic modules backed by cache + LLM generation."""
19
+
20
+ def __init__(self, fullname: str):
21
+ self.fullname = fullname
22
+
23
+ def create_module(self, spec): # pragma: no cover - default works
24
+ return None
25
+
26
+ def exec_module(self, module: ModuleType) -> None:
27
+ context = discover(self.fullname)
28
+ functions = context.functions
29
+
30
+ source = cache.read_cached(self.fullname)
31
+ from_cache = source is not None
32
+
33
+ if source is None:
34
+ source = self._generate_and_cache(functions, context)
35
+
36
+ self._exec_source(source, module)
37
+
38
+ # If cached code is missing requested symbols, regenerate once.
39
+ if functions:
40
+ missing = [name for name in functions if name not in module.__dict__]
41
+ if missing:
42
+ if from_cache:
43
+ desired = sorted(set(functions) | self._declared_symbols(module))
44
+ cache.delete_cached(self.fullname)
45
+ source = self._generate_and_cache(desired, context)
46
+ self._exec_source(source, module, clear_first=True)
47
+ else:
48
+ raise GenerationError(
49
+ f"Generated module for {self.fullname} lacks symbols: {', '.join(missing)}"
50
+ )
51
+
52
+ self._attach_dynamic_getattr(module)
53
+
54
+ if settings.review:
55
+ print(f"Generated code for {self.fullname}:\n{source}\n")
56
+ answer = input("Run this code? [y/N]: ")
57
+ if answer.lower().strip() not in {"y", "yes"}:
58
+ cache.delete_cached(self.fullname)
59
+ raise ImportError("User rejected generated code.")
60
+
61
+ def _generate_and_cache(self, functions, context):
62
+ with spinner(f"Generating {self.fullname}"):
63
+ source = generate_module_code(self.fullname, functions, context.context)
64
+ cache.write_cached(self.fullname, source)
65
+ return source
66
+
67
+ def _exec_source(self, source: str, module: ModuleType, clear_first: bool = False) -> None:
68
+ try:
69
+ validate_code(source, allow_unsafe=settings.allow_unsafe)
70
+ except SecurityError:
71
+ raise
72
+ if clear_first:
73
+ module.__dict__.clear()
74
+ module.__file__ = str(cache.module_path(self.fullname))
75
+ module.__package__ = self.fullname.rpartition('.')[0]
76
+ exec(compile(source, module.__file__, "exec"), module.__dict__)
77
+
78
+ def _attach_dynamic_getattr(self, module: ModuleType) -> None:
79
+ def _dynamic_getattr(name: str):
80
+ if name.startswith("__"):
81
+ raise AttributeError(name)
82
+
83
+ ctx = discover(self.fullname)
84
+ functions = set(ctx.functions or [])
85
+ declared = self._declared_symbols(module)
86
+ desired = sorted(declared | functions | {name})
87
+
88
+ source = self._generate_and_cache(desired, ctx)
89
+ self._exec_source(source, module, clear_first=True)
90
+ # Re-attach for future misses after reload
91
+ module.__getattr__ = _dynamic_getattr
92
+ if name in module.__dict__:
93
+ return module.__dict__[name]
94
+ raise AttributeError(name)
95
+
96
+ module.__getattr__ = _dynamic_getattr
97
+
98
+ @staticmethod
99
+ def _declared_symbols(module: ModuleType) -> set[str]:
100
+ return {k for k in module.__dict__ if not k.startswith("__")}
101
+
102
+
103
+ class MagicPackageLoader(importlib.abc.Loader):
104
+ """Loader for the root 'wishful' package to enable namespace imports."""
105
+
106
+ def create_module(self, spec): # pragma: no cover - default create
107
+ return None
108
+
109
+ def exec_module(self, module: ModuleType) -> None:
110
+ module.__path__ = [str(cache.ensure_cache_dir())]
111
+ module.__package__ = "wishful"
112
+ module.__file__ = str(cache.ensure_cache_dir() / "__init__.py")
@@ -0,0 +1,5 @@
1
+ """LLM integration layer."""
2
+
3
+ from .client import generate_module_code, GenerationError
4
+
5
+ __all__ = ["generate_module_code", "GenerationError"]
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from typing import List, Sequence
5
+
6
+ import litellm
7
+
8
+ from wishful.config import settings
9
+ from wishful.llm.prompts import build_messages, strip_code_fences
10
+
11
+
12
+ class GenerationError(ImportError):
13
+ """Raised when the LLM call fails or returns empty output."""
14
+
15
+
16
+ _FAKE_MODE = os.getenv("WISHFUL_FAKE_LLM", "0") == "1"
17
+
18
+
19
+ def _fake_response(functions: Sequence[str]) -> str:
20
+ body = []
21
+ for name in functions or ("generated_helper",):
22
+ body.append(
23
+ f"def {name}(*args, **kwargs):\n \"\"\"Auto-generated placeholder. Replace with real logic.\"\"\"\n return {{'args': args, 'kwargs': kwargs}}\n"
24
+ )
25
+ return "\n\n".join(body)
26
+
27
+
28
+ def generate_module_code(module: str, functions: Sequence[str], context: str | None) -> str:
29
+ """Call the LLM (or fake stub) to generate module source code."""
30
+
31
+ if _FAKE_MODE:
32
+ return _fake_response(functions)
33
+
34
+ messages = build_messages(module, functions, context)
35
+ try:
36
+ response = litellm.completion(
37
+ model=settings.model,
38
+ messages=messages,
39
+ temperature=settings.temperature,
40
+ max_tokens=settings.max_tokens,
41
+ )
42
+ except Exception as exc: # pragma: no cover - network path not executed in tests
43
+ raise GenerationError(f"LLM call failed: {exc}") from exc
44
+
45
+ try:
46
+ content = response["choices"][0]["message"]["content"]
47
+ except Exception as exc: # pragma: no cover
48
+ raise GenerationError("Unexpected LLM response structure") from exc
49
+
50
+ if not content or not content.strip():
51
+ raise GenerationError("LLM returned empty content")
52
+
53
+ return strip_code_fences(content).strip()
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ from textwrap import dedent
4
+ from typing import Iterable, List, Sequence
5
+
6
+
7
+ def build_messages(module: str, functions: Sequence[str], context: str | None) -> List[dict]:
8
+ func_list = ", ".join(functions) if functions else "" "module-level helpers" ""
9
+ user_parts = [f"Module: {module}"]
10
+ if functions:
11
+ user_parts.append(f"Functions to implement: {', '.join(functions)}")
12
+ if context:
13
+ user_parts.append("Context:\n" + context.strip())
14
+
15
+ user_prompt = "\n\n".join(user_parts)
16
+
17
+ system = dedent(
18
+ """
19
+ You are a Python code generator. Output ONLY executable Python code.
20
+ - Do not wrap code in markdown fences.
21
+ - Only use the Python standard library.
22
+ - Prefer simple, readable implementations.
23
+ - Avoid network, filesystem writes, subprocess, or shell execution.
24
+ - Include docstrings and type hints where helpful.
25
+ """
26
+ ).strip()
27
+
28
+ return [
29
+ {"role": "system", "content": system},
30
+ {"role": "user", "content": user_prompt},
31
+ ]
32
+
33
+
34
+ def strip_code_fences(text: str) -> str:
35
+ """Remove Markdown code fences if present."""
36
+
37
+ if "```" not in text:
38
+ return text
39
+
40
+ parts = text.split("```")
41
+ if len(parts) >= 3:
42
+ # content between first and second fence
43
+ return parts[1].strip('\n') if parts[0].strip() == "" else parts[1]+parts[2]
44
+ return text
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,82 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from typing import Iterable, Set
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
+ _FORBIDDEN_FUNCTIONS = {"open"}
14
+
15
+
16
+ def _collect_names(node: ast.AST) -> Set[str]:
17
+ names = set()
18
+ for child in ast.walk(node):
19
+ if isinstance(child, ast.Name):
20
+ names.add(child.id)
21
+ return names
22
+
23
+
24
+ def validate_code(source: str, *, allow_unsafe: bool = False) -> None:
25
+ """Perform light-weight static checks on generated code.
26
+
27
+ The goal is to block obviously dangerous constructs without being overly
28
+ restrictive. Users can opt-out by setting `allow_unsafe=True`.
29
+ """
30
+
31
+ if allow_unsafe:
32
+ return
33
+
34
+ try:
35
+ tree = ast.parse(source)
36
+ except SyntaxError as exc: # surface errors early
37
+ raise ImportError(f"Generated code has syntax error: {exc}") from exc
38
+
39
+ for node in ast.walk(tree):
40
+ if isinstance(node, ast.Import):
41
+ for alias in node.names:
42
+ if alias.name.split(".")[0] in _FORBIDDEN_IMPORTS:
43
+ raise SecurityError(f"Forbidden import: {alias.name}")
44
+ elif isinstance(node, ast.ImportFrom):
45
+ if node.module and node.module.split(".")[0] in _FORBIDDEN_IMPORTS:
46
+ raise SecurityError(f"Forbidden import: {node.module}")
47
+ elif isinstance(node, ast.Call):
48
+ if isinstance(node.func, ast.Name):
49
+ func_name = node.func.id
50
+ if func_name in _FORBIDDEN_CALLS:
51
+ raise SecurityError(f"Forbidden call: {func_name}()")
52
+ if func_name == "open":
53
+ # Evaluate mode argument safety (write modes contain 'w', 'a', '+').
54
+ if node.args:
55
+ first_arg = node.args[0]
56
+ if isinstance(first_arg, ast.Constant) and isinstance(first_arg.value, str):
57
+ mode_arg = None
58
+ if len(node.args) > 1 and isinstance(node.args[1], ast.Constant):
59
+ mode_arg = node.args[1].value
60
+ elif node.keywords:
61
+ for kw in node.keywords:
62
+ if kw.arg == "mode" and isinstance(kw.value, ast.Constant):
63
+ mode_arg = kw.value.value
64
+ if mode_arg and any(ch in str(mode_arg) for ch in "wa+"):
65
+ raise SecurityError("open() in write/append mode is blocked")
66
+ if isinstance(node.func, ast.Attribute):
67
+ # Block os.system / subprocess.call etc even if imported under alias.
68
+ attr_chain = []
69
+ current = node.func
70
+ while isinstance(current, ast.Attribute):
71
+ attr_chain.append(current.attr)
72
+ current = current.value
73
+ if isinstance(current, ast.Name):
74
+ attr_chain.append(current.id)
75
+ dotted = ".".join(reversed(attr_chain))
76
+ if dotted.startswith("os.") or dotted.startswith("subprocess."):
77
+ raise SecurityError(f"Forbidden call: {dotted}()")
78
+
79
+ # Additional rule: do not allow top-level exec/eval in any alias
80
+ names = _collect_names(tree)
81
+ if names & _FORBIDDEN_CALLS:
82
+ raise SecurityError(f"Forbidden builtins present: {', '.join(names & _FORBIDDEN_CALLS)}")
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ from contextlib import contextmanager
4
+ from typing import Iterator
5
+
6
+ from rich.console import Console
7
+ from rich.progress import Progress, SpinnerColumn, TextColumn
8
+
9
+ from wishful.config import settings
10
+
11
+ _console = Console()
12
+
13
+
14
+ @contextmanager
15
+ def spinner(message: str) -> Iterator[None]:
16
+ if not settings.spinner:
17
+ yield
18
+ return
19
+
20
+ with Progress(SpinnerColumn(), TextColumn(message), console=_console, transient=True) as progress:
21
+ task_id = progress.add_task(message, total=None)
22
+ try:
23
+ yield
24
+ finally:
25
+ progress.update(task_id, completed=1)
26
+ progress.stop()