pylennon 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.
- pylennon-0.1.0/.gitignore +21 -0
- pylennon-0.1.0/LICENSE +21 -0
- pylennon-0.1.0/PKG-INFO +48 -0
- pylennon-0.1.0/README.md +36 -0
- pylennon-0.1.0/pyproject.toml +38 -0
- pylennon-0.1.0/src/pylennon/__init__.py +9 -0
- pylennon-0.1.0/src/pylennon/__main__.py +203 -0
- pylennon-0.1.0/src/pylennon/_imagine.py +312 -0
- pylennon-0.1.0/src/pylennon/app.py +132 -0
- pylennon-0.1.0/src/pylennon/build.py +157 -0
- pylennon-0.1.0/src/pylennon/exceptions.py +10 -0
- pylennon-0.1.0/src/pylennon/scaffold.py +250 -0
- pylennon-0.1.0/src/pylennon/stub.py +48 -0
- pylennon-0.1.0/uv.lock +582 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
.idea
|
|
2
|
+
.idea/*
|
|
3
|
+
|
|
4
|
+
# Python bytecode
|
|
5
|
+
__pycache__/
|
|
6
|
+
*.py[cod]
|
|
7
|
+
|
|
8
|
+
# Virtual environment
|
|
9
|
+
.venv/
|
|
10
|
+
|
|
11
|
+
# Build / distribution artifacts
|
|
12
|
+
dist/
|
|
13
|
+
*.egg-info/
|
|
14
|
+
src/*.egg-info/
|
|
15
|
+
|
|
16
|
+
# Secrets / env files
|
|
17
|
+
.env
|
|
18
|
+
*.env
|
|
19
|
+
set_env.ps1
|
|
20
|
+
/set_env_shit.ps1
|
|
21
|
+
pylennon.log
|
pylennon-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Novoselskiy Maxim Igorevich
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
pylennon-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pylennon
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: AI-assisted Python development: write interfaces, generate implementations
|
|
5
|
+
Project-URL: Homepage, https://github.com/MaxNovoselsky/PyLennon
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Requires-Dist: anthropic>=0.55
|
|
10
|
+
Requires-Dist: libcst>=1.0
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# PyLennon
|
|
14
|
+
|
|
15
|
+
AI-assisted Python development framework. Write function interfaces (signatures, type hints, docstrings); `pylennon build` generates the implementations using Claude.
|
|
16
|
+
|
|
17
|
+
## Dev setup
|
|
18
|
+
|
|
19
|
+
```powershell
|
|
20
|
+
# Install uv (once)
|
|
21
|
+
winget install astral-sh.uv
|
|
22
|
+
|
|
23
|
+
# Clone, then:
|
|
24
|
+
uv sync
|
|
25
|
+
|
|
26
|
+
# Verify
|
|
27
|
+
uv run pylennon --help
|
|
28
|
+
uv run pylennon build example/stubs.py
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from pylennon import LennonApp
|
|
35
|
+
|
|
36
|
+
lennon_app = LennonApp()
|
|
37
|
+
|
|
38
|
+
@lennon_app.imagine
|
|
39
|
+
def parse_invoice(raw_text: str) -> Invoice:
|
|
40
|
+
"""Parse a raw invoice string into a structured Invoice object."""
|
|
41
|
+
...
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
```powershell
|
|
45
|
+
pylennon build src/invoices.py
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
See `idea.md` for the full design.
|
pylennon-0.1.0/README.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# PyLennon
|
|
2
|
+
|
|
3
|
+
AI-assisted Python development framework. Write function interfaces (signatures, type hints, docstrings); `pylennon build` generates the implementations using Claude.
|
|
4
|
+
|
|
5
|
+
## Dev setup
|
|
6
|
+
|
|
7
|
+
```powershell
|
|
8
|
+
# Install uv (once)
|
|
9
|
+
winget install astral-sh.uv
|
|
10
|
+
|
|
11
|
+
# Clone, then:
|
|
12
|
+
uv sync
|
|
13
|
+
|
|
14
|
+
# Verify
|
|
15
|
+
uv run pylennon --help
|
|
16
|
+
uv run pylennon build example/stubs.py
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from pylennon import LennonApp
|
|
23
|
+
|
|
24
|
+
lennon_app = LennonApp()
|
|
25
|
+
|
|
26
|
+
@lennon_app.imagine
|
|
27
|
+
def parse_invoice(raw_text: str) -> Invoice:
|
|
28
|
+
"""Parse a raw invoice string into a structured Invoice object."""
|
|
29
|
+
...
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
```powershell
|
|
33
|
+
pylennon build src/invoices.py
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
See `idea.md` for the full design.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pylennon"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "AI-assisted Python development: write interfaces, generate implementations"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"anthropic>=0.55",
|
|
15
|
+
"libcst>=1.0",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.scripts]
|
|
19
|
+
pylennon = "pylennon.__main__:main"
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Homepage = "https://github.com/MaxNovoselsky/PyLennon"
|
|
23
|
+
|
|
24
|
+
[tool.hatch.build.targets.wheel]
|
|
25
|
+
packages = ["src/pylennon"]
|
|
26
|
+
|
|
27
|
+
[tool.hatch.build.targets.sdist]
|
|
28
|
+
exclude = [
|
|
29
|
+
"example",
|
|
30
|
+
"idea.md",
|
|
31
|
+
"progress.md",
|
|
32
|
+
"functional_tests"
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[dependency-groups]
|
|
36
|
+
dev = [
|
|
37
|
+
"pytest>=8.0",
|
|
38
|
+
]
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from .app import LennonApp
|
|
4
|
+
from .exceptions import GenerationError, ImagineNotBuiltError
|
|
5
|
+
from .stub import StubInfo
|
|
6
|
+
|
|
7
|
+
logging.getLogger(__name__).addHandler(logging.NullHandler())
|
|
8
|
+
|
|
9
|
+
__all__ = ["LennonApp", "StubInfo", "ImagineNotBuiltError", "GenerationError"]
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import json
|
|
3
|
+
import shutil
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _load_apps(path: Path):
|
|
9
|
+
from .build import load_app_from_file, load_apps_from_dir
|
|
10
|
+
if path.is_dir():
|
|
11
|
+
apps = load_apps_from_dir(path)
|
|
12
|
+
if not apps:
|
|
13
|
+
print(f"error: no LennonApp instance found in {path}", file=sys.stderr)
|
|
14
|
+
sys.exit(1)
|
|
15
|
+
return apps
|
|
16
|
+
return [load_app_from_file(path)]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _rel(p: str) -> str:
|
|
20
|
+
try:
|
|
21
|
+
return str(Path(p).relative_to(Path.cwd()))
|
|
22
|
+
except ValueError:
|
|
23
|
+
return p
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _read_manifest(app) -> list[dict]:
|
|
27
|
+
from .app import MANIFEST_FILE
|
|
28
|
+
manifest_path = app.output_dir / MANIFEST_FILE
|
|
29
|
+
try:
|
|
30
|
+
raw = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
31
|
+
return raw if isinstance(raw, list) else []
|
|
32
|
+
except (FileNotFoundError, json.JSONDecodeError):
|
|
33
|
+
return []
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def cmd_build(args):
|
|
37
|
+
from .build import build_app, load_app_from_file, load_apps_from_dir
|
|
38
|
+
|
|
39
|
+
path = Path(args.file).resolve()
|
|
40
|
+
if not path.exists():
|
|
41
|
+
print(f"error: path not found: {path}", file=sys.stderr)
|
|
42
|
+
sys.exit(1)
|
|
43
|
+
|
|
44
|
+
if path.is_dir():
|
|
45
|
+
apps = load_apps_from_dir(path)
|
|
46
|
+
if not apps:
|
|
47
|
+
print(f"error: no LennonApp instance found in {path}", file=sys.stderr)
|
|
48
|
+
sys.exit(1)
|
|
49
|
+
total_built = sum(build_app(app) for app in apps)
|
|
50
|
+
total_stubs = sum(len(app.stubs) for app in apps)
|
|
51
|
+
print(f"\nDone — built {total_built}/{total_stubs} stub(s)")
|
|
52
|
+
else:
|
|
53
|
+
app = load_app_from_file(path)
|
|
54
|
+
built = build_app(app, prune_orphans=False)
|
|
55
|
+
print(f"\nDone — built {built}/{len(app.stubs)} stub(s) in {app.output_dir}")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def cmd_status(args):
|
|
59
|
+
path = Path(args.file).resolve()
|
|
60
|
+
if not path.exists():
|
|
61
|
+
print(f"error: path not found: {path}", file=sys.stderr)
|
|
62
|
+
sys.exit(1)
|
|
63
|
+
|
|
64
|
+
apps = _load_apps(path)
|
|
65
|
+
|
|
66
|
+
for app in apps:
|
|
67
|
+
built_hashes = {e["stub_hash"] for e in _read_manifest(app)}
|
|
68
|
+
stubs = app.all_stubs
|
|
69
|
+
if not stubs:
|
|
70
|
+
continue
|
|
71
|
+
name_w = max(len(s.name) for s in stubs)
|
|
72
|
+
for stub in stubs:
|
|
73
|
+
status = "built" if stub.stub_hash in built_hashes else "missing"
|
|
74
|
+
src = _rel(stub.source_file)
|
|
75
|
+
print(f"{status:<7} {stub.stub_hash} {stub.name:<{name_w}} {src}")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def cmd_detail(args):
|
|
79
|
+
from ._imagine import build_system_prompt, build_user_prompt
|
|
80
|
+
from .app import MANIFEST_FILE
|
|
81
|
+
|
|
82
|
+
path = Path(args.file).resolve()
|
|
83
|
+
if not path.exists():
|
|
84
|
+
print(f"error: path not found: {path}", file=sys.stderr)
|
|
85
|
+
sys.exit(1)
|
|
86
|
+
|
|
87
|
+
apps = _load_apps(path)
|
|
88
|
+
|
|
89
|
+
matches: list[tuple] = [] # (app, stub)
|
|
90
|
+
for app in apps:
|
|
91
|
+
for stub in app.all_stubs:
|
|
92
|
+
if stub.stub_hash == args.stub_hash:
|
|
93
|
+
matches.append((app, stub))
|
|
94
|
+
|
|
95
|
+
if not matches:
|
|
96
|
+
print(f"error: no stub with hash '{args.stub_hash}' found in {path}", file=sys.stderr)
|
|
97
|
+
sys.exit(1)
|
|
98
|
+
|
|
99
|
+
found_app, first_stub = matches[0]
|
|
100
|
+
manifest_entry = next(
|
|
101
|
+
(e for e in _read_manifest(found_app) if e.get("stub_hash") == args.stub_hash),
|
|
102
|
+
None,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
status = "built" if manifest_entry else "missing"
|
|
106
|
+
print(f"name: {first_stub.name}")
|
|
107
|
+
print(f"stub_hash: {first_stub.stub_hash}")
|
|
108
|
+
print(f"status: {status}")
|
|
109
|
+
if manifest_entry:
|
|
110
|
+
print(f"prompt_hash: {manifest_entry['prompt_hash']}")
|
|
111
|
+
print(f"generated: {manifest_entry['generated']}")
|
|
112
|
+
src_files = [_rel(s.source_file) for _, s in matches]
|
|
113
|
+
label = "source_file: " if len(src_files) == 1 else "source_files:"
|
|
114
|
+
print(f"{label} {src_files[0]}")
|
|
115
|
+
for f in src_files[1:]:
|
|
116
|
+
print(f" {f}")
|
|
117
|
+
|
|
118
|
+
system_prompt = build_system_prompt(
|
|
119
|
+
instructions_file=found_app.instructions_file,
|
|
120
|
+
always_include=found_app.always_include,
|
|
121
|
+
)
|
|
122
|
+
user_prompt = build_user_prompt(first_stub)
|
|
123
|
+
|
|
124
|
+
print()
|
|
125
|
+
print("--- SYSTEM PROMPT ---")
|
|
126
|
+
print(system_prompt, end="")
|
|
127
|
+
print()
|
|
128
|
+
print("--- USER PROMPT ---")
|
|
129
|
+
print(user_prompt, end="")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def cmd_scaffold(args):
|
|
133
|
+
from .scaffold import scaffold
|
|
134
|
+
|
|
135
|
+
src_dir = Path(args.file).resolve()
|
|
136
|
+
if not src_dir.exists():
|
|
137
|
+
print(f"error: path not found: {src_dir}", file=sys.stderr)
|
|
138
|
+
sys.exit(1)
|
|
139
|
+
if not src_dir.is_dir():
|
|
140
|
+
print(f"error: scaffold requires a directory, not a file: {src_dir}", file=sys.stderr)
|
|
141
|
+
sys.exit(1)
|
|
142
|
+
|
|
143
|
+
out_dir = Path(args.output_dir).resolve()
|
|
144
|
+
|
|
145
|
+
apps = _load_apps(src_dir)
|
|
146
|
+
scaffolded, skipped, copied = scaffold(src_dir, out_dir, apps)
|
|
147
|
+
|
|
148
|
+
total = scaffolded + skipped + copied
|
|
149
|
+
print(f"\nDone -- {scaffolded} file(s) scaffolded, {skipped} skipped, {copied} copied -> {_rel(str(out_dir))}")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def cmd_clean(args):
|
|
153
|
+
path = Path(args.file).resolve()
|
|
154
|
+
if not path.exists():
|
|
155
|
+
print(f"error: path not found: {path}", file=sys.stderr)
|
|
156
|
+
sys.exit(1)
|
|
157
|
+
|
|
158
|
+
apps = _load_apps(path)
|
|
159
|
+
deleted: set[Path] = set()
|
|
160
|
+
for app in apps:
|
|
161
|
+
out = app.output_dir.resolve()
|
|
162
|
+
if out in deleted:
|
|
163
|
+
continue
|
|
164
|
+
if out.exists():
|
|
165
|
+
print(f"Deleting {_rel(str(out))} ...")
|
|
166
|
+
shutil.rmtree(out)
|
|
167
|
+
else:
|
|
168
|
+
print(f"Nothing to clean — {_rel(str(out))} does not exist")
|
|
169
|
+
deleted.add(out)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def main():
|
|
173
|
+
parser = argparse.ArgumentParser(prog="pylennon")
|
|
174
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
175
|
+
|
|
176
|
+
p = sub.add_parser("build", help="Generate implementations for all @imagine stubs")
|
|
177
|
+
p.add_argument("file", help="Path to the module or directory containing LennonApp stubs")
|
|
178
|
+
p.set_defaults(func=cmd_build)
|
|
179
|
+
|
|
180
|
+
p = sub.add_parser("status", help="Show build status of all registered stubs")
|
|
181
|
+
p.add_argument("file", help="Path to the module or directory containing LennonApp stubs")
|
|
182
|
+
p.set_defaults(func=cmd_status)
|
|
183
|
+
|
|
184
|
+
p = sub.add_parser("detail", help="Show full metadata and prompt for a specific stub")
|
|
185
|
+
p.add_argument("file", help="Path to the module or directory containing LennonApp stubs")
|
|
186
|
+
p.add_argument("stub_hash", help="The stub hash to inspect (from `pylennon status`)")
|
|
187
|
+
p.set_defaults(func=cmd_detail)
|
|
188
|
+
|
|
189
|
+
p = sub.add_parser("scaffold", help="Produce a standalone copy without pylennon dependency")
|
|
190
|
+
p.add_argument("file", help="Source directory containing LennonApp stubs")
|
|
191
|
+
p.add_argument("output_dir", help="Directory to write the scaffolded output")
|
|
192
|
+
p.set_defaults(func=cmd_scaffold)
|
|
193
|
+
|
|
194
|
+
p = sub.add_parser("clean", help="Delete the generated/ output directory")
|
|
195
|
+
p.add_argument("file", help="Path to the module or directory containing the LennonApp instance")
|
|
196
|
+
p.set_defaults(func=cmd_clean)
|
|
197
|
+
|
|
198
|
+
args = parser.parse_args()
|
|
199
|
+
args.func(args)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
if __name__ == "__main__":
|
|
203
|
+
main()
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import inspect
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import textwrap
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import anthropic
|
|
11
|
+
from anthropic.types import ThinkingConfigDisabledParam, ToolChoiceNoneParam
|
|
12
|
+
|
|
13
|
+
from .stub import StubInfo
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
DEFAULT_MODEL = "claude-sonnet-4-6"
|
|
18
|
+
|
|
19
|
+
DEFAULT_SYSTEM_PROMPT = """\
|
|
20
|
+
You are part of PyLennon, an AI-assisted Python development framework.
|
|
21
|
+
|
|
22
|
+
PyLennon allows developers to write stubs — Python functions and classes with \
|
|
23
|
+
signatures, type hints, and docstrings, but with `...` as the body — decorated \
|
|
24
|
+
with `@lennon_app.imagine`. At build time, PyLennon sends each stub to you to \
|
|
25
|
+
generate a working implementation. At runtime, the `@imagine` decorator \
|
|
26
|
+
transparently intercepts calls and routes them to the generated code.
|
|
27
|
+
|
|
28
|
+
YOUR ROLE:
|
|
29
|
+
You receive one stub at a time and return a complete, working Python implementation.
|
|
30
|
+
|
|
31
|
+
IMPORTANT — what @imagine stubs are:
|
|
32
|
+
- A function or class marked `@lennon_app.imagine` is NOT empty or unimplemented at runtime.
|
|
33
|
+
- It has a fully working AI-generated implementation that the framework loads automatically.
|
|
34
|
+
- Stubs always show `...` as the body — that is the contract, not the implementation.
|
|
35
|
+
- If you see a stub in `uses` or `context` with `...` as its body or method bodies,
|
|
36
|
+
do NOT implement it. It is already generated and works at runtime — treat it as a black box.
|
|
37
|
+
|
|
38
|
+
IMPORTANT RULES:
|
|
39
|
+
- do **NOT** use any tools, like WebFetch. It's strictly forbidden.
|
|
40
|
+
|
|
41
|
+
RESPONSE FORMAT:
|
|
42
|
+
- Respond with a JSON object containing a single key "code" whose value is
|
|
43
|
+
the complete, valid Python source file as a string — nothing else in "code".
|
|
44
|
+
- No prose, no markdown fences, no comments outside the code, no explanations.
|
|
45
|
+
- Preserve the exact function or class name and signature from the stub —
|
|
46
|
+
the framework dispatches calls by name, so renaming anything breaks runtime dispatch.
|
|
47
|
+
|
|
48
|
+
IMPLEMENTATION RULES:
|
|
49
|
+
- Target Python 3.12.
|
|
50
|
+
- Implement exactly what the docstring describes — no more, no less.
|
|
51
|
+
- Do NOT add public methods, functions, or attributes not present in the stub.
|
|
52
|
+
- You MAY add private (`_name`) or name-mangled (`__name`) helpers if they simplify the implementation.
|
|
53
|
+
- Raise `ValueError` or `TypeError` for invalid inputs when the docstring specifies constraints.
|
|
54
|
+
Do not silently swallow errors or return sentinel values unless the stub explicitly says so.
|
|
55
|
+
- The output is a standalone module: no `if __name__ == '__main__'` blocks, no example usage, no test code.
|
|
56
|
+
- Do not add docstrings or type hints, PyLennon will add it from original function afterwards.
|
|
57
|
+
- If a stub method is shown decorated with `@property` (and, where present, `@<name>.setter` /
|
|
58
|
+
`@<name>.deleter`), implement it as a property in the generated class — do not turn it into a
|
|
59
|
+
plain method or a constructor-assigned attribute.
|
|
60
|
+
|
|
61
|
+
CONTEXT (type definitions):
|
|
62
|
+
- The `context` section lists types referenced by the stub — dataclasses, Pydantic models, enums, etc.
|
|
63
|
+
- They are provided so you understand the shape of the data your function receives or returns:
|
|
64
|
+
field names, types, validators, and other constraints that affect how you interact with them.
|
|
65
|
+
- Import them using the exact paths shown — do not redefine or copy-paste them into your output.
|
|
66
|
+
- If a context type has `@lennon_app.imagine` on it, or its methods show `...` as the body,
|
|
67
|
+
it is an @imagine stub with a real generated implementation at runtime.
|
|
68
|
+
Do not implement it — just import and use it as-is.
|
|
69
|
+
|
|
70
|
+
IMPORTS:
|
|
71
|
+
- Import only what you actually use.
|
|
72
|
+
- Use the exact import paths shown in the type context section.
|
|
73
|
+
- Prefer stdlib over third-party when both work equally well.
|
|
74
|
+
|
|
75
|
+
USES:
|
|
76
|
+
- If `uses` functions or classes are provided, call them directly — do not reimplement their logic.
|
|
77
|
+
- They are shown with `...` as the body because they are @imagine stubs, but they have real,
|
|
78
|
+
working generated implementations at runtime. Do not implement them — just call them.\
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _render_property(attr_name: str, prop: property, *, indent: str) -> str:
|
|
83
|
+
blocks: list[str] = []
|
|
84
|
+
for decorator, fn in (
|
|
85
|
+
("@property", prop.fget),
|
|
86
|
+
(f"@{attr_name}.setter", prop.fset),
|
|
87
|
+
(f"@{attr_name}.deleter", prop.fdel),
|
|
88
|
+
):
|
|
89
|
+
if fn is None:
|
|
90
|
+
continue
|
|
91
|
+
try:
|
|
92
|
+
sig = str(inspect.signature(fn))
|
|
93
|
+
except (ValueError, TypeError):
|
|
94
|
+
sig = "(self)" if fn is prop.fget else "(self, value)"
|
|
95
|
+
doc = inspect.getdoc(fn)
|
|
96
|
+
block = [f"{indent}{decorator}", f"{indent}def {attr_name}{sig}:"]
|
|
97
|
+
if doc:
|
|
98
|
+
block.append(f'{indent} """{doc}"""')
|
|
99
|
+
block.append(f"{indent} ...")
|
|
100
|
+
blocks.append("\n".join(block))
|
|
101
|
+
return "\n".join(blocks)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _get_class_context(stub: StubInfo) -> str | None:
|
|
105
|
+
qualname = getattr(stub.func, '__qualname__', '')
|
|
106
|
+
if '.' not in qualname:
|
|
107
|
+
return None
|
|
108
|
+
class_name = qualname.rsplit('.', 1)[0]
|
|
109
|
+
module = inspect.getmodule(stub.func)
|
|
110
|
+
if module is None:
|
|
111
|
+
return None
|
|
112
|
+
cls = getattr(module, class_name, None)
|
|
113
|
+
if cls is None or not inspect.isclass(cls):
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
lines = [f"class {class_name}:"]
|
|
117
|
+
for attr_name, val in vars(cls).items():
|
|
118
|
+
if isinstance(val, property):
|
|
119
|
+
lines.append(_render_property(attr_name, val, indent=" "))
|
|
120
|
+
continue
|
|
121
|
+
if not callable(val) and not isinstance(val, (classmethod, staticmethod)):
|
|
122
|
+
continue
|
|
123
|
+
fn = val.__func__ if isinstance(val, (classmethod, staticmethod)) else val
|
|
124
|
+
if attr_name == '__init__':
|
|
125
|
+
try:
|
|
126
|
+
src = textwrap.dedent(inspect.getsource(fn))
|
|
127
|
+
lines.append(textwrap.indent(src, " "))
|
|
128
|
+
except (OSError, TypeError):
|
|
129
|
+
try:
|
|
130
|
+
sig = str(inspect.signature(fn))
|
|
131
|
+
except (ValueError, TypeError):
|
|
132
|
+
sig = "(*args, **kwargs)"
|
|
133
|
+
lines.append(f" def __init__{sig}: ...")
|
|
134
|
+
elif attr_name.startswith('__'):
|
|
135
|
+
continue
|
|
136
|
+
else:
|
|
137
|
+
try:
|
|
138
|
+
sig = str(inspect.signature(fn))
|
|
139
|
+
except (ValueError, TypeError):
|
|
140
|
+
sig = "(*args, **kwargs)"
|
|
141
|
+
doc = inspect.getdoc(fn)
|
|
142
|
+
lines.append(f" def {attr_name}{sig}:")
|
|
143
|
+
if doc:
|
|
144
|
+
lines.append(f' """{doc}"""')
|
|
145
|
+
lines.append(" ...")
|
|
146
|
+
return "\n".join(lines)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def build_system_prompt(*, instructions_file: Path, always_include: list[Path]) -> str:
|
|
150
|
+
parts: list[str] = [DEFAULT_SYSTEM_PROMPT]
|
|
151
|
+
|
|
152
|
+
if instructions_file.exists():
|
|
153
|
+
parts.append("")
|
|
154
|
+
parts.append(instructions_file.read_text(encoding="utf-8").strip())
|
|
155
|
+
|
|
156
|
+
for extra in always_include:
|
|
157
|
+
if extra.exists():
|
|
158
|
+
parts.append("")
|
|
159
|
+
parts.append(extra.read_text(encoding="utf-8").strip())
|
|
160
|
+
|
|
161
|
+
return "\n".join(parts) + "\n"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def build_user_prompt(stub: StubInfo) -> str:
|
|
165
|
+
parts: list[str] = []
|
|
166
|
+
|
|
167
|
+
class_ctx = _get_class_context(stub)
|
|
168
|
+
|
|
169
|
+
if class_ctx:
|
|
170
|
+
parts.append("This stub is a method. Generate a standalone function (do not wrap it in a class).")
|
|
171
|
+
parts.append("The `self` parameter refers to an instance of the class shown in context below.")
|
|
172
|
+
parts.append("")
|
|
173
|
+
parts.append("This method belongs to the following class (shown for reference):")
|
|
174
|
+
parts.append("```python")
|
|
175
|
+
parts.append(class_ctx)
|
|
176
|
+
parts.append("```")
|
|
177
|
+
parts.append("")
|
|
178
|
+
|
|
179
|
+
if stub.context:
|
|
180
|
+
parts.append("The following types are referenced in this stub (do NOT redefine them, import instead):")
|
|
181
|
+
for t in stub.context:
|
|
182
|
+
module = getattr(t, '__module__', None)
|
|
183
|
+
qualname = getattr(t, '__qualname__', t.__name__)
|
|
184
|
+
parts.append("```python")
|
|
185
|
+
if module and module != 'builtins':
|
|
186
|
+
parts.append(f"# import: from {module} import {qualname}")
|
|
187
|
+
try:
|
|
188
|
+
parts.append(inspect.getsource(t))
|
|
189
|
+
except (OSError, TypeError):
|
|
190
|
+
parts.append(f"# {t.__name__} (source unavailable)")
|
|
191
|
+
parts.append("```")
|
|
192
|
+
parts.append("")
|
|
193
|
+
|
|
194
|
+
if stub.uses:
|
|
195
|
+
parts.append(
|
|
196
|
+
"The following functions are available for this stub to call"
|
|
197
|
+
" (signatures and docstrings only — they have real implementations at runtime):"
|
|
198
|
+
)
|
|
199
|
+
for fn in stub.uses:
|
|
200
|
+
try:
|
|
201
|
+
sig = str(inspect.signature(fn))
|
|
202
|
+
except (ValueError, TypeError):
|
|
203
|
+
sig = "(*args, **kwargs)"
|
|
204
|
+
doc = inspect.getdoc(fn)
|
|
205
|
+
parts.append("```python")
|
|
206
|
+
parts.append(f"def {fn.__name__}{sig}:")
|
|
207
|
+
if doc:
|
|
208
|
+
parts.append(f' """{doc}"""')
|
|
209
|
+
parts.append(" ...")
|
|
210
|
+
parts.append("```")
|
|
211
|
+
parts.append("")
|
|
212
|
+
|
|
213
|
+
parts.append("Stub to implement:")
|
|
214
|
+
if stub.is_class:
|
|
215
|
+
lines = [f"class {stub.name}:"]
|
|
216
|
+
if stub.docstring:
|
|
217
|
+
lines.append(f' """{stub.docstring}"""')
|
|
218
|
+
has_methods = False
|
|
219
|
+
for attr_name, val in vars(stub.func).items():
|
|
220
|
+
if attr_name.startswith('__') and attr_name != '__init__':
|
|
221
|
+
continue
|
|
222
|
+
if isinstance(val, property):
|
|
223
|
+
lines.append(_render_property(attr_name, val, indent=" "))
|
|
224
|
+
has_methods = True
|
|
225
|
+
continue
|
|
226
|
+
fn = val.__func__ if isinstance(val, (classmethod, staticmethod)) else val
|
|
227
|
+
if not callable(fn):
|
|
228
|
+
continue
|
|
229
|
+
try:
|
|
230
|
+
sig = str(inspect.signature(fn))
|
|
231
|
+
except (ValueError, TypeError):
|
|
232
|
+
sig = "(*args, **kwargs)"
|
|
233
|
+
doc = inspect.getdoc(fn)
|
|
234
|
+
lines.append(f" def {attr_name}{sig}:")
|
|
235
|
+
if doc:
|
|
236
|
+
lines.append(f' """{doc}"""')
|
|
237
|
+
lines.append(" ...")
|
|
238
|
+
has_methods = True
|
|
239
|
+
if not has_methods:
|
|
240
|
+
lines.append(" ...")
|
|
241
|
+
parts.append("```python")
|
|
242
|
+
parts.append("\n".join(lines))
|
|
243
|
+
parts.append("```")
|
|
244
|
+
else:
|
|
245
|
+
sig = str(stub.signature) if stub.signature else "(*args, **kwargs)"
|
|
246
|
+
parts.append("```python")
|
|
247
|
+
parts.append(f"def {stub.name}{sig}:")
|
|
248
|
+
if stub.docstring:
|
|
249
|
+
parts.append(f' """{stub.docstring}"""')
|
|
250
|
+
parts.append(" ...")
|
|
251
|
+
parts.append("```")
|
|
252
|
+
|
|
253
|
+
return "\n".join(parts) + "\n"
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def compute_prompt_hash(system_prompt: str, user_prompt: str) -> str:
|
|
257
|
+
combined = system_prompt + "\x00" + user_prompt
|
|
258
|
+
return hashlib.sha256(combined.encode()).hexdigest()[:8]
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def generate_from_prompt(system_prompt: str, user_prompt: str, model: str = DEFAULT_MODEL) -> str:
|
|
262
|
+
from .exceptions import GenerationError
|
|
263
|
+
|
|
264
|
+
client = anthropic.Anthropic()
|
|
265
|
+
message = client.messages.create(
|
|
266
|
+
model=model,
|
|
267
|
+
max_tokens=16384,
|
|
268
|
+
thinking=ThinkingConfigDisabledParam(type="disabled"),
|
|
269
|
+
system=system_prompt,
|
|
270
|
+
messages=[{"role": "user", "content": user_prompt}],
|
|
271
|
+
tool_choice=ToolChoiceNoneParam(type="none"),
|
|
272
|
+
tools=[],
|
|
273
|
+
output_config={
|
|
274
|
+
"format": {
|
|
275
|
+
"type": "json_schema",
|
|
276
|
+
"schema": {
|
|
277
|
+
"type": "object",
|
|
278
|
+
"properties": {
|
|
279
|
+
"code": {"type": "string"},
|
|
280
|
+
},
|
|
281
|
+
"required": ["code"],
|
|
282
|
+
"additionalProperties": False,
|
|
283
|
+
},
|
|
284
|
+
}
|
|
285
|
+
},
|
|
286
|
+
)
|
|
287
|
+
logger.debug("System prompt: %s", system_prompt)
|
|
288
|
+
logger.debug("User prompt: %s", user_prompt)
|
|
289
|
+
logger.debug("AI raw response: %s", message.model_dump_json())
|
|
290
|
+
|
|
291
|
+
if message.stop_reason == "tool_use":
|
|
292
|
+
tool_names = [b.name for b in message.content if b.type == "tool_use"]
|
|
293
|
+
raise GenerationError(
|
|
294
|
+
f"model attempted to use tools ({', '.join(tool_names)}) — "
|
|
295
|
+
"tool use is not supported; check your API key or model configuration"
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
text_blocks = [block.text for block in message.content if block.type == "text"]
|
|
299
|
+
if not text_blocks:
|
|
300
|
+
content_types = [b.type for b in message.content]
|
|
301
|
+
raise GenerationError(
|
|
302
|
+
f"model returned no text content (stop_reason={message.stop_reason!r}, "
|
|
303
|
+
f"content types: {content_types})"
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
try:
|
|
307
|
+
data = json.loads(text_blocks[0])
|
|
308
|
+
code: str = data["code"]
|
|
309
|
+
except (json.JSONDecodeError, KeyError) as exc:
|
|
310
|
+
raise GenerationError(f"model returned malformed JSON response: {exc}") from exc
|
|
311
|
+
|
|
312
|
+
return code if code.endswith("\n") else code + "\n"
|