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 +233 -0
- wishful-0.1.0/README.md +219 -0
- wishful-0.1.0/pyproject.toml +23 -0
- wishful-0.1.0/src/wishful/__init__.py +54 -0
- wishful-0.1.0/src/wishful/cache/__init__.py +23 -0
- wishful-0.1.0/src/wishful/cache/manager.py +56 -0
- wishful-0.1.0/src/wishful/config.py +90 -0
- wishful-0.1.0/src/wishful/core/__init__.py +6 -0
- wishful-0.1.0/src/wishful/core/discovery.py +85 -0
- wishful-0.1.0/src/wishful/core/finder.py +44 -0
- wishful-0.1.0/src/wishful/core/loader.py +112 -0
- wishful-0.1.0/src/wishful/llm/__init__.py +5 -0
- wishful-0.1.0/src/wishful/llm/client.py +53 -0
- wishful-0.1.0/src/wishful/llm/prompts.py +44 -0
- wishful-0.1.0/src/wishful/py.typed +0 -0
- wishful-0.1.0/src/wishful/safety/__init__.py +5 -0
- wishful-0.1.0/src/wishful/safety/validator.py +82 -0
- wishful-0.1.0/src/wishful/ui.py +26 -0
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.
|
wishful-0.1.0/README.md
ADDED
|
@@ -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,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,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,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()
|