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.
@@ -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.
@@ -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.
@@ -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"