diff --git a/.vscode/settings.json b/.vscode/settings.json index 7e68766..2f0b2bd 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,5 @@ { - "python-envs.pythonProjects": [] + "python-envs.pythonProjects": [], + "python-envs.defaultEnvManager": "ms-python.python:venv", + "python-envs.defaultPackageManager": "ms-python.python:pip" } \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 6e4354b..661636a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,12 @@ If you only remember one thing: **this is a _uv‑managed_ project — always us - **LLM access:** real generation uses `litellm` and environment variables; tests are designed to run **without network**. - **Safety first:** generated code is statically checked; do not weaken safety rules without updating tests and documentation. - **Tests are your contract:** before and after changes, run the relevant `pytest` suite via `uv`. +- **Test-Driven Development (TDD) Process:** + 1. **Before implementation:** Run all tests with coverage: `uv run pytest --cov=wishful tests/` + 2. **Design new tests:** Write tests for the new feature/fix before implementing + 3. **Implement:** Write the actual code to make tests pass + 4. **Verify:** Run tests again, fix issues until all green + 5. **Coverage gate:** Ensure new global coverage is not less than before --- @@ -40,8 +46,8 @@ uv sync This reads `pyproject.toml` and `uv.lock`, creates a virtual env, and installs: -- Runtime deps: `litellm`, `rich`, `python-dotenv` -- Dev deps: `pytest` (and anything else added via `uv add --dev`) +- Runtime deps: `litellm`, `rich`, `python-dotenv`, `pydantic` +- Dev deps: `pytest`, `pytest-cov`, `coverage`, `mypy`, `ruff`, `bandit`, `radon` ### Running commands (always via `uv run`) @@ -90,15 +96,37 @@ If you find yourself about to type `python …` or `pytest …`, prepend `uv run **wishful** lets users write imports like: ```python -from wishful.text import extract_emails +from wishful.static.text import extract_emails ``` and, on first import, an LLM generates the implementation on the fly. The generated module is cached as plain Python in a configurable directory (default `.wishful/`). Subsequent imports use the cache without calling the LLM again. +### Namespace Architecture (Important!) + +The project uses **three distinct namespaces**: + +1. **`wishful.static.*`** — Cached generation (default behavior) + - Generated code is written to cache and reused on subsequent imports + - Optimized for performance and consistency + - Use for: utilities, parsers, validators, anything that doesn't need runtime context + +2. **`wishful.dynamic.*`** — Runtime-context-aware generation + - Regenerates on every import, capturing fresh runtime context each time + - Never uses cache (always calls LLM) + - Use for: creative content, context-sensitive functions, testing variations + +3. **`wishful.*` (internal)** — Protected internal modules + - Real package modules: `wishful.cache`, `wishful.config`, `wishful.types`, `wishful.core`, etc. + - Cannot be overridden by code generation + - Import these normally for internal API access + +**Critical for tests and examples:** Always use `wishful.static.*` or `wishful.dynamic.*` for generated modules, never bare `wishful.*` (which would conflict with internal modules). + Key properties: -- Uses a custom **import hook** (meta‑path finder + loader). +- Uses a custom **import hook** (meta‑path finder + loader) with namespace routing. - **Context‑aware**: forwards nearby comments/code lines to the LLM as hints. +- **Type-aware**: supports complex return types via `@wishful.type` decorator (see Type Registry section). - **Safety‑checked**: generated code is parsed and checked for obviously dangerous constructs. - **Cache‑backed**: generated modules live as `.py` files and can be edited or committed. @@ -111,8 +139,8 @@ From the root: - `pyproject.toml` - Project metadata: name, version, description. - `requires-python = ">=3.12"`. - - Runtime deps: `litellm`, `rich`, `python-dotenv`. - - Dev deps under `[dependency-groups].dev`: `pytest`. +- Runtime deps: `litellm`, `rich`, `python-dotenv`, `pydantic` +- Dev deps under `[dependency-groups].dev`: `pytest`, `pytest-cov`, `coverage`, `mypy`, `ruff`, `bandit`, `radon` - Build system uses `uv_build` as backend. - `uv.lock` @@ -131,15 +159,25 @@ From the root: - Provides `configure(**kwargs)` and `reset_defaults()` utilities. - `core/` – import mechanics - `__init__.py` – re‑exports `MagicFinder`, `MagicLoader`, `install`. - - `finder.py` – `MagicFinder` (meta‑path finder) that intercepts `wishful.*` imports and delegates to `MagicLoader` unless the module is part of the built‑in package. + - `finder.py` – `MagicFinder` (meta‑path finder) that intercepts `wishful.static.*` and `wishful.dynamic.*` imports, routes them to appropriate loaders, and protects internal `wishful.*` modules. - `loader.py` – `MagicLoader` and `MagicPackageLoader`: - - Handles cache lookup, LLM generation, dynamic regeneration when requested symbols are missing, and dynamic `__getattr__` for on‑demand symbols. + - Accepts `mode` parameter ("static" or "dynamic") to control caching behavior. + - Handles cache lookup (static only), LLM generation, dynamic regeneration when requested symbols are missing, and dynamic `__getattr__` for on‑demand symbols. - `cache/` – cache management - `manager.py` – cache path computation, read/write, clear, list. + - Strips `static`/`dynamic` namespace prefixes from cache paths so both use same cache file. - `llm/` - `client.py` – wraps `litellm` calls and exposes `generate_module_code`, `GenerationError`. + - Accepts `type_schemas` and `function_output_types` for type-aware generation. - `_FAKE_MODE` via `WISHFUL_FAKE_LLM=1` returns deterministic stubs (no network). - `prompts.py` – prompt construction (`build_messages`) and `strip_code_fences`. + - Formats type schemas and output types in system prompt for LLM. + - `types/` – type registry system + - `__init__.py` – exports `type` decorator and registry functions. + - `registry.py` – `TypeRegistry` class for registering and serializing complex types. + - Supports Pydantic models, dataclasses, TypedDict. + - `@wishful.type` decorator for registering types and binding to function outputs. + - Serializes type definitions to Python code for inclusion in LLM prompts. - `safety/` - `validator.py` – `validate_code(source, allow_unsafe)` plus `SecurityError`. - AST‑based checks for forbidden imports (`os`, `subprocess`, `sys`), forbidden calls (`eval`, `exec`, unsafe `open`, `os.system`, `subprocess.*`, etc.). @@ -159,9 +197,14 @@ From the root: - `test_discovery.py` – context discovery helpers. - `test_llm.py` – LLM client and prompt utilities. - `test_safety.py` – security validator rules. + - `test_types.py` – type registry and serialization (30 tests). + - `test_namespaces.py` – static vs dynamic namespace behavior (6 tests). - `examples/` - - `00_quick_start.py` and others – small scripts demonstrating common usage patterns. + - `00_quick_start.py` through `06_omg_why.py` – basic usage patterns. + - `07_typed_outputs.py` – demonstrates type registry with Pydantic, dataclasses, TypedDict. + - `08_dynamic_vs_static.py` – shows difference between static (cached) and dynamic (runtime-aware) namespaces. + - `09_context_shenanigans.py` – demonstrates context discovery and import-site hints. - `docs/ideas/advanced_context_discovery.md` - Design/brainstorm document for richer context discovery strategies. @@ -180,7 +223,8 @@ Understanding the import pipeline is the most important conceptual model for thi - `MagicFinder.find_spec(fullname, path, target)`: - Ignores non‑`wishful` modules. - If the module corresponds to an **internal** package module (real file under `src/wishful/…`), it returns `None` so normal import mechanisms handle it. - - For external dynamic modules (e.g. `wishful.text`, `wishful.dates`…), it returns a spec with `MagicLoader` and `is_package=False`. + - For `wishful.static.*` modules, it returns a spec with `MagicLoader(mode="static")`. + - For `wishful.dynamic.*` modules, it returns a spec with `MagicLoader(mode="dynamic")`. - For the root `wishful` namespace (when not resolved by the built‑in package), it can use `MagicPackageLoader`. 3. **Context discovery** @@ -188,14 +232,16 @@ Understanding the import pipeline is the most important conceptual model for thi - `discover()` walks the Python stack to find the import site: - Parses the import statement into requested symbol names (e.g. `extract_emails`). - Captures lines around the import, plus call-site snippets elsewhere in the file, within a configurable radius (`WISHFUL_CONTEXT_RADIUS` or `wishful.set_context_radius`). - - Returns an `ImportContext(functions=[...], context=str | None)`. + - **Fetches registered type schemas** from `wishful.types.get_all_type_schemas()`. + - **Fetches function output type bindings** via `wishful.types.get_output_type_for_function()` for each requested function. + - Returns an `ImportContext(functions=[...], context=str | None, type_schemas=dict, function_output_types=dict)`. 4. **Cache check and optional LLM generation** - Loader queries `cache.read_cached(fullname)`: - - If cached source exists, it is used directly (`from_cache=True`). - - Otherwise `_generate_and_cache` is called: - - Wraps `generate_module_code(fullname, functions, context)` in a `spinner`. - - Writes the string result to a `.py` file via `cache.write_cached`. + - **Static mode:** If cached source exists, it is used directly (`from_cache=True`). Otherwise `_generate_and_cache` is called and the result is cached. + - **Dynamic mode:** Always calls `_generate_and_cache` and **never uses cache**, even if it exists. Regenerates on every import to capture runtime context. + - Generation wraps `generate_module_code(fullname, functions, context, type_schemas, function_output_types)` in a `spinner`. + - In static mode only, writes the string result to a `.py` file via `cache.write_cached`. 5. **Safety validation and execution** - Before executing, `validate_code(source, allow_unsafe=settings.allow_unsafe)` enforces safety. @@ -271,6 +317,91 @@ When working on the project: --- +## Type Registry System + +The type registry (`src/wishful/types/`) allows users to register complex types (Pydantic models, dataclasses, TypedDict) so the LLM can generate functions that return properly structured data. + +### Using the Type Registry + +Basic registration: + +```python +import wishful +from dataclasses import dataclass + +@wishful.type +@dataclass +class Book: + """A book with title, author, and year.""" + title: str + author: str + year: int +``` + +Binding types to specific function outputs: + +```python +@wishful.type(output_for="parse_user_data") +@dataclass +class UserProfile: + """User profile with name, email, and age.""" + name: str + email: str + age: int +``` + +Multiple functions sharing a type: + +```python +from typing import TypedDict + +class ProductInfo(TypedDict): + """Product information.""" + name: str + price: float + +wishful.type(ProductInfo, output_for=["parse_product", "create_product"]) +``` + +Pydantic models with Field constraints and docstring-driven behavior: + +```python +from pydantic import BaseModel, Field + +@wishful.type +class ProjectPlan(BaseModel): + """Project plan written by master yoda from star wars.""" + project_brief: str + milestones: list[str] = Field(description="list of milestones", min_length=10) + budget: float = Field(gt=0, description="project budget in USD") +``` + +The LLM will respect both Field constraints (min_length=10, gt=0) AND the docstring style (generates text in Yoda-speak). + +### How It Works + +1. **Registration**: The `@wishful.type` decorator registers a type in the global `TypeRegistry`. +2. **Serialization**: When generating code, the registry serializes registered types to Python source code. +3. **Prompt Enhancement**: Type definitions and output bindings are included in the LLM prompt's system message. +4. **Generation**: The LLM generates code that constructs and returns instances of the registered types. + +### Implementation Details + +- `TypeRegistry._serialize_pydantic()` – extracts Pydantic model fields and types, **includes docstrings** + - `_build_field_args()` – extracts Pydantic v2 Field constraints from metadata: + - Parses `field_info.metadata` list for constraint objects: `MinLen`, `MaxLen`, `Gt`, `Ge`, `Lt`, `Le` + - Extracts `pattern` from `_PydanticGeneralMetadata` objects + - Supports both Pydantic v1 (direct attributes) and v2 (metadata list) constraint storage + - Serializes constraints into `Field(description='...', min_length=10, ...)` format +- `TypeRegistry._serialize_dataclass()` – generates dataclass definitions, **includes docstrings** +- `TypeRegistry._serialize_typed_dict()` – formats TypedDict specifications, **includes docstrings** +- Type schemas are passed to `generate_module_code()` as `type_schemas` and `function_output_types` +- **Docstrings influence LLM behavior**: The docstring text (e.g., "Project plan written by master yoda from star wars") is included in the serialized type definition and affects how the LLM generates content (tone, style, domain-specific language) +- See `examples/07_typed_outputs.py` for comprehensive usage examples +- See `tests/test_types.py` for 30 tests covering all scenarios + +--- + ## Cache & CLI Behavior ### Cache layout @@ -278,8 +409,9 @@ When working on the project: - The root cache directory is `settings.cache_dir` (default `.wishful` under the current working directory). - `cache.manager.module_path(fullname)`: - Strips leading `wishful.` from the module name. + - **Also strips `static` and `dynamic` namespace prefixes** so both `wishful.static.text` and `wishful.dynamic.text` map to the same cache file. - Converts dots to directories and appends `.py`. - - Example: `"wishful.text"` → `.wishful/text.py`. + - Example: `"wishful.static.text"` → `.wishful/text.py`, `"wishful.dynamic.text"` → `.wishful/text.py`. - Utilities in `cache.manager`: - `read_cached(fullname)` → `str | None` - `write_cached(fullname, source)` → `Path` @@ -289,7 +421,7 @@ The top‑level public API in `wishful.__init__` re‑exports high‑level cache - `wishful.clear_cache()` - `wishful.inspect_cache()` -- `wishful.regenerate(module_name)` +- `wishful.regenerate(module_name)` – defaults to static namespace if no prefix given ### CLI (`python -m wishful`) @@ -298,13 +430,13 @@ Via `src/wishful/__main__.py`: - `python -m wishful` – prints help and usage. - `python -m wishful inspect` – shows current cached modules under `settings.cache_dir`. - `python -m wishful clear` – clears the cache directory. -- `python -m wishful regen wishful.text` – deletes cache for the given module so it is regenerated on next import. +- `python -m wishful regen wishful.static.text` – deletes cache for the given module so it is regenerated on next import. From this repo, always invoke via uv: ```bash uv run python -m wishful inspect -uv run python -m wishful regen wishful.text +uv run python -m wishful regen wishful.static.text ``` If you modify CLI behavior, update tests in `tests/test_cli.py` accordingly. @@ -315,7 +447,7 @@ If you modify CLI behavior, update tests in `tests/test_cli.py` accordingly. The only place that calls the LLM is `src/wishful/llm/client.py`. -- `generate_module_code(module: str, functions: Sequence[str], context: str | None) -> str` +- `generate_module_code(module: str, functions: Sequence[str], context: str | None, type_schemas: dict[str, str] | None, function_output_types: dict[str, str] | None) -> str` - If `WISHFUL_FAKE_LLM=1`, returns stub implementations from `_fake_response`: - For each requested function, generates a placeholder that returns its args/kwargs. - Otherwise: @@ -327,11 +459,14 @@ The only place that calls the LLM is `src/wishful/llm/client.py`. - Extracts the returned content, strips markdown code fences, and returns the raw Python source string. - Raises `GenerationError` on failure or empty responses. -- `prompts.build_messages(module, functions, context)`: +- `prompts.build_messages(module, functions, context, type_schemas, function_output_types)`: - `system` message: - Instructs the model to emit **only executable Python code**, no markdown fences. - - Encourages simple, readable, standard‑library‑only code. + - Encourages simple, readable code. + - Explicitly states "You may use any Python libraries available in the environment" (Pydantic, requests, etc.). - Explicitly discourages network, filesystem writes, subprocess, shell execution. + - **Includes registered type schemas** when available, formatted as Python class definitions. + - **Specifies output types** for specific functions when registered via `@wishful.type(output_for=...)`. - `user` message: - Contains module name and list of functions to implement. - Includes discovered context (comments/nearby code) as a block when available. diff --git a/README.md b/README.md index ca47b82..95e797d 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,12 @@ # wishful 🪄 +[![PyPI version](https://badge.fury.io/py/wishful.svg)](https://badge.fury.io/py/wishful) +[![Python 3.12+](https://img.shields.io/badge/python-3.12+-blue.svg)](https://www.python.org/downloads/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![Tests](https://img.shields.io/badge/tests-83%20passed-brightgreen.svg)](https://github.com/pyros-projects/wishful) +[![Coverage](https://img.shields.io/badge/coverage-80%25-green.svg)](https://github.com/pyros-projects/wishful) +[![Code style: ruff](https://img.shields.io/badge/code%20style-ruff-000000.svg)](https://github.com/astral-sh/ruff) + > _"Code so good, you'd think it was wishful thinking"_ Stop writing boilerplate. Start wishing for it instead. @@ -44,8 +51,8 @@ or any provider else supported by litellm **3. Import your wildest fantasies** ```python -from wishful.text import extract_emails -from wishful.dates import to_yyyy_mm_dd +from wishful.static.text import extract_emails +from wishful.static.dates import to_yyyy_mm_dd raw = "Contact us at team@example.com or sales@demo.dev" print(extract_emails(raw)) # ['team@example.com', 'sales@demo.dev'] @@ -59,6 +66,8 @@ print(to_yyyy_mm_dd("31.12.2025")) # '2025-12-31' It's like having a junior dev who never sleeps and always delivers exactly what you asked for (well, _almost_ always). +> **Note**: Use `wishful.static.*` for cached imports (recommended) or `wishful.dynamic.*` for runtime-aware regeneration on every import. See [Static vs Dynamic](#-static-vs-dynamic-when-to-use-which) below. + --- ## 🎯 Wishful Guidance: Help the AI Read Your Mind @@ -67,7 +76,7 @@ Want better results? Drop hints. Literal comments. wishful reads the code _aroun ```python # desired: parse standard nginx combined logs into list of dicts -from wishful.logs import parse_nginx_logs +from wishful.static.logs import parse_nginx_logs records = parse_nginx_logs(Path("/var/log/nginx/access.log").read_text()) ``` @@ -76,6 +85,96 @@ The AI sees your comment and knows _exactly_ what you're after. It's like pair p --- +## 🎨 Type Registry: Teach the AI Your Data Structures + +Want the LLM to generate functions that return **properly structured data**? Register your types with `@wishful.type`: + +### Pydantic Models with Constraints + +```python +from pydantic import BaseModel, Field +import wishful + +@wishful.type +class ProjectPlan(BaseModel): + """Project plan written by master yoda from star wars.""" + project_brief: str + milestones: list[str] = Field(description="list of milestones", min_length=10) + budget: float = Field(gt=0, description="project budget in USD") + +# Now the LLM knows about ProjectPlan and will respect Field constraints! +from wishful.static.pm import project_plan_generator + +plan = project_plan_generator(idea="sudoku web app") +print(plan.milestones) +# ['Decide, you must, key features.', 'Wireframe, you will, the interface.', ...] +# ^ 10+ milestones in Yoda-speak because of the docstring! 🎭 +``` + +**What's happening here?** +- The `@wishful.type` decorator registers your Pydantic model +- The **docstring** influences the LLM's tone/style (Yoda-speak!) +- **Field constraints** (`min_length=10`, `gt=0`) are actually enforced +- Generated code uses your exact type definition + +### Dataclasses and TypedDict Too + +```python +from dataclasses import dataclass +from typing import TypedDict + +@wishful.type(output_for="parse_user_data") +@dataclass +class UserProfile: + """User profile with name, email, and age.""" + name: str + email: str + age: int + +class ProductInfo(TypedDict): + """Product information.""" + name: str + price: float + in_stock: bool + +# Tell the LLM multiple functions use this type +wishful.type(ProductInfo, output_for=["parse_product", "create_product"]) +``` + +The LLM will generate functions that return instances of your registered types. It's like having an API contract, but the implementation writes itself. ✨ + +--- + +## 🔄 Static vs Dynamic: When to Use Which + +wishful supports two import modes: + +### `wishful.static.*` — Cached & Consistent (Default) + +```python +from wishful.static.text import extract_emails +``` + +- ✅ **Cached**: Generated once, reused forever +- ✅ **Fast**: No LLM calls after first import +- ✅ **Editable**: Tweak `.wishful/text.py` directly +- 👉 **Use for**: utilities, parsers, validators, anything stable + +### `wishful.dynamic.*` — Runtime-Aware & Fresh + +```python +from wishful.dynamic.content import generate_story +``` + +- 🔄 **Regenerates**: Fresh LLM call on every import +- 🎯 **Context-aware**: Captures runtime context each time +- 🎨 **Creative**: Different results on each run +- 👉 **Use for**: creative content, experiments, testing variations + +**Note**: Dynamic imports always regenerate and never use the cache, even if a cached version exists. This ensures fresh, context-aware results every time. + +--- + ## 🗄️ Cache Ops: Because Sometimes Wishes Need Revising ### Python API @@ -87,7 +186,7 @@ import wishful wishful.inspect_cache() # ['.wishful/text.py', '.wishful/dates.py'] # Regret a wish? Regenerate it -wishful.regenerate("wishful.text") # Next import re-generates from scratch +wishful.regenerate("wishful.static.text") # Next import re-generates from scratch # Nuclear option: forget everything wishful.clear_cache() # Deletes the entire .wishful/ directory @@ -105,7 +204,7 @@ wishful inspect wishful clear # Regenerate a specific module -wishful regen wishful.text +wishful regen wishful.static.text ``` The cache is just regular Python files in `.wishful/`. Want to tweak the generated code? Edit it directly. It's your wish, after all. @@ -122,10 +221,7 @@ wishful.configure( cache_dir="/tmp/.wishful", # Hide your wishes somewhere else spinner=False, # Silence the "generating..." spinner review=True, # Paranoid? Review code before it runs - # Control how much nearby code is sent to the LLM for context - # (applies to both import lines and call sites) - # or set via WISHFUL_CONTEXT_RADIUS env var. - # Example: wishful.set_context_radius(6) + context_radius=6, # Lines of context around imports/calls (default: 3) allow_unsafe=False, # Keep the safety rails ON (recommended) ) ``` @@ -179,22 +275,22 @@ python my_tests.py # No API calls, just predictable stubs Here's the 30-second version: -1. **Import hook**: wishful installs a `MagicFinder` on `sys.meta_path` that intercepts `wishful.*` imports. -2. **Cache check**: If `.wishful/.py` exists, it loads instantly. No AI needed. -3. **LLM generation**: If not cached, wishful calls the LLM (via `litellm`) to generate the code based on your import and surrounding context. -4. **Validation**: The generated code is AST-parsed and safety-checked (unless you disabled that like a madman). -5. **Execution**: Code is written to `.wishful/`, compiled, and executed as the import result. -6. **Transparency**: The cache is just plain Python files. Edit them. Commit them. They're yours. +1. **Import hook**: wishful installs a `MagicFinder` on `sys.meta_path` that intercepts `wishful.static.*` and `wishful.dynamic.*` imports. +2. **Cache check**: For `static` imports, if `.wishful/.py` exists, it loads instantly. `dynamic` imports always regenerate. +3. **Context discovery**: wishful captures nearby comments, code, and registered type schemas to send to the LLM. +4. **LLM generation**: The LLM (via `litellm`) generates code based on your import, context, and type definitions. +5. **Validation**: The generated code is AST-parsed and safety-checked (unless you disabled that like a madman). +6. **Execution**: Code is written to `.wishful/`, compiled, and executed as the import result. +7. **Transparency**: The cache is just plain Python files. Edit them. Commit them. They're yours. -It's import hooks meets LLMs meets "why didn't this exist already?" +It's import hooks meets LLMs meets type-aware code generation meets "why didn't this exist already?" --- ## 🎭 Fun with Wishful Thinking ```python -# Need some cosmic horror? Just wish for it. -from wishful.story import cosmic_horror_intro +# Need some cosatic.story import cosmic_horror_intro intro = cosmic_horror_intro( setting="a deserted amusement park", @@ -203,14 +299,20 @@ intro = cosmic_horror_intro( print(intro) # 🎢👻 # Math that writes itself -from wishful.numbers import primes_from_to, sum_list +from wishful.static.numbers import primes_from_to, sum_list total = sum_list(list=primes_from_to(1, 100)) print(total) # 1060 (probably) # Because who has time to write date parsers? -from wishful.dates import parse_fuzzy_date +from wishful.static.dates import parse_fuzzy_date + +print(parse_fuzzy_date("next Tuesday")) # Your guess is as good as mine + +# Want different results each time? Use dynamic imports! +from wishful.dynamic.jokes import programming_joke +print(programming_joke()) # New joke on every import 🎲 print(parse_fuzzy_date("next Tuesday")) # Your guess is as good as mine ``` @@ -281,11 +383,15 @@ wishful/ │ ├── __main__.py # CLI interface │ ├── config.py # Configuration │ ├── cache/ # Cache management -│ ├── core/ # Import hooks +│ ├── core/ # Import hooks & discovery │ ├── llm/ # LLM integration +│ ├── types/ # Type registry system │ └── safety/ # Safety validation -├── tests/ # Test suite +├── tests/ # Test suite (83 tests, 80% coverage) ├── examples/ # Usage examples +│ ├── 07_typed_outputs.py # Type registry showcase +│ ├── 08_dynamic_vs_static.py # Static vs dynamic modes +│ └── 09_context_shenanigans.py # Context discovery └── pyproject.toml # Project config ``` @@ -296,6 +402,13 @@ wishful/ **Q: Is this production-ready?** A: Define "production." 🙃 +**Q: Can I make the LLM follow a specific style?** +A: Yes! Use docstrings in `@wishful.type` decorated classes. Want Yoda-speak? Add `"""Written by master yoda from star wars."""` — the LLM will actually do it. + +**Q: Do type hints and Pydantic constraints actually work?** +A: Surprisingly, yes! Field constraints like `min_length=10` or `gt=0` are serialized and sent to the LLM, which respects them. + + **Q: What if the LLM generates bad code?** A: That's what the cache is for. Check `.wishful/`, tweak it, commit it, and it's locked in. diff --git a/coverage.json b/coverage.json new file mode 100644 index 0000000..92bd8a4 --- /dev/null +++ b/coverage.json @@ -0,0 +1 @@ +{"meta": {"format": 3, "version": "7.12.0", "timestamp": "2025-11-24T10:53:08.783188", "branch_coverage": false, "show_contexts": false}, "files": {"src/wishful/__init__.py": {"executed_lines": [1, 3, 5, 6, 7, 9, 10, 11, 12, 13, 14, 15, 18, 20, 34, 37, 40, 42, 43, 44, 47, 50, 53, 60, 62, 64, 65, 66, 69, 74], "summary": {"covered_lines": 29, "num_statements": 30, "percent_covered": 96.66666666666667, "percent_covered_display": "97", "missing_lines": 1, "excluded_lines": 0, "percent_statements_covered": 96.66666666666667, "percent_statements_covered_display": "97"}, "missing_lines": [71], "excluded_lines": [], "functions": {"clear_cache": {"executed_lines": [40, 42, 43, 44], "summary": {"covered_lines": 4, "num_statements": 4, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "inspect_cache": {"executed_lines": [50], "summary": {"covered_lines": 1, "num_statements": 1, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "regenerate": {"executed_lines": [60, 62, 64, 65, 66], "summary": {"covered_lines": 5, "num_statements": 5, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "set_context_radius": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 1, "percent_covered": 0.0, "percent_covered_display": "0", "missing_lines": 1, "excluded_lines": 0, "percent_statements_covered": 0.0, "percent_statements_covered_display": "0"}, "missing_lines": [71], "excluded_lines": []}, "": {"executed_lines": [1, 3, 5, 6, 7, 9, 10, 11, 12, 13, 14, 15, 18, 20, 34, 37, 47, 53, 69, 74], "summary": {"covered_lines": 19, "num_statements": 19, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}}, "classes": {"": {"executed_lines": [1, 3, 5, 6, 7, 9, 10, 11, 12, 13, 14, 15, 18, 20, 34, 37, 40, 42, 43, 44, 47, 50, 53, 60, 62, 64, 65, 66, 69, 74], "summary": {"covered_lines": 29, "num_statements": 30, "percent_covered": 96.66666666666667, "percent_covered_display": "97", "missing_lines": 1, "excluded_lines": 0, "percent_statements_covered": 96.66666666666667, "percent_statements_covered_display": "97"}, "missing_lines": [71], "excluded_lines": []}}}, "src/wishful/__main__.py": {"executed_lines": [1, 3, 5, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 20, 21, 22, 23, 24, 25, 26, 27, 30, 31, 32, 35, 36, 37, 38, 39, 40, 43, 45, 46, 47, 48, 50, 51, 57, 58, 59, 60, 61, 63, 64, 65, 66, 67, 68, 71], "summary": {"covered_lines": 48, "num_statements": 49, "percent_covered": 97.95918367346938, "percent_covered_display": "98", "missing_lines": 1, "excluded_lines": 0, "percent_statements_covered": 97.95918367346938, "percent_statements_covered_display": "98"}, "missing_lines": [72], "excluded_lines": [], "functions": {"_print_usage": {"executed_lines": [9, 10, 11, 12, 13, 14, 15, 16, 17], "summary": {"covered_lines": 9, "num_statements": 9, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "_cmd_inspect": {"executed_lines": [21, 22, 23, 24, 25, 26, 27], "summary": {"covered_lines": 7, "num_statements": 7, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "_cmd_clear": {"executed_lines": [31, 32], "summary": {"covered_lines": 2, "num_statements": 2, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "_cmd_regen": {"executed_lines": [36, 37, 38, 39, 40], "summary": {"covered_lines": 5, "num_statements": 5, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "main": {"executed_lines": [45, 46, 47, 48, 50, 51, 57, 58, 59, 60, 61, 63, 64, 65, 66, 67, 68], "summary": {"covered_lines": 17, "num_statements": 17, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "": {"executed_lines": [1, 3, 5, 8, 20, 30, 35, 43, 71], "summary": {"covered_lines": 8, "num_statements": 9, "percent_covered": 88.88888888888889, "percent_covered_display": "89", "missing_lines": 1, "excluded_lines": 0, "percent_statements_covered": 88.88888888888889, "percent_statements_covered_display": "89"}, "missing_lines": [72], "excluded_lines": []}}, "classes": {"": {"executed_lines": [1, 3, 5, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 20, 21, 22, 23, 24, 25, 26, 27, 30, 31, 32, 35, 36, 37, 38, 39, 40, 43, 45, 46, 47, 48, 50, 51, 57, 58, 59, 60, 61, 63, 64, 65, 66, 67, 68, 71], "summary": {"covered_lines": 48, "num_statements": 49, "percent_covered": 97.95918367346938, "percent_covered_display": "98", "missing_lines": 1, "excluded_lines": 0, "percent_statements_covered": 97.95918367346938, "percent_statements_covered_display": "98"}, "missing_lines": [72], "excluded_lines": []}}}, "src/wishful/cache/__init__.py": {"executed_lines": [1, 3, 14], "summary": {"covered_lines": 2, "num_statements": 2, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "functions": {"": {"executed_lines": [1, 3, 14], "summary": {"covered_lines": 2, "num_statements": 2, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}}, "classes": {"": {"executed_lines": [1, 3, 14], "summary": {"covered_lines": 2, "num_statements": 2, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}}}, "src/wishful/cache/manager.py": {"executed_lines": [1, 3, 4, 5, 7, 10, 12, 13, 14, 16, 17, 18, 19, 22, 23, 24, 27, 28, 29, 30, 31, 34, 35, 36, 37, 38, 41, 42, 43, 44, 47, 48, 49, 52, 53, 54, 55, 58, 59], "summary": {"covered_lines": 39, "num_statements": 39, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "functions": {"module_path": {"executed_lines": [12, 13, 14, 16, 17, 18, 19], "summary": {"covered_lines": 7, "num_statements": 7, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "ensure_cache_dir": {"executed_lines": [23, 24], "summary": {"covered_lines": 2, "num_statements": 2, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "read_cached": {"executed_lines": [28, 29, 30, 31], "summary": {"covered_lines": 4, "num_statements": 4, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "write_cached": {"executed_lines": [35, 36, 37, 38], "summary": {"covered_lines": 4, "num_statements": 4, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "delete_cached": {"executed_lines": [42, 43, 44], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "clear_cache": {"executed_lines": [48, 49], "summary": {"covered_lines": 2, "num_statements": 2, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "inspect_cache": {"executed_lines": [53, 54, 55], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "has_cached": {"executed_lines": [59], "summary": {"covered_lines": 1, "num_statements": 1, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "": {"executed_lines": [1, 3, 4, 5, 7, 10, 22, 27, 34, 41, 47, 52, 58], "summary": {"covered_lines": 13, "num_statements": 13, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}}, "classes": {"": {"executed_lines": [1, 3, 4, 5, 7, 10, 12, 13, 14, 16, 17, 18, 19, 22, 23, 24, 27, 28, 29, 30, 31, 34, 35, 36, 37, 38, 41, 42, 43, 44, 47, 48, 49, 52, 53, 54, 55, 58, 59], "summary": {"covered_lines": 39, "num_statements": 39, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}}}, "src/wishful/config.py": {"executed_lines": [1, 3, 4, 5, 6, 7, 9, 13, 16, 17, 32, 33, 34, 40, 41, 42, 43, 44, 45, 46, 47, 48, 50, 51, 64, 67, 85, 97, 98, 99, 102, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115], "summary": {"covered_lines": 40, "num_statements": 40, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "functions": {"Settings.copy": {"executed_lines": [51], "summary": {"covered_lines": 1, "num_statements": 1, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "configure": {"executed_lines": [85, 97, 98, 99], "summary": {"covered_lines": 4, "num_statements": 4, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "reset_defaults": {"executed_lines": [106, 107, 108, 109, 110, 111, 112, 113, 114, 115], "summary": {"covered_lines": 10, "num_statements": 10, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "": {"executed_lines": [1, 3, 4, 5, 6, 7, 9, 13, 16, 17, 32, 33, 34, 40, 41, 42, 43, 44, 45, 46, 47, 48, 50, 64, 67, 102], "summary": {"covered_lines": 25, "num_statements": 25, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}}, "classes": {"Settings": {"executed_lines": [51], "summary": {"covered_lines": 1, "num_statements": 1, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "": {"executed_lines": [1, 3, 4, 5, 6, 7, 9, 13, 16, 17, 32, 33, 34, 40, 41, 42, 43, 44, 45, 46, 47, 48, 50, 64, 67, 85, 97, 98, 99, 102, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115], "summary": {"covered_lines": 39, "num_statements": 39, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}}}, "src/wishful/core/__init__.py": {"executed_lines": [1, 3, 4, 6], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "functions": {"": {"executed_lines": [1, 3, 4, 6], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}}, "classes": {"": {"executed_lines": [1, 3, 4, 6], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}}}, "src/wishful/core/discovery.py": {"executed_lines": [1, 3, 4, 5, 6, 7, 8, 9, 12, 15, 16, 23, 24, 25, 26, 28, 37, 38, 39, 41, 42, 43, 44, 47, 48, 49, 50, 52, 53, 54, 55, 58, 59, 60, 61, 62, 65, 66, 75, 76, 77, 78, 79, 82, 83, 90, 91, 94, 95, 98, 101, 102, 103, 106, 107, 108, 110, 111, 113, 116, 117, 118, 119, 121, 122, 123, 125, 126, 128, 131, 132, 133, 134, 135, 138, 140, 143, 144, 147, 148, 151, 152, 161, 162, 163, 166, 167, 168, 173, 174, 175, 176, 177, 180, 181, 182, 183, 184, 185, 186, 187, 190, 193, 195], "summary": {"covered_lines": 103, "num_statements": 110, "percent_covered": 93.63636363636364, "percent_covered_display": "94", "missing_lines": 7, "excluded_lines": 7, "percent_statements_covered": 93.63636363636364, "percent_statements_covered_display": "94"}, "missing_lines": [40, 104, 141, 145, 169, 170, 194], "excluded_lines": [28, 29, 30, 31, 32, 33, 34], "functions": {"ImportContext.__init__": {"executed_lines": [23, 24, 25, 26], "summary": {"covered_lines": 4, "num_statements": 4, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "ImportContext.__repr__": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 0, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 6, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [29, 30, 31, 32, 33, 34]}, "_gather_context_lines": {"executed_lines": [38, 39, 41, 42, 43, 44], "summary": {"covered_lines": 6, "num_statements": 7, "percent_covered": 85.71428571428571, "percent_covered_display": "86", "missing_lines": 1, "excluded_lines": 0, "percent_statements_covered": 85.71428571428571, "percent_statements_covered_display": "86"}, "missing_lines": [40], "excluded_lines": []}, "_parse_imported_names": {"executed_lines": [48, 49, 50, 52, 53, 54, 55], "summary": {"covered_lines": 7, "num_statements": 7, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "_safe_parse_line": {"executed_lines": [59, 60, 61, 62], "summary": {"covered_lines": 4, "num_statements": 4, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "_names_from_import_from": {"executed_lines": [66], "summary": {"covered_lines": 1, "num_statements": 1, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "_names_from_import": {"executed_lines": [76, 77, 78, 79], "summary": {"covered_lines": 4, "num_statements": 4, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "_alias_targets": {"executed_lines": [83], "summary": {"covered_lines": 1, "num_statements": 1, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "_matches_import_from": {"executed_lines": [91], "summary": {"covered_lines": 1, "num_statements": 1, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "_matches_import": {"executed_lines": [95], "summary": {"covered_lines": 1, "num_statements": 1, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "discover": {"executed_lines": [101, 102, 103, 106, 107, 108, 110, 111, 113], "summary": {"covered_lines": 9, "num_statements": 10, "percent_covered": 90.0, "percent_covered_display": "90", "missing_lines": 1, "excluded_lines": 0, "percent_statements_covered": 90.0, "percent_statements_covered_display": "90"}, "missing_lines": [104], "excluded_lines": []}, "_iter_relevant_frames": {"executed_lines": [117, 118, 119, 121, 122, 123, 125, 126, 128], "summary": {"covered_lines": 9, "num_statements": 9, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "_is_user_frame": {"executed_lines": [132, 133, 134, 135], "summary": {"covered_lines": 4, "num_statements": 4, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "_gather_usage_context": {"executed_lines": [140, 143, 144, 147, 148], "summary": {"covered_lines": 5, "num_statements": 7, "percent_covered": 71.42857142857143, "percent_covered_display": "71", "missing_lines": 2, "excluded_lines": 0, "percent_statements_covered": 71.42857142857143, "percent_statements_covered_display": "71"}, "missing_lines": [141, 145], "excluded_lines": []}, "_call_site_lines": {"executed_lines": [152], "summary": {"covered_lines": 1, "num_statements": 1, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "_snippets_from_lines": {"executed_lines": [162, 163], "summary": {"covered_lines": 2, "num_statements": 2, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "_parse_file_safe": {"executed_lines": [167, 168], "summary": {"covered_lines": 2, "num_statements": 4, "percent_covered": 50.0, "percent_covered_display": "50", "missing_lines": 2, "excluded_lines": 0, "percent_statements_covered": 50.0, "percent_statements_covered_display": "50"}, "missing_lines": [169, 170], "excluded_lines": []}, "_build_context_snippets": {"executed_lines": [174, 175, 176, 177], "summary": {"covered_lines": 4, "num_statements": 4, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "_dedupe": {"executed_lines": [181, 182, 183, 184, 185, 186, 187], "summary": {"covered_lines": 7, "num_statements": 7, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "set_context_radius": {"executed_lines": [193, 195], "summary": {"covered_lines": 2, "num_statements": 3, "percent_covered": 66.66666666666667, "percent_covered_display": "67", "missing_lines": 1, "excluded_lines": 0, "percent_statements_covered": 66.66666666666667, "percent_statements_covered_display": "67"}, "missing_lines": [194], "excluded_lines": []}, "": {"executed_lines": [1, 3, 4, 5, 6, 7, 8, 9, 12, 15, 16, 28, 37, 47, 58, 65, 75, 82, 90, 94, 98, 116, 131, 138, 151, 161, 166, 173, 180, 190], "summary": {"covered_lines": 29, "num_statements": 29, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 1, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [28]}}, "classes": {"ImportContext": {"executed_lines": [23, 24, 25, 26], "summary": {"covered_lines": 4, "num_statements": 4, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 6, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [29, 30, 31, 32, 33, 34]}, "": {"executed_lines": [1, 3, 4, 5, 6, 7, 8, 9, 12, 15, 16, 28, 37, 38, 39, 41, 42, 43, 44, 47, 48, 49, 50, 52, 53, 54, 55, 58, 59, 60, 61, 62, 65, 66, 75, 76, 77, 78, 79, 82, 83, 90, 91, 94, 95, 98, 101, 102, 103, 106, 107, 108, 110, 111, 113, 116, 117, 118, 119, 121, 122, 123, 125, 126, 128, 131, 132, 133, 134, 135, 138, 140, 143, 144, 147, 148, 151, 152, 161, 162, 163, 166, 167, 168, 173, 174, 175, 176, 177, 180, 181, 182, 183, 184, 185, 186, 187, 190, 193, 195], "summary": {"covered_lines": 99, "num_statements": 106, "percent_covered": 93.39622641509433, "percent_covered_display": "93", "missing_lines": 7, "excluded_lines": 1, "percent_statements_covered": 93.39622641509433, "percent_statements_covered_display": "93"}, "missing_lines": [40, 104, 141, 145, 169, 170, 194], "excluded_lines": [28]}}}, "src/wishful/core/finder.py": {"executed_lines": [1, 3, 4, 5, 7, 10, 11, 12, 15, 16, 18, 19, 20, 23, 24, 27, 28, 29, 30, 31, 32, 35, 36, 38, 39, 42, 43, 51, 53, 54, 55, 58, 59, 62, 63, 66, 69, 70, 72], "summary": {"covered_lines": 38, "num_statements": 40, "percent_covered": 95.0, "percent_covered_display": "95", "missing_lines": 2, "excluded_lines": 0, "percent_statements_covered": 95.0, "percent_statements_covered_display": "95"}, "missing_lines": [48, 71], "excluded_lines": [], "functions": {"MagicFinder.find_spec": {"executed_lines": [19, 20, 23, 24, 27, 28, 29, 30, 31, 32, 35, 36, 38, 39, 42, 43], "summary": {"covered_lines": 16, "num_statements": 17, "percent_covered": 94.11764705882354, "percent_covered_display": "94", "missing_lines": 1, "excluded_lines": 0, "percent_statements_covered": 94.11764705882354, "percent_statements_covered_display": "94"}, "missing_lines": [48], "excluded_lines": []}, "_is_internal_module": {"executed_lines": [53, 54, 55, 58, 59, 62, 63], "summary": {"covered_lines": 7, "num_statements": 7, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "install": {"executed_lines": [69, 70, 72], "summary": {"covered_lines": 3, "num_statements": 4, "percent_covered": 75.0, "percent_covered_display": "75", "missing_lines": 1, "excluded_lines": 0, "percent_statements_covered": 75.0, "percent_statements_covered_display": "75"}, "missing_lines": [71], "excluded_lines": []}, "": {"executed_lines": [1, 3, 4, 5, 7, 10, 11, 12, 15, 16, 18, 51, 66], "summary": {"covered_lines": 12, "num_statements": 12, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}}, "classes": {"MagicFinder": {"executed_lines": [19, 20, 23, 24, 27, 28, 29, 30, 31, 32, 35, 36, 38, 39, 42, 43], "summary": {"covered_lines": 16, "num_statements": 17, "percent_covered": 94.11764705882354, "percent_covered_display": "94", "missing_lines": 1, "excluded_lines": 0, "percent_statements_covered": 94.11764705882354, "percent_statements_covered_display": "94"}, "missing_lines": [48], "excluded_lines": []}, "": {"executed_lines": [1, 3, 4, 5, 7, 10, 11, 12, 15, 16, 18, 51, 53, 54, 55, 58, 59, 62, 63, 66, 69, 70, 72], "summary": {"covered_lines": 22, "num_statements": 23, "percent_covered": 95.65217391304348, "percent_covered_display": "96", "missing_lines": 1, "excluded_lines": 0, "percent_statements_covered": 95.65217391304348, "percent_statements_covered_display": "96"}, "missing_lines": [71], "excluded_lines": []}}}, "src/wishful/core/loader.py": {"executed_lines": [1, 3, 4, 5, 7, 8, 9, 10, 11, 12, 15, 16, 23, 24, 25, 27, 28, 30, 31, 32, 34, 35, 36, 37, 39, 40, 41, 49, 50, 51, 53, 54, 55, 58, 59, 60, 61, 62, 64, 65, 66, 67, 69, 70, 71, 72, 74, 75, 77, 78, 79, 82, 84, 85, 86, 88, 90, 91, 94, 95, 96, 97, 99, 100, 101, 103, 104, 105, 107, 112, 114, 115, 116, 123, 124, 126, 127, 128, 129, 130, 133, 134, 136, 137, 139, 140, 141, 142], "summary": {"covered_lines": 82, "num_statements": 91, "percent_covered": 90.10989010989012, "percent_covered_display": "90", "missing_lines": 9, "excluded_lines": 4, "percent_statements_covered": 90.10989010989012, "percent_statements_covered_display": "90"}, "missing_lines": [56, 57, 80, 108, 117, 118, 119, 120, 121], "excluded_lines": [27, 28, 136, 137], "functions": {"MagicLoader.__init__": {"executed_lines": [24, 25], "summary": {"covered_lines": 2, "num_statements": 2, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "MagicLoader.create_module": {"executed_lines": [28], "summary": {"covered_lines": 0, "num_statements": 0, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 1, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [28]}, "MagicLoader.exec_module": {"executed_lines": [31, 32, 34, 35, 36, 37], "summary": {"covered_lines": 6, "num_statements": 6, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "MagicLoader._generate_and_cache": {"executed_lines": [40, 41, 49, 50, 51], "summary": {"covered_lines": 5, "num_statements": 5, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "MagicLoader._exec_source": {"executed_lines": [54, 55, 58, 59, 60, 61, 62], "summary": {"covered_lines": 7, "num_statements": 9, "percent_covered": 77.77777777777777, "percent_covered_display": "78", "missing_lines": 2, "excluded_lines": 0, "percent_statements_covered": 77.77777777777777, "percent_statements_covered_display": "78"}, "missing_lines": [56, 57], "excluded_lines": []}, "MagicLoader._attach_dynamic_getattr": {"executed_lines": [65, 82], "summary": {"covered_lines": 2, "num_statements": 2, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "MagicLoader._attach_dynamic_getattr._dynamic_getattr": {"executed_lines": [66, 67, 69, 70, 71, 72, 74, 75, 77, 78, 79], "summary": {"covered_lines": 11, "num_statements": 12, "percent_covered": 91.66666666666667, "percent_covered_display": "92", "missing_lines": 1, "excluded_lines": 0, "percent_statements_covered": 91.66666666666667, "percent_statements_covered_display": "92"}, "missing_lines": [80], "excluded_lines": []}, "MagicLoader._declared_symbols": {"executed_lines": [86], "summary": {"covered_lines": 1, "num_statements": 1, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "MagicLoader._load_source": {"executed_lines": [90, 91, 94, 95, 96, 97], "summary": {"covered_lines": 6, "num_statements": 6, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "MagicLoader._ensure_symbols": {"executed_lines": [100, 101, 103, 104, 105, 107, 112], "summary": {"covered_lines": 7, "num_statements": 8, "percent_covered": 87.5, "percent_covered_display": "88", "missing_lines": 1, "excluded_lines": 0, "percent_statements_covered": 87.5, "percent_statements_covered_display": "88"}, "missing_lines": [108], "excluded_lines": []}, "MagicLoader._maybe_review": {"executed_lines": [115, 116], "summary": {"covered_lines": 2, "num_statements": 7, "percent_covered": 28.571428571428573, "percent_covered_display": "29", "missing_lines": 5, "excluded_lines": 0, "percent_statements_covered": 28.571428571428573, "percent_statements_covered_display": "29"}, "missing_lines": [117, 118, 119, 120, 121], "excluded_lines": []}, "MagicLoader._missing_symbols": {"executed_lines": [124], "summary": {"covered_lines": 1, "num_statements": 1, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "MagicLoader._regenerate_with": {"executed_lines": [127, 128, 129, 130], "summary": {"covered_lines": 4, "num_statements": 4, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "MagicPackageLoader.create_module": {"executed_lines": [137], "summary": {"covered_lines": 0, "num_statements": 0, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 1, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [137]}, "MagicPackageLoader.exec_module": {"executed_lines": [140, 141, 142], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "": {"executed_lines": [1, 3, 4, 5, 7, 8, 9, 10, 11, 12, 15, 16, 23, 27, 30, 39, 53, 64, 84, 85, 88, 99, 114, 123, 126, 133, 134, 136, 139], "summary": {"covered_lines": 25, "num_statements": 25, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 2, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [27, 136]}}, "classes": {"MagicLoader": {"executed_lines": [24, 25, 28, 31, 32, 34, 35, 36, 37, 40, 41, 49, 50, 51, 54, 55, 58, 59, 60, 61, 62, 65, 66, 67, 69, 70, 71, 72, 74, 75, 77, 78, 79, 82, 86, 90, 91, 94, 95, 96, 97, 100, 101, 103, 104, 105, 107, 112, 115, 116, 124, 127, 128, 129, 130], "summary": {"covered_lines": 54, "num_statements": 63, "percent_covered": 85.71428571428571, "percent_covered_display": "86", "missing_lines": 9, "excluded_lines": 1, "percent_statements_covered": 85.71428571428571, "percent_statements_covered_display": "86"}, "missing_lines": [56, 57, 80, 108, 117, 118, 119, 120, 121], "excluded_lines": [28]}, "MagicPackageLoader": {"executed_lines": [137, 140, 141, 142], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 1, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [137]}, "": {"executed_lines": [1, 3, 4, 5, 7, 8, 9, 10, 11, 12, 15, 16, 23, 27, 30, 39, 53, 64, 84, 85, 88, 99, 114, 123, 126, 133, 134, 136, 139], "summary": {"covered_lines": 25, "num_statements": 25, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 2, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [27, 136]}}}, "src/wishful/llm/__init__.py": {"executed_lines": [1, 3, 5], "summary": {"covered_lines": 2, "num_statements": 2, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "functions": {"": {"executed_lines": [1, 3, 5], "summary": {"covered_lines": 2, "num_statements": 2, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}}, "classes": {"": {"executed_lines": [1, 3, 5], "summary": {"covered_lines": 2, "num_statements": 2, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}}}, "src/wishful/llm/client.py": {"executed_lines": [1, 3, 4, 6, 8, 9, 12, 13, 16, 19, 20, 21, 22, 25, 28, 47, 68], "summary": {"covered_lines": 16, "num_statements": 29, "percent_covered": 55.172413793103445, "percent_covered_display": "55", "missing_lines": 13, "excluded_lines": 4, "percent_statements_covered": 55.172413793103445, "percent_statements_covered_display": "55"}, "missing_lines": [37, 38, 40, 43, 44, 54, 57, 58, 69, 70, 74, 75, 76], "excluded_lines": [64, 65, 71, 72], "functions": {"_fake_response": {"executed_lines": [20, 21, 22, 25], "summary": {"covered_lines": 4, "num_statements": 4, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "generate_module_code": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 5, "percent_covered": 0.0, "percent_covered_display": "0", "missing_lines": 5, "excluded_lines": 0, "percent_statements_covered": 0.0, "percent_statements_covered_display": "0"}, "missing_lines": [37, 38, 40, 43, 44], "excluded_lines": []}, "_call_llm": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 3, "percent_covered": 0.0, "percent_covered_display": "0", "missing_lines": 3, "excluded_lines": 2, "percent_statements_covered": 0.0, "percent_statements_covered_display": "0"}, "missing_lines": [54, 57, 58], "excluded_lines": [64, 65]}, "_extract_content": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 5, "percent_covered": 0.0, "percent_covered_display": "0", "missing_lines": 5, "excluded_lines": 2, "percent_statements_covered": 0.0, "percent_statements_covered_display": "0"}, "missing_lines": [69, 70, 74, 75, 76], "excluded_lines": [71, 72]}, "": {"executed_lines": [1, 3, 4, 6, 8, 9, 12, 13, 16, 19, 28, 47, 68], "summary": {"covered_lines": 12, "num_statements": 12, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}}, "classes": {"GenerationError": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 0, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "": {"executed_lines": [1, 3, 4, 6, 8, 9, 12, 13, 16, 19, 20, 21, 22, 25, 28, 47, 68], "summary": {"covered_lines": 16, "num_statements": 29, "percent_covered": 55.172413793103445, "percent_covered_display": "55", "missing_lines": 13, "excluded_lines": 4, "percent_statements_covered": 55.172413793103445, "percent_statements_covered_display": "55"}, "missing_lines": [37, 38, 40, 43, 44, 54, 57, 58, 69, 70, 74, 75, 76], "excluded_lines": [64, 65, 71, 72]}}}, "src/wishful/llm/prompts.py": {"executed_lines": [1, 3, 5, 8, 15, 18, 19, 20, 21, 24, 25, 26, 27, 28, 29, 30, 33, 37, 39, 40, 42, 44, 50, 53, 54, 56, 57, 59], "summary": {"covered_lines": 28, "num_statements": 30, "percent_covered": 93.33333333333333, "percent_covered_display": "93", "missing_lines": 2, "excluded_lines": 0, "percent_statements_covered": 93.33333333333333, "percent_statements_covered_display": "93"}, "missing_lines": [32, 60], "excluded_lines": [], "functions": {"build_messages": {"executed_lines": [15, 18, 19, 20, 21, 24, 25, 26, 27, 28, 29, 30, 33, 37, 39, 40, 42, 44], "summary": {"covered_lines": 18, "num_statements": 19, "percent_covered": 94.73684210526316, "percent_covered_display": "95", "missing_lines": 1, "excluded_lines": 0, "percent_statements_covered": 94.73684210526316, "percent_statements_covered_display": "95"}, "missing_lines": [32], "excluded_lines": []}, "strip_code_fences": {"executed_lines": [53, 54, 56, 57, 59], "summary": {"covered_lines": 5, "num_statements": 6, "percent_covered": 83.33333333333333, "percent_covered_display": "83", "missing_lines": 1, "excluded_lines": 0, "percent_statements_covered": 83.33333333333333, "percent_statements_covered_display": "83"}, "missing_lines": [60], "excluded_lines": []}, "": {"executed_lines": [1, 3, 5, 8, 50], "summary": {"covered_lines": 5, "num_statements": 5, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}}, "classes": {"": {"executed_lines": [1, 3, 5, 8, 15, 18, 19, 20, 21, 24, 25, 26, 27, 28, 29, 30, 33, 37, 39, 40, 42, 44, 50, 53, 54, 56, 57, 59], "summary": {"covered_lines": 28, "num_statements": 30, "percent_covered": 93.33333333333333, "percent_covered_display": "93", "missing_lines": 2, "excluded_lines": 0, "percent_statements_covered": 93.33333333333333, "percent_statements_covered_display": "93"}, "missing_lines": [32, 60], "excluded_lines": []}}}, "src/wishful/safety/__init__.py": {"executed_lines": [1, 3, 5], "summary": {"covered_lines": 2, "num_statements": 2, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "functions": {"": {"executed_lines": [1, 3, 5], "summary": {"covered_lines": 2, "num_statements": 2, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}}, "classes": {"": {"executed_lines": [1, 3, 5], "summary": {"covered_lines": 2, "num_statements": 2, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}}}, "src/wishful/safety/validator.py": {"executed_lines": [1, 3, 4, 7, 8, 11, 12, 13, 16, 17, 18, 23, 24, 27, 28, 29, 32, 33, 34, 35, 36, 39, 40, 41, 45, 46, 47, 48, 51, 52, 53, 54, 61, 68, 74, 85, 91, 98, 104, 111, 112, 113, 114, 119, 126, 127, 129, 130, 131, 132], "summary": {"covered_lines": 49, "num_statements": 87, "percent_covered": 56.32183908045977, "percent_covered_display": "56", "missing_lines": 38, "excluded_lines": 0, "percent_statements_covered": 56.32183908045977, "percent_statements_covered_display": "56"}, "missing_lines": [19, 20, 42, 55, 56, 57, 58, 62, 63, 64, 65, 69, 70, 71, 75, 76, 77, 78, 79, 80, 81, 82, 86, 87, 88, 92, 93, 94, 95, 99, 100, 101, 105, 106, 107, 108, 115, 116], "excluded_lines": [], "functions": {"_parse_source": {"executed_lines": [17, 18], "summary": {"covered_lines": 2, "num_statements": 4, "percent_covered": 50.0, "percent_covered_display": "50", "missing_lines": 2, "excluded_lines": 0, "percent_statements_covered": 50.0, "percent_statements_covered_display": "50"}, "missing_lines": [19, 20], "excluded_lines": []}, "_check_imports": {"executed_lines": [24], "summary": {"covered_lines": 1, "num_statements": 1, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "_iter_import_names": {"executed_lines": [28, 29], "summary": {"covered_lines": 2, "num_statements": 2, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "_import_names": {"executed_lines": [33, 34, 35, 36], "summary": {"covered_lines": 4, "num_statements": 4, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "_importfrom_names": {"executed_lines": [40, 41], "summary": {"covered_lines": 2, "num_statements": 3, "percent_covered": 66.66666666666667, "percent_covered_display": "67", "missing_lines": 1, "excluded_lines": 0, "percent_statements_covered": 66.66666666666667, "percent_statements_covered_display": "67"}, "missing_lines": [42], "excluded_lines": []}, "_validate_import_names": {"executed_lines": [46, 47, 48], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "_check_calls": {"executed_lines": [52, 53, 54], "summary": {"covered_lines": 3, "num_statements": 7, "percent_covered": 42.857142857142854, "percent_covered_display": "43", "missing_lines": 4, "excluded_lines": 0, "percent_statements_covered": 42.857142857142854, "percent_statements_covered_display": "43"}, "missing_lines": [55, 56, 57, 58], "excluded_lines": []}, "_check_named_call": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 4, "percent_covered": 0.0, "percent_covered_display": "0", "missing_lines": 4, "excluded_lines": 0, "percent_statements_covered": 0.0, "percent_statements_covered_display": "0"}, "missing_lines": [62, 63, 64, 65], "excluded_lines": []}, "_check_attribute_call": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 3, "percent_covered": 0.0, "percent_covered_display": "0", "missing_lines": 3, "excluded_lines": 0, "percent_statements_covered": 0.0, "percent_statements_covered_display": "0"}, "missing_lines": [69, 70, 71], "excluded_lines": []}, "_resolve_attribute_name": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 8, "percent_covered": 0.0, "percent_covered_display": "0", "missing_lines": 8, "excluded_lines": 0, "percent_statements_covered": 0.0, "percent_statements_covered_display": "0"}, "missing_lines": [75, 76, 77, 78, 79, 80, 81, 82], "excluded_lines": []}, "_validate_open_call": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 3, "percent_covered": 0.0, "percent_covered_display": "0", "missing_lines": 3, "excluded_lines": 0, "percent_statements_covered": 0.0, "percent_statements_covered_display": "0"}, "missing_lines": [86, 87, 88], "excluded_lines": []}, "_extract_open_mode": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 4, "percent_covered": 0.0, "percent_covered_display": "0", "missing_lines": 4, "excluded_lines": 0, "percent_statements_covered": 0.0, "percent_statements_covered_display": "0"}, "missing_lines": [92, 93, 94, 95], "excluded_lines": []}, "_extract_positional_mode": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 3, "percent_covered": 0.0, "percent_covered_display": "0", "missing_lines": 3, "excluded_lines": 0, "percent_statements_covered": 0.0, "percent_statements_covered_display": "0"}, "missing_lines": [99, 100, 101], "excluded_lines": []}, "_extract_keyword_mode": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 4, "percent_covered": 0.0, "percent_covered_display": "0", "missing_lines": 4, "excluded_lines": 0, "percent_statements_covered": 0.0, "percent_statements_covered_display": "0"}, "missing_lines": [105, 106, 107, 108], "excluded_lines": []}, "_check_forbidden_builtins": {"executed_lines": [112, 113, 114], "summary": {"covered_lines": 3, "num_statements": 5, "percent_covered": 60.0, "percent_covered_display": "60", "missing_lines": 2, "excluded_lines": 0, "percent_statements_covered": 60.0, "percent_statements_covered_display": "60"}, "missing_lines": [115, 116], "excluded_lines": []}, "validate_code": {"executed_lines": [126, 127, 129, 130, 131, 132], "summary": {"covered_lines": 6, "num_statements": 6, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "": {"executed_lines": [1, 3, 4, 7, 8, 11, 12, 13, 16, 23, 27, 32, 39, 45, 51, 61, 68, 74, 85, 91, 98, 104, 111, 119], "summary": {"covered_lines": 23, "num_statements": 23, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}}, "classes": {"SecurityError": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 0, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "": {"executed_lines": [1, 3, 4, 7, 8, 11, 12, 13, 16, 17, 18, 23, 24, 27, 28, 29, 32, 33, 34, 35, 36, 39, 40, 41, 45, 46, 47, 48, 51, 52, 53, 54, 61, 68, 74, 85, 91, 98, 104, 111, 112, 113, 114, 119, 126, 127, 129, 130, 131, 132], "summary": {"covered_lines": 49, "num_statements": 87, "percent_covered": 56.32183908045977, "percent_covered_display": "56", "missing_lines": 38, "excluded_lines": 0, "percent_statements_covered": 56.32183908045977, "percent_statements_covered_display": "56"}, "missing_lines": [19, 20, 42, 55, 56, 57, 58, 62, 63, 64, 65, 69, 70, 71, 75, 76, 77, 78, 79, 80, 81, 82, 86, 87, 88, 92, 93, 94, 95, 99, 100, 101, 105, 106, 107, 108, 115, 116], "excluded_lines": []}}}, "src/wishful/types/__init__.py": {"executed_lines": [1, 3, 12], "summary": {"covered_lines": 2, "num_statements": 2, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "functions": {"": {"executed_lines": [1, 3, 12], "summary": {"covered_lines": 2, "num_statements": 2, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}}, "classes": {"": {"executed_lines": [1, 3, 12], "summary": {"covered_lines": 2, "num_statements": 2, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}}}, "src/wishful/types/registry.py": {"executed_lines": [1, 7, 9, 10, 11, 12, 14, 17, 18, 20, 22, 24, 26, 30, 31, 33, 34, 35, 36, 38, 40, 42, 44, 46, 48, 50, 52, 53, 55, 58, 59, 62, 63, 66, 67, 70, 71, 76, 78, 80, 86, 88, 91, 92, 95, 97, 98, 101, 102, 103, 104, 109, 120, 122, 124, 127, 128, 130, 131, 132, 134, 135, 136, 141, 143, 145, 147, 148, 154, 156, 158, 161, 162, 163, 165, 167, 169, 170, 173, 193, 197, 200, 224, 225, 226, 229, 231, 234, 237, 239, 242, 244, 247, 249, 252, 254], "summary": {"covered_lines": 94, "num_statements": 124, "percent_covered": 75.80645161290323, "percent_covered_display": "76", "missing_lines": 30, "excluded_lines": 0, "percent_statements_covered": 75.80645161290323, "percent_statements_covered_display": "76"}, "missing_lines": [72, 74, 83, 84, 105, 110, 112, 113, 114, 115, 116, 118, 137, 151, 152, 159, 174, 175, 177, 178, 181, 182, 183, 184, 185, 186, 187, 189, 190, 191], "excluded_lines": [], "functions": {"TypeRegistry.__init__": {"executed_lines": [22, 24], "summary": {"covered_lines": 2, "num_statements": 2, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "TypeRegistry.register": {"executed_lines": [30, 31, 33, 34, 35, 36], "summary": {"covered_lines": 6, "num_statements": 6, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "TypeRegistry.get_schema": {"executed_lines": [40], "summary": {"covered_lines": 1, "num_statements": 1, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "TypeRegistry.get_all_schemas": {"executed_lines": [44], "summary": {"covered_lines": 1, "num_statements": 1, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "TypeRegistry.get_output_type": {"executed_lines": [48], "summary": {"covered_lines": 1, "num_statements": 1, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "TypeRegistry.clear": {"executed_lines": [52, 53], "summary": {"covered_lines": 2, "num_statements": 2, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "TypeRegistry._serialize_type": {"executed_lines": [58, 59, 62, 63, 66, 67, 70, 71], "summary": {"covered_lines": 8, "num_statements": 10, "percent_covered": 80.0, "percent_covered_display": "80", "missing_lines": 2, "excluded_lines": 0, "percent_statements_covered": 80.0, "percent_statements_covered_display": "80"}, "missing_lines": [72, 74], "excluded_lines": []}, "TypeRegistry._is_pydantic_model": {"executed_lines": [78, 80], "summary": {"covered_lines": 2, "num_statements": 4, "percent_covered": 50.0, "percent_covered_display": "50", "missing_lines": 2, "excluded_lines": 0, "percent_statements_covered": 50.0, "percent_statements_covered_display": "50"}, "missing_lines": [83, 84], "excluded_lines": []}, "TypeRegistry._serialize_pydantic": {"executed_lines": [88, 91, 92, 95, 97, 98, 101, 102, 103, 104, 109, 120], "summary": {"covered_lines": 12, "num_statements": 20, "percent_covered": 60.0, "percent_covered_display": "60", "missing_lines": 8, "excluded_lines": 0, "percent_statements_covered": 60.0, "percent_statements_covered_display": "60"}, "missing_lines": [105, 110, 112, 113, 114, 115, 116, 118], "excluded_lines": []}, "TypeRegistry._serialize_dataclass": {"executed_lines": [124, 127, 128, 130, 131, 132, 134, 135, 136, 141, 143], "summary": {"covered_lines": 11, "num_statements": 12, "percent_covered": 91.66666666666667, "percent_covered_display": "92", "missing_lines": 1, "excluded_lines": 0, "percent_statements_covered": 91.66666666666667, "percent_statements_covered_display": "92"}, "missing_lines": [137], "excluded_lines": []}, "TypeRegistry._is_typed_dict": {"executed_lines": [147, 148], "summary": {"covered_lines": 2, "num_statements": 4, "percent_covered": 50.0, "percent_covered_display": "50", "missing_lines": 2, "excluded_lines": 0, "percent_statements_covered": 50.0, "percent_statements_covered_display": "50"}, "missing_lines": [151, 152], "excluded_lines": []}, "TypeRegistry._serialize_typed_dict": {"executed_lines": [156, 158, 161, 162, 163, 165], "summary": {"covered_lines": 6, "num_statements": 7, "percent_covered": 85.71428571428571, "percent_covered_display": "86", "missing_lines": 1, "excluded_lines": 0, "percent_statements_covered": 85.71428571428571, "percent_statements_covered_display": "86"}, "missing_lines": [159], "excluded_lines": []}, "TypeRegistry._format_annotation": {"executed_lines": [169, 170, 173, 193], "summary": {"covered_lines": 4, "num_statements": 18, "percent_covered": 22.22222222222222, "percent_covered_display": "22", "missing_lines": 14, "excluded_lines": 0, "percent_statements_covered": 22.22222222222222, "percent_statements_covered_display": "22"}, "missing_lines": [174, 175, 177, 178, 181, 182, 183, 184, 185, 186, 187, 189, 190, 191], "excluded_lines": []}, "type": {"executed_lines": [224, 229, 231, 234], "summary": {"covered_lines": 4, "num_statements": 4, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "type.decorator": {"executed_lines": [225, 226], "summary": {"covered_lines": 2, "num_statements": 2, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "get_type_schema": {"executed_lines": [239], "summary": {"covered_lines": 1, "num_statements": 1, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "get_all_type_schemas": {"executed_lines": [244], "summary": {"covered_lines": 1, "num_statements": 1, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "get_output_type_for_function": {"executed_lines": [249], "summary": {"covered_lines": 1, "num_statements": 1, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "clear_type_registry": {"executed_lines": [254], "summary": {"covered_lines": 1, "num_statements": 1, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}, "": {"executed_lines": [1, 7, 9, 10, 11, 12, 14, 17, 18, 20, 26, 38, 42, 46, 50, 55, 76, 86, 122, 145, 154, 167, 197, 200, 237, 242, 247, 252], "summary": {"covered_lines": 26, "num_statements": 26, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}}, "classes": {"TypeRegistry": {"executed_lines": [22, 24, 30, 31, 33, 34, 35, 36, 40, 44, 48, 52, 53, 58, 59, 62, 63, 66, 67, 70, 71, 78, 80, 88, 91, 92, 95, 97, 98, 101, 102, 103, 104, 109, 120, 124, 127, 128, 130, 131, 132, 134, 135, 136, 141, 143, 147, 148, 156, 158, 161, 162, 163, 165, 169, 170, 173, 193], "summary": {"covered_lines": 58, "num_statements": 88, "percent_covered": 65.9090909090909, "percent_covered_display": "66", "missing_lines": 30, "excluded_lines": 0, "percent_statements_covered": 65.9090909090909, "percent_statements_covered_display": "66"}, "missing_lines": [72, 74, 83, 84, 105, 110, 112, 113, 114, 115, 116, 118, 137, 151, 152, 159, 174, 175, 177, 178, 181, 182, 183, 184, 185, 186, 187, 189, 190, 191], "excluded_lines": []}, "": {"executed_lines": [1, 7, 9, 10, 11, 12, 14, 17, 18, 20, 26, 38, 42, 46, 50, 55, 76, 86, 122, 145, 154, 167, 197, 200, 224, 225, 226, 229, 231, 234, 237, 239, 242, 244, 247, 249, 252, 254], "summary": {"covered_lines": 36, "num_statements": 36, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}}}, "src/wishful/ui.py": {"executed_lines": [1, 3, 4, 6, 7, 9, 11, 14, 15, 16, 17, 18], "summary": {"covered_lines": 12, "num_statements": 18, "percent_covered": 66.66666666666667, "percent_covered_display": "67", "missing_lines": 6, "excluded_lines": 0, "percent_statements_covered": 66.66666666666667, "percent_statements_covered_display": "67"}, "missing_lines": [20, 21, 22, 23, 25, 26], "excluded_lines": [], "functions": {"spinner": {"executed_lines": [16, 17, 18], "summary": {"covered_lines": 3, "num_statements": 9, "percent_covered": 33.333333333333336, "percent_covered_display": "33", "missing_lines": 6, "excluded_lines": 0, "percent_statements_covered": 33.333333333333336, "percent_statements_covered_display": "33"}, "missing_lines": [20, 21, 22, 23, 25, 26], "excluded_lines": []}, "": {"executed_lines": [1, 3, 4, 6, 7, 9, 11, 14, 15], "summary": {"covered_lines": 9, "num_statements": 9, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": []}}, "classes": {"": {"executed_lines": [1, 3, 4, 6, 7, 9, 11, 14, 15, 16, 17, 18], "summary": {"covered_lines": 12, "num_statements": 18, "percent_covered": 66.66666666666667, "percent_covered_display": "67", "missing_lines": 6, "excluded_lines": 0, "percent_statements_covered": 66.66666666666667, "percent_statements_covered_display": "67"}, "missing_lines": [20, 21, 22, 23, 25, 26], "excluded_lines": []}}}}, "totals": {"covered_lines": 589, "num_statements": 698, "percent_covered": 84.38395415472779, "percent_covered_display": "84", "missing_lines": 109, "excluded_lines": 15, "percent_statements_covered": 84.38395415472779, "percent_statements_covered_display": "84"}} \ No newline at end of file diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md new file mode 100644 index 0000000..e56fd97 --- /dev/null +++ b/docs/CHANGELOG.md @@ -0,0 +1,97 @@ +# Changelog + +All notable changes to wishful will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.2.0] - 2024-11-24 + +### Added + +#### 🎨 Type Registry System +- **Type registration decorator** (`@wishful.type`) for Pydantic models, dataclasses, and TypedDict +- **Pydantic Field constraint support**: LLM now respects `min_length`, `max_length`, `gt`, `ge`, `lt`, `le`, and `pattern` constraints +- **Docstring-driven LLM behavior**: Class docstrings influence generated code tone and style (e.g., "written by master yoda" generates Yoda-speak) +- **Type binding to functions**: `@wishful.type(output_for="function_name")` tells LLM which functions should return which types +- **Multi-function type sharing**: `wishful.type(TypeClass, output_for=["func1", "func2"])` for shared types + +#### 🔄 Static vs Dynamic Namespaces +- **`wishful.static.*` namespace**: Cached generation (default behavior, fast subsequent imports) +- **`wishful.dynamic.*` namespace**: Runtime-aware regeneration on every import (for creative/contextual content) +- Both namespaces share the same cache file for consistency + +#### 🧠 Enhanced Context Discovery +- **Type schema integration**: Registered types are automatically included in LLM prompts +- **Function output type hints**: LLM receives information about expected return types +- Type definitions are serialized with full docstrings and Field constraints + +#### 📦 Pydantic v2 Support +- **Metadata-based constraint extraction**: Properly parses Pydantic v2's constraint storage (`MinLen`, `MaxLen`, `Gt`, etc.) +- **`_PydanticGeneralMetadata` handling**: Extracts `pattern` and other general constraints +- **Backward compatibility**: Still supports Pydantic v1 direct attribute access + +### Changed + +#### System Prompt Updates +- **External library support**: Changed from "only use Python standard library" to "you may use any Python libraries available in the environment" +- Pydantic, requests, and other common libraries now explicitly allowed in generated code + +#### Examples +- Added `07_typed_outputs.py`: Comprehensive type registry demonstration +- Added `08_dynamic_vs_static.py`: Static vs dynamic namespace comparison +- Added `09_context_shenanigans.py`: Context discovery behavior showcase +- All examples updated to use `wishful.static.*` namespace convention + +#### Documentation +- **AGENTS.md**: Complete sync with current codebase state + - Added Pydantic Field constraint documentation + - Added docstring influence documentation + - Added type registry implementation details + - Updated TDD process documentation +- **README.md**: Added type registry section, static/dynamic namespace explanation, and updated FAQ + +### Internal Improvements + +#### Type Registry (`src/wishful/types/`) +- `_build_field_args()`: New method to extract Field() arguments from Pydantic field_info +- `_serialize_pydantic()`: Enhanced to include Field constraints in serialized schemas +- Docstring serialization for all type systems (Pydantic, dataclass, TypedDict) + +#### Discovery System (`src/wishful/core/discovery.py`) +- `ImportContext`: Extended with `type_schemas` and `function_output_types` fields +- `discover()`: Now fetches registered type schemas and output type bindings +- Integration with `wishful.types.get_all_type_schemas()` and `get_output_type_for_function()` + +#### LLM Prompts (`src/wishful/llm/prompts.py`) +- Enhanced `build_messages()` to include type definitions in prompts +- System prompt updated to allow external libraries +- Type schemas formatted as executable Python code for LLM + +### Tests +- **83 total tests** with **80% code coverage** +- Added 4 new tests in `test_discovery.py` for type registry integration +- Added 30 tests in `test_types.py` for type serialization (all scenarios) +- Added 6 tests in `test_namespaces.py` for static vs dynamic behavior + +### Dependencies +- Added `pydantic>=2.12.4` as runtime dependency + +--- + +## [0.1.6] - 2024-11-XX + +### Initial Release +- Basic import hook system with LLM code generation +- Cache management (static `.wishful/` directory) +- Context discovery from import sites +- Safety validation (AST-based checks) +- CLI interface (`wishful inspect`, `clear`, `regen`) +- Configuration system with environment variables +- litellm integration for multi-provider LLM support +- Fake LLM mode for deterministic testing (`WISHFUL_FAKE_LLM=1`) + +--- + +[0.2.0]: https://github.com/pyros-projects/wishful/compare/v0.1.6...v0.2.0 +[0.1.6]: https://github.com/pyros-projects/wishful/releases/tag/v0.1.6 diff --git a/examples/00_quick_start.py b/examples/00_quick_start.py index cf0bcb4..792b42b 100644 --- a/examples/00_quick_start.py +++ b/examples/00_quick_start.py @@ -20,14 +20,14 @@ def heading(title: str) -> None: def example_extract_emails(): heading("Example: extract emails from text") text = "Contact us: team@example.com or sales@demo.dev" - from wishful.text import extract_emails + from wishful.static.text import extract_emails print(extract_emails(text)) def example_date_normalizer(): heading("Example: normalize dates") - from wishful.dates import to_yyyy_mm_dd + from wishful.static.dates import to_yyyy_mm_dd print(to_yyyy_mm_dd("31.12.2025")) print(to_yyyy_mm_dd("12/25/2025")) @@ -36,7 +36,7 @@ def example_date_normalizer(): def example_nginx_logs(): heading("Example: nginx log parser with inline context") # desired: parse standard nginx combined logs into list of dicts - from wishful.logs import parse_nginx_logs + from wishful.static.logs import parse_nginx_logs sample = '127.0.0.1 - - [10/Oct/2025:13:55:36 +0000] "GET /index.html HTTP/1.1" 200 2326 "-" "curl/7.81.0"' records = parse_nginx_logs(sample) @@ -45,7 +45,7 @@ def example_nginx_logs(): def example_read_README(): heading("Example: read README and count headers") # desired: loads text and counts headers - from wishful.text import count_headers + from wishful.static.text import count_headers records = count_headers(path="README.md") @@ -53,7 +53,7 @@ def example_read_README(): def example_primes(): heading("Example: functions inside functions - sum of primes") - from wishful.numbers import primes_from_to, sum_list + from wishful.static.numbers import primes_from_to, sum_list sum = sum_list(list=primes_from_to(1, 100)) @@ -62,7 +62,7 @@ def example_primes(): def example_story(): heading("Example: story generation with setting") - from wishful.story import cosmic_horror_intro + from wishful.static.story import cosmic_horror_intro intro = cosmic_horror_intro(setting="a deserted amusement park", word_count_at_least=100) @@ -72,10 +72,11 @@ def example_story(): def example_cache_ops(tmp_dir: Path): heading("Example: cache inspection and regeneration") wishful.configure(cache_dir=tmp_dir) - from wishful.utils import hello_world + from wishful.static.utils import hello_world + print("hello_world():", hello_world()) print("cached files:", wishful.inspect_cache()) - wishful.regenerate("wishful.utils") + wishful.regenerate("wishful.static.utils") print("after regenerate:", wishful.inspect_cache()) diff --git a/examples/01_json_yaml.py b/examples/01_json_yaml.py index 600f8d3..1507597 100644 --- a/examples/01_json_yaml.py +++ b/examples/01_json_yaml.py @@ -5,7 +5,7 @@ """ # Desired: parse JSON with automatic type conversion and validation -from wishful.data import parse_json_safe, dict_to_yaml +from wishful.static.data import parse_json_safe, dict_to_yaml json_str = '{"name": "Alice", "age": 30, "active": true}' data = parse_json_safe(json_str) diff --git a/examples/02_web_scraping.py b/examples/02_web_scraping.py index c3986fd..beea83d 100644 --- a/examples/02_web_scraping.py +++ b/examples/02_web_scraping.py @@ -4,7 +4,7 @@ """ # Desired: extract all links from HTML, clean URLs -from wishful.web import extract_links, clean_url +from wishful.static.web import extract_links, clean_url html = """ diff --git a/examples/03_data_validation.py b/examples/03_data_validation.py index 2004666..39615f3 100644 --- a/examples/03_data_validation.py +++ b/examples/03_data_validation.py @@ -4,7 +4,7 @@ """ # Desired: validate email format, sanitize user input, normalize whitespace -from wishful.validation import is_valid_email, sanitize_html, normalize_whitespace +from wishful.static.validation import is_valid_email, sanitize_html, normalize_whitespace # Email validation emails = ["valid@example.com", "invalid@", "also@valid.co.uk", "nope"] diff --git a/examples/04_format_conversion.py b/examples/04_format_conversion.py index ca31a64..8bac92f 100644 --- a/examples/04_format_conversion.py +++ b/examples/04_format_conversion.py @@ -4,7 +4,7 @@ """ # Desired: convert CSV to JSON, JSON to XML, dict to query string -from wishful.convert import csv_to_json, dict_to_query_string +from wishful.static.convert import csv_to_json, dict_to_query_string csv_data = """name,age,city Alice,30,NYC diff --git a/examples/05_api_client.py b/examples/05_api_client.py index 2e7fb5d..27eed55 100644 --- a/examples/05_api_client.py +++ b/examples/05_api_client.py @@ -4,7 +4,7 @@ """ # Desired: make a GET request with headers, parse JSON response, handle errors gracefully -from wishful.http import fetch_json, post_json +from wishful.static.http import fetch_json, post_json # Note: This will generate a basic HTTP client # In fake mode, it returns stub data diff --git a/examples/06_omg_why.py b/examples/06_omg_why.py index 1441ca2..bb1cc08 100644 --- a/examples/06_omg_why.py +++ b/examples/06_omg_why.py @@ -6,25 +6,10 @@ """ # ======================================== -# Mathematical Nonsense +# String Nonsense # ======================================== -import wishful -from wishful.omg import nth_digit_of_pi, prime_factors, fibonacci_mod_cheese - -wishful.clear_cache() - -print("=== Mathematical Chaos ===") -print(f"100th digit of π: {nth_digit_of_pi(100)}") -print(f"Prime factors of 3734775621: {prime_factors(3734775621)}") -# desired: return nth fibonacci number modulo 7919 (the 1000th prime, aka "cheese prime") -print(f"Fibonacci(50) mod cheese: {fibonacci_mod_cheese(50)}") - -# ======================================== -# String Crimes -# ======================================== - -from wishful.cursed import reverse_words_keep_punctuation, zalgo_text, uwuify +from wishful.static.cursed import reverse_words_keep_punctuation, zalgo_text, uwuify print("\n=== String Crimes ===") text = "Hello, world! How are you?" @@ -39,19 +24,22 @@ # Time Travel Crimes # ======================================== -from wishful.time import day_of_week_for_any_date, seconds_until_christmas, unix_time_to_readable + +from wishful.static.time import day_of_week_for_any_date, seconds_until_christmas, unix_time_to_readable + print("\n=== Temporal Shenanigans ===") # desired: return day of week (Monday, Tuesday, etc) for any YYYY-MM-DD date print(f"What day was 2000-01-01? {day_of_week_for_any_date('2000-01-01')}") print(f"Seconds until Christmas: {seconds_until_christmas()}") -print(f"Unix epoch as human time: {unix_time_to_readable(0)}") +print(f"Unix epoch as human time: {unix_time_to_readable(746214400)}") # ======================================== # List Manipulation Madness # ======================================== -from wishful.lists import flatten_nested, rotate_list, chunk_by_size +from wishful.static.lists import chunk_by_size, flatten_nested, rotate_list + print("\n=== List Crimes ===") nested = [1, [2, 3, [4, 5]], 6, [[7]]] @@ -64,7 +52,7 @@ # Color Space Nonsense # ======================================== -from wishful.colors import hex_to_rgb, rgb_to_hsl, complementary_color +from wishful.static.colors import complementary_color, hex_to_rgb, rgb_to_hsl print("\n=== Color Chaos ===") print(f"#FF5733 to RGB: {hex_to_rgb('#FF5733')}") @@ -72,17 +60,23 @@ # desired: return the complementary color (opposite on color wheel) as hex print(f"Complement of #FF5733: {complementary_color('#FF5733')}") -# ======================================== -# The Truly Unhinged -# ======================================== -from wishful.why import generate_fake_ipsum, random_excuse, rock_paper_scissors_lizard_spock +from wishful.static.omg import fibonacci_mod_cheese, nth_digit_of_pi, prime_factors + + +print("=== Mathematical Chaos ===") +print(f"100th digit of π: {nth_digit_of_pi(100)}") +print(f"Prime factors of 3734775621: {prime_factors(3734775621)}") +# desired: return nth fibonacci number modulo 7919 (the 1000th prime, aka "cheese prime") +print(f"Fibonacci(50) mod cheese: {fibonacci_mod_cheese(50)}") + +from wishful.static.why import generate_fake_ipsum, random_excuse, rock_paper_scissors_lizard_spock print("\n=== Peak Absurdity ===") # desired: generate Lorem Ipsum style text but with random tech buzzwords -print(f"Fake ipsum: {generate_fake_ipsum(words=15)}") +print(f"Fake ipsum: {generate_fake_ipsum(words=15, as_list=False)}") # desired: generate a random plausible-sounding excuse for being late -print(f"Excuse generator: {random_excuse(include_traffic=True, include_words=generate_fake_ipsum(words=5))}") +print(f"Excuse generator: {random_excuse(number_sentences=3, include_words=generate_fake_ipsum(words=2, as_list=True))}") # desired: play rock-paper-scissors-lizard-spock, return winner explanation result = rock_paper_scissors_lizard_spock("rock", "spock") print(f"Rock vs Spock: {result}") diff --git a/examples/07_typed_outputs.py b/examples/07_typed_outputs.py new file mode 100644 index 0000000..71b8aaa --- /dev/null +++ b/examples/07_typed_outputs.py @@ -0,0 +1,222 @@ +"""Example demonstrating type registration for complex return types. + +This example shows how to use @wishful.type to register Pydantic models, +dataclasses, and TypedDict types so the LLM can generate functions that +return properly structured data. + +Run with: `uv run python examples/07_typed_outputs.py` +""" + +import os +from dataclasses import dataclass +from typing import TypedDict +from pydantic import BaseModel, Field + +import wishful +wishful.clear_cache() # Clear cached generated code for fresh runs +wishful.configure(allow_unsafe=True) + +def heading(title: str) -> None: + print("\n" + "=" * len(title)) + print(title) + print("=" * len(title)) + + +# Example 1: Simple type registration +@wishful.type +@dataclass +class Book: + """A book with title, author, and year.""" + title: str + author: str + year: int + isbn: str | None = None + + +def example_simple_type_registration(): + heading("Example 1: Simple Type Registration") + + # The LLM knows about the Book type and can use it + from wishful.static.library import create_sample_book + + book = create_sample_book() + print(f"Generated book: {book}") + print(f"Type: {type(book)}") + + +# Example 2: Specify output type for a function +@wishful.type(output_for="parse_user_data") +@dataclass +class UserProfile: + """User profile with name, email, and age.""" + name: str + email: str + age: int + is_active: bool = True + + +def example_typed_output(): + heading("Example 2: Typed Function Output") + + # The LLM will generate parse_user_data to return a UserProfile + from wishful.static.users import parse_user_data + + raw_data = "John Doe, john@example.com, 30" + profile = parse_user_data(raw_data) + + print(f"Parsed profile: {profile}") + + # In fake mode, this returns a dict; with a real LLM, it returns UserProfile + if hasattr(profile, 'name'): + print(f"Name: {profile.name}") + print(f"Email: {profile.email}") + print(f"Age: {profile.age}") + else: + print("(Fake mode returns dict; real LLM would return UserProfile instance)") + + +# Example 3: Multiple functions sharing the same output type +class ProductInfo(TypedDict): + """Product information.""" + name: str + price: float + in_stock: bool + category: str + + +wishful.type(ProductInfo, output_for=["parse_product", "create_product"]) + + +def example_shared_type(): + heading("Example 3: Multiple Functions with Shared Type") + + from wishful.static.products import parse_product, create_product + + # Both functions return ProductInfo + product1 = parse_product("Laptop,$999.99,true,Electronics") + print(f"Parsed product: {product1}") + + product2 = create_product( + name="Mouse", + price=29.99, + in_stock=True, + category="Accessories" + ) + print(f"Created product: {product2}") + + +# Example 4: Nested types for complex data structures +@wishful.type +@dataclass +class Address: + """Mailing address.""" + street: str + city: str + state: str + zip_code: str + + +@wishful.type(output_for="extract_contact_info") +@dataclass +class ContactInfo: + """Contact information with address.""" + name: str + phone: str + email: str + address: Address | None = None + + +def example_nested_types(): + heading("Example 4: Nested Complex Types") + + # The LLM knows about both Address and ContactInfo + from wishful.static.contacts import extract_contact_info + + text = """ + Name: Alice Smith + Phone: 555-1234 + Email: alice@example.com + Address: 123 Main St, Springfield, IL, 62701 + """ + + contact = extract_contact_info(text) + print(f"Extracted contact: {contact}") + + # Handle both real and fake mode + if hasattr(contact, 'address') and contact.address: + print(f"Lives in: {contact.address.city}, {contact.address.state}") + else: + print("(Fake mode returns dict; real LLM would return ContactInfo instance)") + + +# Example 5: Using types with validation logic +@wishful.type(output_for=["validate_age", "calculate_birth_year"]) +@dataclass +class Person: + """Person with validated age.""" + name: str + age: int + + def __post_init__(self): + if self.age < 0 or self.age > 150: + raise ValueError(f"Invalid age: {self.age}") + + +def example_validated_types(): + heading("Example 5: Types with Validation") + + from wishful.static.people import validate_age, calculate_birth_year + + # validate_age checks if age is in valid range + result = validate_age("25") + print(f"Age validation result: {result}") + + # calculate_birth_year computes birth year from age + person = calculate_birth_year(age=30, current_year=2025) + print(f"Birth year calculation: {person}") + print("(With real LLM, these would return Person instances with validation)") + + +# Example 6: Pydantic +@wishful.type +class ProjectPlan(BaseModel): + """Project plan written by master yoda from star wars.""" + project_brief: str + milestones: list[str] = Field(description="list of milestones", min_length=10) + +def example_pydantic(): + heading("Example 6: Pydantic") + + from wishful.static.pm import project_plan_generator + + # validate_age checks if age is in valid range + result = project_plan_generator(idea="sudoku web app") + print(f"{result}") + + + + + +def main(): + # Make output deterministic in CI if desired + if os.getenv("WISHFUL_FAKE_LLM") == "1": + print("Using fake LLM stub responses (WISHFUL_FAKE_LLM=1)") + + print("\n🪄 Type Registry Examples for Wishful\n") + print("These examples show how to register complex types so the LLM") + print("can generate functions that return properly structured data.\n") + + example_simple_type_registration() + example_typed_output() + example_shared_type() + example_nested_types() + example_validated_types() + example_pydantic() + + print("\n" + "=" * 60) + print("✨ All examples completed!") + print("=" * 60) + + +if __name__ == "__main__": + main() diff --git a/examples/08_dynamic_vs_static.py b/examples/08_dynamic_vs_static.py new file mode 100644 index 0000000..cd6f2f0 --- /dev/null +++ b/examples/08_dynamic_vs_static.py @@ -0,0 +1,138 @@ +"""Example demonstrating wishful.static.* vs wishful.dynamic.* namespaces. + +This example shows the difference between: +- wishful.static.* : Cached generation (generated once, reused) +- wishful.dynamic.* : Runtime generation (regenerated every time with context) + +Run with: `uv run python examples/08_dynamic_vs_static.py` +""" + +import os +import wishful + + +def heading(title: str) -> None: + print("\n" + "=" * len(title)) + print(title) + print("=" * len(title)) + + +def example_static_cached(): + heading("Example 1: Static (Cached) Behavior") + + print("First import - generates code:") + from wishful.static.greetings import say_hello + result1 = say_hello("Alice") + print(f" Result: {result1}") + + print("\nSecond import - uses cache, same result:") + # Clear module to re-import + import sys + sys.modules.pop('wishful.static.greetings', None) + from wishful.static.greetings import say_hello as say_hello2 + result2 = say_hello2("Bob") + print(f" Result: {result2}") + print(" (Same function definition, just different input)") + + +def example_dynamic_runtime(): + heading("Example 2: Dynamic (Runtime Context) Behavior") + + print("Dynamic generation sees runtime arguments:") + print("\nCalling with topic='space':") + from wishful.dynamic.ideas import generate_project_idea + idea1 = generate_project_idea(topic="space") + print(f" Result: {idea1}") + + print("\nCalling with topic='cooking' (regenerates with new context):") + # In dynamic mode, each import can see different runtime context + # import sys + # sys.modules.pop('wishful.dynamic.ideas', None) + from wishful.dynamic.ideas import generate_project_idea as gen2 + + idea2 = gen2(topic="cooking") + print(f" Result: {idea2}") + print(" (LLM sees the actual argument values during generation!)") + + print("\nCalling with topic='python ai library':") + # In dynamic mode, each import can see different runtime context + # import sys + # sys.modules.pop('wishful.dynamic.ideas', None) + from wishful.dynamic.ideas import generate_project_idea as gen3 + idea3 = gen3(topic="python ai library") + print(f" Result: {idea3}") + + +def example_why_static(): + heading("Example 3: When to Use Static") + + print("Use wishful.static.* for:") + print(" ✓ Utilities that don't depend on runtime values") + print(" ✓ Parsers, validators, formatters") + print(" ✓ Performance (generated once, cached forever)") + print() + + from wishful.static.text import extract_emails + text = "Contact: alice@example.com or bob@test.org" + emails = extract_emails(text) + print(f"Extract emails: {emails}") + print("(This function definition won't change, so cache it!)") + + +def example_why_dynamic(): + heading("Example 4: When to Use Dynamic") + + print("Use wishful.dynamic.* for:") + print(" ✓ Functions that should 'hardcode' runtime values") + print(" ✓ Generating creative content based on specific inputs") + print(" ✓ Context-aware behavior") + print() + + print("Generating a story opening with specific setting:") + from wishful.dynamic.stories import create_opening + opening = create_opening( + genre="sci-fi", + setting="abandoned space station", + protagonist="a lone engineer" + ) + print(f"Result: {opening}") + print("\n(The LLM saw those exact values and generated accordingly!)") + + +def example_namespace_isolation(): + heading("Example 5: Namespace Isolation") + + print("Internal wishful.* modules (core, cache, etc.) are protected:") + print(" wishful.cache ✓ Internal module") + print(" wishful.config ✓ Internal module") + print(" wishful.types ✓ Internal module") + print() + print("Your generated code lives in namespaces:") + print(" wishful.static.* ✓ Your cached functions") + print(" wishful.dynamic.* ✓ Your runtime-aware functions") + print() + print("This prevents naming conflicts!") + + +def main(): + if os.getenv("WISHFUL_FAKE_LLM") == "1": + print("Using fake LLM stub responses (WISHFUL_FAKE_LLM=1)") + print("Note: Fake mode doesn't show the full power of dynamic generation\n") + + print("\n🪄 Static vs Dynamic Namespaces in Wishful\n") + + example_static_cached() + example_dynamic_runtime() + example_why_static() + example_why_dynamic() + example_namespace_isolation() + + print("\n" + "=" * 60) + print("✨ Key Takeaway:") + print(" • wishful.static.* → Generate once, cache, reuse") + print(" • wishful.dynamic.* → See runtime context, regenerate") + print("=" * 60) + + +if __name__ == "__main__": + main() diff --git a/examples/09_context_shenanigans.py b/examples/09_context_shenanigans.py new file mode 100644 index 0000000..13f99fb --- /dev/null +++ b/examples/09_context_shenanigans.py @@ -0,0 +1,12 @@ + +import wishful +wishful.clear_cache() + + +from wishful.dynamic.ideas import generate_project_idea +idea1 = generate_project_idea(topic="space", include_project_brief=True, include_plan=True, plan_levels=["Milestone", "Story", "Task"], format="markdown") +print(f"{idea1}") + +idea2 = generate_project_idea(topic="space", include_project_brief=True, include_plan=True, plan_levels=["Milestone", "Story", "Task"], format="json", old_ideas_to_avoid=[idea1]) +print(f"{idea2}") + diff --git a/pyproject.toml b/pyproject.toml index 52432c3..f8945a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "wishful" -version = "0.1.6" +version = "0.2.0" description = "Wishful thinking for Python" readme = "README.md" authors = [ @@ -11,6 +11,7 @@ dependencies = [ "litellm>=1.40.0", "rich>=13.7.0", "python-dotenv>=1.0.1", + "pydantic>=2.12.4", ] [build-system] @@ -19,5 +20,11 @@ build-backend = "uv_build" [dependency-groups] dev = [ + "bandit>=1.9.1", + "coverage>=7.12.0", + "mypy>=1.18.2", "pytest>=9.0.1", + "pytest-cov>=7.0.0", + "radon>=6.0.1", + "ruff>=0.14.6", ] diff --git a/src/wishful/__init__.py b/src/wishful/__init__.py index d03cea3..64245fb 100644 --- a/src/wishful/__init__.py +++ b/src/wishful/__init__.py @@ -12,6 +12,7 @@ from wishful.core.finder import install as install_finder from wishful.llm.client import GenerationError from wishful.safety.validator import SecurityError +from wishful.types import type as type_decorator # Install on import so `import magic.xyz` is intercepted immediately. install_finder() @@ -22,10 +23,16 @@ "inspect_cache", "regenerate", "set_context_radius", + "settings", + "reset_defaults", "SecurityError", "GenerationError", + "type", ] +# Alias for cleaner API +type = type_decorator + def clear_cache() -> None: """Delete all generated files from the cache directory.""" @@ -44,10 +51,16 @@ def inspect_cache() -> List[str]: def regenerate(module_name: str) -> None: - """Force regeneration of a module on next import.""" - + """Force regeneration of a module on next import. + + Accepts module names with or without the wishful.static prefix. + Example: regenerate('users') or regenerate('wishful.static.users') + """ + # Ensure it has the wishful prefix if not module_name.startswith("wishful"): - module_name = f"wishful.{module_name}" + # Default to static namespace for backward compatibility + module_name = f"wishful.static.{module_name}" + cache.delete_cached(module_name) sys.modules.pop(module_name, None) importlib.invalidate_caches() diff --git a/src/wishful/__main__.py b/src/wishful/__main__.py index df5d397..ce1bb21 100644 --- a/src/wishful/__main__.py +++ b/src/wishful/__main__.py @@ -1,54 +1,72 @@ """Command-line interface for wishful.""" import sys -from pathlib import Path import wishful -def main(): +def _print_usage() -> None: + print("wishful - Just-in-time Python module generation") + print("\nUsage:") + print(" python -m wishful inspect Show cached modules") + print(" python -m wishful clear Clear all cache") + print(" python -m wishful regen Regenerate a module") + print("\nExamples:") + print(" python -m wishful inspect") + print(" python -m wishful regen wishful.text") + print(" python -m wishful clear") + + +def _cmd_inspect() -> None: + cached = wishful.inspect_cache() + if not cached: + print("No cached modules found in", wishful.settings.cache_dir) + return + print(f"Cached modules in {wishful.settings.cache_dir}:") + for path in cached: + print(f" {path}") + + +def _cmd_clear() -> None: + wishful.clear_cache() + print(f"Cleared all cached modules from {wishful.settings.cache_dir}") + + +def _cmd_regen(args: list[str]) -> None: + if not args: + raise ValueError("'regen' requires a module name") + module_name = args[0] + wishful.regenerate(module_name) + print(f"Regenerated {module_name} (will be re-created on next import)") + + +def main() -> None: """Main CLI entry point.""" - if len(sys.argv) < 2: - print("wishful - Just-in-time Python module generation") - print("\nUsage:") - print(" python -m wishful inspect Show cached modules") - print(" python -m wishful clear Clear all cache") - print(" python -m wishful regen Regenerate a module") - print("\nExamples:") - print(" python -m wishful inspect") - print(" python -m wishful regen wishful.text") - print(" python -m wishful clear") + args = sys.argv[1:] + if not args: + _print_usage() sys.exit(0) - command = sys.argv[1] - - if command == "inspect": - cached = wishful.inspect_cache() - if not cached: - print("No cached modules found in", wishful.settings.cache_dir) - else: - print(f"Cached modules in {wishful.settings.cache_dir}:") - for path in cached: - print(f" {path}") - - elif command == "clear": - wishful.clear_cache() - print(f"Cleared all cached modules from {wishful.settings.cache_dir}") - - elif command == "regen": - if len(sys.argv) < 3: - print("Error: 'regen' requires a module name") - print("Usage: python -m wishful regen ") - sys.exit(1) - module_name = sys.argv[2] - wishful.regenerate(module_name) - print(f"Regenerated {module_name} (will be re-created on next import)") - - else: + command, *rest = args + handlers = { + "inspect": _cmd_inspect, + "clear": _cmd_clear, + "regen": lambda: _cmd_regen(rest), + } + + handler = handlers.get(command) + if handler is None: print(f"Unknown command: {command}") print("Use 'python -m wishful' for help") sys.exit(1) + try: + handler() + except ValueError as exc: + print(f"Error: {exc}") + print("Usage: python -m wishful regen ") + sys.exit(1) + if __name__ == "__main__": main() diff --git a/src/wishful/cache/manager.py b/src/wishful/cache/manager.py index cdae695..7421a6e 100644 --- a/src/wishful/cache/manager.py +++ b/src/wishful/cache/manager.py @@ -2,16 +2,19 @@ import shutil from pathlib import Path -from typing import Iterable, List, Optional +from typing import List, Optional from wishful.config import settings def module_path(fullname: str) -> Path: - # Strip leading namespace "wishful" and map dots to directories. + # Strip leading namespace "wishful" (and static/dynamic) and map dots to directories. parts = fullname.split(".") if parts[0] == "wishful": parts = parts[1:] + # Also strip 'static' or 'dynamic' if present + if parts and parts[0] in ("static", "dynamic"): + parts = parts[1:] relative = Path(*parts) if parts else Path("__init__") return settings.cache_dir / relative.with_suffix(".py") diff --git a/src/wishful/config.py b/src/wishful/config.py index 207b84c..da8ee21 100644 --- a/src/wishful/config.py +++ b/src/wishful/config.py @@ -3,6 +3,7 @@ import os from dataclasses import dataclass, field from pathlib import Path +from textwrap import dedent from typing import Optional from dotenv import load_dotenv @@ -13,6 +14,19 @@ _DEFAULT_MODEL = os.getenv("DEFAULT_MODEL", os.getenv("WISHFUL_MODEL", "azure/gpt-4.1")) +_DEFAULT_SYSTEM_PROMPT = os.getenv( + "WISHFUL_SYSTEM_PROMPT", + dedent( + """ + You are a Python code generator. Output ONLY executable Python code. + - Do not wrap code in markdown fences. + - You may use any Python libraries available in the environment. + - Prefer simple, readable implementations. + - Avoid network calls, filesystem writes, subprocess, or shell execution. + - Include docstrings and type hints where helpful. + """ + ).strip(), +) @dataclass @@ -31,6 +45,7 @@ class Settings: spinner: bool = os.getenv("WISHFUL_SPINNER", "1") != "0" max_tokens: int = int(os.getenv("WISHFUL_MAX_TOKENS", "4096")) temperature: float = float(os.getenv("WISHFUL_TEMPERATURE", "1")) + system_prompt: str = _DEFAULT_SYSTEM_PROMPT def copy(self) -> "Settings": return Settings( @@ -42,6 +57,7 @@ def copy(self) -> "Settings": spinner=self.spinner, max_tokens=self.max_tokens, temperature=self.temperature, + system_prompt=self.system_prompt, ) @@ -58,6 +74,7 @@ def configure( spinner: Optional[bool] = None, temperature: Optional[float] = None, max_tokens: Optional[int] = None, + system_prompt: Optional[str] = None, ) -> None: """Update global settings in-place. @@ -65,22 +82,21 @@ def configure( settings. Accepts both strings and :class:`pathlib.Path` for `cache_dir`. """ - if model is not None: - settings.model = model - if cache_dir is not None: - settings.cache_dir = Path(cache_dir) - if review is not None: - settings.review = review - if debug is not None: - settings.debug = debug - if allow_unsafe is not None: - settings.allow_unsafe = allow_unsafe - if spinner is not None: - settings.spinner = spinner - if temperature is not None: - settings.temperature = temperature - if max_tokens is not None: - settings.max_tokens = max_tokens + updates = { + "model": model, + "cache_dir": Path(cache_dir) if cache_dir is not None else None, + "review": review, + "debug": debug, + "allow_unsafe": allow_unsafe, + "spinner": spinner, + "temperature": temperature, + "max_tokens": max_tokens, + "system_prompt": system_prompt, + } + + for attr, value in updates.items(): + if value is not None: + setattr(settings, attr, value) def reset_defaults() -> None: @@ -96,3 +112,4 @@ def reset_defaults() -> None: settings.spinner = defaults.spinner settings.max_tokens = defaults.max_tokens settings.temperature = defaults.temperature + settings.system_prompt = defaults.system_prompt diff --git a/src/wishful/core/discovery.py b/src/wishful/core/discovery.py index d155824..9c9ff08 100644 --- a/src/wishful/core/discovery.py +++ b/src/wishful/core/discovery.py @@ -6,19 +6,34 @@ import os from pathlib import Path from textwrap import dedent -from typing import List, Optional, Sequence, Tuple +from typing import Iterable, List, Sequence + +from wishful.types import get_all_type_schemas, get_output_type_for_function # Default radius for surrounding-context capture; configurable via env + setter. _context_radius = int(os.getenv("WISHFUL_CONTEXT_RADIUS", "3")) class ImportContext: - def __init__(self, functions: Sequence[str], context: str | None): + def __init__( + self, + functions: Sequence[str], + context: str | None, + type_schemas: dict[str, str] | None = None, + function_output_types: dict[str, str] | None = None, + ): self.functions = list(functions) self.context = context + self.type_schemas = type_schemas or {} + self.function_output_types = function_output_types or {} def __repr__(self) -> str: # pragma: no cover - debug helper - return f"ImportContext(functions={self.functions}, context={self.context!r})" + return ( + f"ImportContext(functions={self.functions}, " + f"context={self.context!r}, " + f"type_schemas={list(self.type_schemas.keys())}, " + f"function_output_types={self.function_output_types})" + ) def _gather_context_lines(filename: str, lineno: int, radius: int = 2) -> str: @@ -32,36 +47,90 @@ def _gather_context_lines(filename: str, lineno: int, radius: int = 2) -> str: def _parse_imported_names(source_line: str, fullname: str) -> List[str]: - try: - tree = ast.parse(dedent(source_line)) - except SyntaxError: + tree = _safe_parse_line(source_line) + if tree is None: return [] - names: List[str] = [] - for node in ast.walk(tree): - if isinstance(node, ast.ImportFrom): - if node.module and node.module.startswith("wishful") and fullname.startswith(node.module): - for alias in node.names: - # Use original name (not alias) because that's what the module must define. - names.append(alias.name) - elif isinstance(node, ast.Import): - for alias in node.names: - if alias.name.startswith("wishful") and fullname.startswith(alias.name): - # For `import wishful.foo as bar`, the target is bar (module). - target = alias.asname or alias.name.split(".")[-1] - names.append(target) + names: list[str] = [] + names.extend(_names_from_import_from(tree, fullname)) + names.extend(_names_from_import(tree, fullname)) return names -def discover(fullname: str) -> ImportContext: - """Attempt to recover requested symbol names and nearby comments. +def _safe_parse_line(source_line: str) -> ast.AST | None: + try: + return ast.parse(dedent(source_line)) + except SyntaxError: + return None + + +def _names_from_import_from(tree: ast.AST, fullname: str) -> list[str]: + return [ + alias.name + for node in ast.walk(tree) + if isinstance(node, ast.ImportFrom) + for alias in node.names + if _matches_import_from(node.module, fullname) + ] + + +def _names_from_import(tree: ast.AST, fullname: str) -> list[str]: + matches: list[str] = [] + for node in (n for n in ast.walk(tree) if isinstance(n, ast.Import)): + matches.extend(_alias_targets(node.names, fullname)) + return matches + + +def _alias_targets(aliases: Sequence[ast.alias], fullname: str) -> list[str]: + return [ + alias.asname or alias.name.split(".")[-1] + for alias in aliases + if _matches_import(alias.name, fullname) + ] - This uses stack inspection heuristics. It is best-effort; absence of - signals simply results in empty context. - """ +def _matches_import_from(module: str | None, fullname: str) -> bool: + return bool(module) and module.startswith("wishful") and fullname.startswith(module) + + +def _matches_import(name: str, fullname: str) -> bool: + return name.startswith("wishful") and fullname.startswith(name) + + +def discover(fullname: str, runtime_context: dict | None = None) -> ImportContext: + """Attempt to recover requested symbol names and nearby comments.""" + + for filename, lineno in _iter_relevant_frames(fullname): + code_line = linecache.getline(filename, lineno).strip() + if not code_line: + continue + + functions = _parse_imported_names(code_line, fullname) + if not functions: + continue + + context = _build_context_snippets(filename, lineno, functions) + + # Fetch type information from registry + type_schemas = get_all_type_schemas() + function_output_types = {} + for func in functions: + output_type = get_output_type_for_function(func) + if output_type: + function_output_types[func] = output_type + + return ImportContext( + functions=functions, + context=context, + type_schemas=type_schemas, + function_output_types=function_output_types, + ) + + return ImportContext(functions=[], context=None) + + +def _iter_relevant_frames(fullname: str) -> Iterable[tuple[str, int]]: frame = inspect.currentframe() - # Skip the discover() frame itself. if frame: frame = frame.f_back @@ -69,28 +138,17 @@ def discover(fullname: str) -> ImportContext: filename = frame.f_code.co_filename lineno = frame.f_lineno - if filename.startswith("<"): - frame = frame.f_back - continue - normalized = filename.replace("\\", "/") - if "/src/wishful/" in normalized and "/tests/" not in normalized: - frame = frame.f_back - continue - - code_line = linecache.getline(filename, lineno).strip() - if code_line: - functions = _parse_imported_names(code_line, fullname) - if functions: - context_parts: list[str] = [] - context_parts.append(_gather_context_lines(filename, lineno, radius=_context_radius)) - usage_snippets = _gather_usage_context(filename, functions, radius=_context_radius) - context_parts.extend(usage_snippets) - context = "\n\n".join(part for part in context_parts if part) - return ImportContext(functions=functions, context=context or None) + if _is_user_frame(filename): + yield filename, lineno frame = frame.f_back - return ImportContext(functions=[], context=None) + +def _is_user_frame(filename: str) -> bool: + if filename.startswith("<"): + return False + normalized = filename.replace("\\", "/") + return not ("/src/wishful/" in normalized and "/tests/" not in normalized) def _gather_usage_context(filename: str, functions: Sequence[str], radius: int) -> list[str]: @@ -98,34 +156,50 @@ def _gather_usage_context(filename: str, functions: Sequence[str], radius: int) if not functions: return [] - try: - source = Path(filename).read_text() - except OSError: + tree = _parse_file_safe(filename) + if tree is None: return [] + linenos = _call_site_lines(tree, set(functions)) + return _snippets_from_lines(filename, linenos, radius) + + +def _call_site_lines(tree: ast.AST, targets: set[str]) -> list[int]: + return [ + node.lineno + for node in ast.walk(tree) + if isinstance(node, ast.Call) + if isinstance(node.func, ast.Name) + if node.func.id in targets + ] + + +def _snippets_from_lines(filename: str, linenos: Sequence[int], radius: int) -> list[str]: + snippets = [_gather_context_lines(filename, lineno, radius=radius) for lineno in linenos] + return _dedupe([s for s in snippets if s]) + + +def _parse_file_safe(filename: str) -> ast.AST | None: try: - tree = ast.parse(source) - except SyntaxError: - return [] + return ast.parse(Path(filename).read_text()) + except (OSError, SyntaxError): + return None + - snippets: list[str] = [] - targets = set(functions) +def _build_context_snippets(filename: str, lineno: int, functions: Sequence[str]) -> str | None: + snippets = [_gather_context_lines(filename, lineno, radius=_context_radius)] + snippets += _gather_usage_context(filename, functions, radius=_context_radius) + combined = "\n\n".join(part for part in snippets if part) + return combined or None - for node in ast.walk(tree): - if isinstance(node, ast.Call): - func = node.func - if isinstance(func, ast.Name) and func.id in targets: - snippet = _gather_context_lines(filename, node.lineno, radius=radius) - if snippet: - snippets.append(snippet) - # Deduplicate while preserving order +def _dedupe(items: Sequence[str]) -> list[str]: seen = set() unique: list[str] = [] - for snippet in snippets: - if snippet not in seen: - seen.add(snippet) - unique.append(snippet) + for item in items: + if item not in seen: + seen.add(item) + unique.append(item) return unique diff --git a/src/wishful/core/finder.py b/src/wishful/core/finder.py index 2f82bda..202ac7c 100644 --- a/src/wishful/core/finder.py +++ b/src/wishful/core/finder.py @@ -2,37 +2,65 @@ import importlib.abc import importlib.util -import sys from pathlib import Path -from typing import Optional from wishful.core.loader import MagicLoader, MagicPackageLoader MAGIC_NAMESPACE = "wishful" +STATIC_NAMESPACE = "wishful.static" +DYNAMIC_NAMESPACE = "wishful.dynamic" class MagicFinder(importlib.abc.MetaPathFinder): - """Intercept imports for the `wishful.*` namespace.""" + """Intercept imports for the `wishful.static.*` and `wishful.dynamic.*` namespaces.""" - def find_spec(self, fullname: str, path, target=None): + def find_spec(self, fullname: str, path, target=None): # type: ignore[override] if not fullname.startswith(MAGIC_NAMESPACE): return None - - # Check if this module actually exists on disk as part of our package - # If it does, let the default import mechanism handle it - parts = fullname.split('.') - if len(parts) >= 2: - # Check for our internal package modules - module_file = Path(__file__).parent.parent / parts[1] - if module_file.exists() or (module_file.with_suffix('.py')).exists(): - return None + # Allow internal wishful modules (core, cache, llm, etc.) + if _is_internal_module(fullname): + return None + + # Handle root namespace packages if fullname == MAGIC_NAMESPACE: return importlib.util.spec_from_loader(fullname, MagicPackageLoader(), is_package=True) + if fullname == STATIC_NAMESPACE: + return importlib.util.spec_from_loader(fullname, MagicPackageLoader(), is_package=True) + if fullname == DYNAMIC_NAMESPACE: + return importlib.util.spec_from_loader(fullname, MagicPackageLoader(), is_package=True) + + # Determine if this is a static or dynamic import + is_static = fullname.startswith(STATIC_NAMESPACE + ".") + is_dynamic = fullname.startswith(DYNAMIC_NAMESPACE + ".") + + if is_static: + return importlib.util.spec_from_loader( + fullname, MagicLoader(fullname, mode="static"), is_package=False + ) + elif is_dynamic: + return importlib.util.spec_from_loader( + fullname, MagicLoader(fullname, mode="dynamic"), is_package=False + ) + + # Reject direct wishful.* imports that aren't static/dynamic + return None + - loader = MagicLoader(fullname) - return importlib.util.spec_from_loader(fullname, loader, is_package=False) +def _is_internal_module(fullname: str) -> bool: + """Check if a module is part of the internal wishful package.""" + parts = fullname.split('.') + if len(parts) < 2: + return False + + # Skip static/dynamic namespace checks - they're never internal + if parts[1] in ('static', 'dynamic'): + return False + + # Check if it's a real internal module + module_file = Path(__file__).parent.parent / parts[1] + return module_file.exists() or module_file.with_suffix('.py').exists() def install() -> None: diff --git a/src/wishful/core/loader.py b/src/wishful/core/loader.py index 5870718..459c5c0 100644 --- a/src/wishful/core/loader.py +++ b/src/wishful/core/loader.py @@ -2,9 +2,7 @@ import importlib.abc import importlib.util -import sys from types import ModuleType -from typing import Optional, Sequence from wishful.cache import manager as cache from wishful.config import settings @@ -15,53 +13,41 @@ class MagicLoader(importlib.abc.Loader): - """Loader that returns dynamic modules backed by cache + LLM generation.""" - - def __init__(self, fullname: str): + """Loader that returns dynamic modules backed by cache + LLM generation. + + Supports two modes: + - 'static': Traditional cached behavior (default) + - 'dynamic': Regenerates with runtime context on every access + """ + + def __init__(self, fullname: str, mode: str = "static"): self.fullname = fullname + self.mode = mode # 'static' or 'dynamic' def create_module(self, spec): # pragma: no cover - default works return None def exec_module(self, module: ModuleType) -> None: context = discover(self.fullname) - functions = context.functions - - source = cache.read_cached(self.fullname) - from_cache = source is not None - - if source is None: - source = self._generate_and_cache(functions, context) + source, from_cache = self._load_source(context) self._exec_source(source, module) - - # If cached code is missing requested symbols, regenerate once. - if functions: - missing = [name for name in functions if name not in module.__dict__] - if missing: - if from_cache: - desired = sorted(set(functions) | self._declared_symbols(module)) - cache.delete_cached(self.fullname) - source = self._generate_and_cache(desired, context) - self._exec_source(source, module, clear_first=True) - else: - raise GenerationError( - f"Generated module for {self.fullname} lacks symbols: {', '.join(missing)}" - ) - + self._ensure_symbols(module, context, from_cache) self._attach_dynamic_getattr(module) - - if settings.review: - print(f"Generated code for {self.fullname}:\n{source}\n") - answer = input("Run this code? [y/N]: ") - if answer.lower().strip() not in {"y", "yes"}: - cache.delete_cached(self.fullname) - raise ImportError("User rejected generated code.") + self._maybe_review(source) def _generate_and_cache(self, functions, context): with spinner(f"Generating {self.fullname}"): - source = generate_module_code(self.fullname, functions, context.context) - cache.write_cached(self.fullname, source) + source = generate_module_code( + self.fullname, + functions, + context.context, + type_schemas=context.type_schemas, + function_output_types=context.function_output_types, + ) + # Only write to cache in static mode + if self.mode == "static": + cache.write_cached(self.fullname, source) return source def _exec_source(self, source: str, module: ModuleType, clear_first: bool = False) -> None: @@ -88,17 +74,61 @@ def _dynamic_getattr(name: str): source = self._generate_and_cache(desired, ctx) self._exec_source(source, module, clear_first=True) # Re-attach for future misses after reload - module.__getattr__ = _dynamic_getattr + setattr(module, "__getattr__", _dynamic_getattr) # type: ignore[assignment] if name in module.__dict__: return module.__dict__[name] raise AttributeError(name) - module.__getattr__ = _dynamic_getattr + setattr(module, "__getattr__", _dynamic_getattr) # type: ignore[assignment] @staticmethod def _declared_symbols(module: ModuleType) -> set[str]: return {k for k in module.__dict__ if not k.startswith("__")} + def _load_source(self, context) -> tuple[str, bool]: + # Dynamic mode: always regenerate, never use cache + if self.mode == "dynamic": + return self._generate_and_cache(context.functions, context), False + + # Static mode: use cache if available + source = cache.read_cached(self.fullname) + if source is not None: + return source, True + return self._generate_and_cache(context.functions, context), False + + def _ensure_symbols(self, module: ModuleType, context, from_cache: bool) -> None: + if not context.functions: + return + + missing = self._missing_symbols(module, context.functions) + if not missing: + return + + if not from_cache: + raise GenerationError( + f"Generated module for {self.fullname} lacks symbols: {', '.join(missing)}" + ) + + self._regenerate_with(module, context) + + def _maybe_review(self, source: str) -> None: + if not settings.review: + return + print(f"Generated code for {self.fullname}:\n{source}\n") + answer = input("Run this code? [y/N]: ") + if answer.lower().strip() not in {"y", "yes"}: + cache.delete_cached(self.fullname) + raise ImportError("User rejected generated code.") + + def _missing_symbols(self, module: ModuleType, requested: list[str]) -> list[str]: + return [name for name in requested if name not in module.__dict__] + + def _regenerate_with(self, module: ModuleType, context) -> None: + desired = sorted(set(context.functions) | self._declared_symbols(module)) + cache.delete_cached(self.fullname) + source = self._generate_and_cache(desired, context) + self._exec_source(source, module, clear_first=True) + class MagicPackageLoader(importlib.abc.Loader): """Loader for the root 'wishful' package to enable namespace imports.""" diff --git a/src/wishful/dynamic/__init__.py b/src/wishful/dynamic/__init__.py new file mode 100644 index 0000000..0be75db --- /dev/null +++ b/src/wishful/dynamic/__init__.py @@ -0,0 +1,8 @@ +"""Wishful dynamic namespace - runtime-context-aware code generation. + +Modules imported from wishful.dynamic.* are regenerated on every import, +capturing runtime context and values for context-sensitive code generation. + +Example: + from wishful.dynamic.stories import create_opening +""" diff --git a/src/wishful/dynamic/__init__.pyi b/src/wishful/dynamic/__init__.pyi new file mode 100644 index 0000000..bc3a9ca --- /dev/null +++ b/src/wishful/dynamic/__init__.pyi @@ -0,0 +1,7 @@ +"""Type stub for wishful.dynamic namespace. + +This stub helps IDEs and type checkers understand that wishful.dynamic.* +is a valid namespace for dynamically generated modules. +""" + +def __getattr__(name: str) -> object: ... diff --git a/src/wishful/llm/client.py b/src/wishful/llm/client.py index e33a751..06d5203 100644 --- a/src/wishful/llm/client.py +++ b/src/wishful/llm/client.py @@ -1,7 +1,7 @@ from __future__ import annotations import os -from typing import List, Sequence +from typing import Sequence import litellm @@ -25,15 +25,37 @@ def _fake_response(functions: Sequence[str]) -> str: return "\n\n".join(body) -def generate_module_code(module: str, functions: Sequence[str], context: str | None) -> str: +def generate_module_code( + module: str, + functions: Sequence[str], + context: str | None, + type_schemas: dict[str, str] | None = None, + function_output_types: dict[str, str] | None = None, +) -> str: """Call the LLM (or fake stub) to generate module source code.""" if _FAKE_MODE: return _fake_response(functions) - messages = build_messages(module, functions, context) + response = _call_llm( + module, functions, context, type_schemas, function_output_types + ) + content = _extract_content(response) + return strip_code_fences(content).strip() + + +def _call_llm( + module: str, + functions: Sequence[str], + context: str | None, + type_schemas: dict[str, str] | None = None, + function_output_types: dict[str, str] | None = None, +): + messages = build_messages( + module, functions, context, type_schemas, function_output_types + ) try: - response = litellm.completion( + return litellm.completion( model=settings.model, messages=messages, temperature=settings.temperature, @@ -42,6 +64,8 @@ def generate_module_code(module: str, functions: Sequence[str], context: str | N except Exception as exc: # pragma: no cover - network path not executed in tests raise GenerationError(f"LLM call failed: {exc}") from exc + +def _extract_content(response) -> str: try: content = response["choices"][0]["message"]["content"] except Exception as exc: # pragma: no cover @@ -49,5 +73,4 @@ def generate_module_code(module: str, functions: Sequence[str], context: str | N if not content or not content.strip(): raise GenerationError("LLM returned empty content") - - return strip_code_fences(content).strip() + return content diff --git a/src/wishful/llm/prompts.py b/src/wishful/llm/prompts.py index 076c952..64023e4 100644 --- a/src/wishful/llm/prompts.py +++ b/src/wishful/llm/prompts.py @@ -1,32 +1,52 @@ from __future__ import annotations -from textwrap import dedent -from typing import Iterable, List, Sequence +from typing import List, Sequence +from wishful.config import settings -def build_messages(module: str, functions: Sequence[str], context: str | None) -> List[dict]: - func_list = ", ".join(functions) if functions else "" "module-level helpers" "" + +def build_messages( + module: str, + functions: Sequence[str], + context: str | None, + type_schemas: dict[str, str] | None = None, + function_output_types: dict[str, str] | None = None, +) -> List[dict]: user_parts = [f"Module: {module}"] + + # Include type schemas if available + if type_schemas: + user_parts.append( + "Type definitions to include in the module:\n" + "(Copy these type definitions directly into the generated code. " + "Do NOT import them from other modules.)\n" + ) + for type_name, schema in type_schemas.items(): + user_parts.append(f"\n{schema}\n") + + # Include function signatures with output types if functions: - user_parts.append(f"Functions to implement: {', '.join(functions)}") + if function_output_types: + func_list = [] + for func in functions: + if func in function_output_types: + output_type = function_output_types[func] + func_list.append(f"{func}(...) -> {output_type}") + else: + func_list.append(func) + user_parts.append( + "Functions to implement:\n" + "\n".join(f"- {f}" for f in func_list) + ) + else: + user_parts.append(f"Functions to implement: {', '.join(functions)}") + if context: user_parts.append("Context:\n" + context.strip()) user_prompt = "\n\n".join(user_parts) - system = dedent( - """ - You are a Python code generator. Output ONLY executable Python code. - - Do not wrap code in markdown fences. - - Only use the Python standard library. - - Prefer simple, readable implementations. - - Avoid network, filesystem writes, subprocess, or shell execution. - - Include docstrings and type hints where helpful. - """ - ).strip() - return [ - {"role": "system", "content": system}, + {"role": "system", "content": settings.system_prompt}, {"role": "user", "content": user_prompt}, ] diff --git a/src/wishful/safety/validator.py b/src/wishful/safety/validator.py index 5ad3bb2..2dff75a 100644 --- a/src/wishful/safety/validator.py +++ b/src/wishful/safety/validator.py @@ -1,7 +1,7 @@ from __future__ import annotations import ast -from typing import Iterable, Set +from typing import Iterable class SecurityError(ImportError): @@ -10,15 +10,110 @@ class SecurityError(ImportError): _FORBIDDEN_IMPORTS = {"os", "subprocess", "sys"} _FORBIDDEN_CALLS = {"eval", "exec"} -_FORBIDDEN_FUNCTIONS = {"open"} +_WRITE_MODES = {"w", "a", "+"} -def _collect_names(node: ast.AST) -> Set[str]: - names = set() - for child in ast.walk(node): - if isinstance(child, ast.Name): - names.add(child.id) - return names +def _parse_source(source: str) -> ast.AST: + try: + return ast.parse(source) + except SyntaxError as exc: + raise ImportError(f"Generated code has syntax error: {exc}") from exc + + +def _check_imports(tree: ast.AST) -> None: + _validate_import_names(list(_iter_import_names(tree))) + + +def _iter_import_names(tree: ast.AST): + yield from _import_names(tree) + yield from _importfrom_names(tree) + + +def _import_names(tree: ast.AST): + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + yield alias.name + + +def _importfrom_names(tree: ast.AST): + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom) and node.module: + yield node.module + + +def _validate_import_names(names: Iterable[str]) -> None: + for name in names: + if name.split(".")[0] in _FORBIDDEN_IMPORTS: + raise SecurityError(f"Forbidden import: {name}") + + +def _check_calls(tree: ast.AST) -> None: + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + if isinstance(node.func, ast.Name): + _check_named_call(node.func.id, node) + elif isinstance(node.func, ast.Attribute): + _check_attribute_call(node.func) + + +def _check_named_call(func_name: str, call: ast.Call) -> None: + if func_name in _FORBIDDEN_CALLS: + raise SecurityError(f"Forbidden call: {func_name}()") + if func_name == "open": + _validate_open_call(call) + + +def _check_attribute_call(attr: ast.Attribute) -> None: + dotted = _resolve_attribute_name(attr) + if dotted.startswith("os.") or dotted.startswith("subprocess."): + raise SecurityError(f"Forbidden call: {dotted}()") + + +def _resolve_attribute_name(attr: ast.Attribute) -> str: + parts = [] + current: ast.AST | None = attr + while isinstance(current, ast.Attribute): + parts.append(current.attr) + current = current.value + if isinstance(current, ast.Name): + parts.append(current.id) + return ".".join(reversed(parts)) + + +def _validate_open_call(call: ast.Call) -> None: + mode_arg = _extract_open_mode(call) + if mode_arg and any(ch in mode_arg for ch in _WRITE_MODES): + raise SecurityError("open() in write/append mode is blocked") + + +def _extract_open_mode(call: ast.Call) -> str | None: + positional = _extract_positional_mode(call) + if positional is not None: + return positional + return _extract_keyword_mode(call) + + +def _extract_positional_mode(call: ast.Call) -> str | None: + if len(call.args) > 1 and isinstance(call.args[1], ast.Constant): + return str(call.args[1].value) + return None + + +def _extract_keyword_mode(call: ast.Call) -> str | None: + for kw in call.keywords: + if kw.arg == "mode" and isinstance(kw.value, ast.Constant): + return str(kw.value.value) + return None + + +def _check_forbidden_builtins(tree: ast.AST) -> None: + names = {node.id for node in ast.walk(tree) if isinstance(node, ast.Name)} + forbidden = names & _FORBIDDEN_CALLS + if forbidden: + joined = ", ".join(sorted(forbidden)) + raise SecurityError(f"Forbidden builtins present: {joined}") def validate_code(source: str, *, allow_unsafe: bool = False) -> None: @@ -31,52 +126,7 @@ def validate_code(source: str, *, allow_unsafe: bool = False) -> None: if allow_unsafe: return - try: - tree = ast.parse(source) - except SyntaxError as exc: # surface errors early - raise ImportError(f"Generated code has syntax error: {exc}") from exc - - for node in ast.walk(tree): - if isinstance(node, ast.Import): - for alias in node.names: - if alias.name.split(".")[0] in _FORBIDDEN_IMPORTS: - raise SecurityError(f"Forbidden import: {alias.name}") - elif isinstance(node, ast.ImportFrom): - if node.module and node.module.split(".")[0] in _FORBIDDEN_IMPORTS: - raise SecurityError(f"Forbidden import: {node.module}") - elif isinstance(node, ast.Call): - if isinstance(node.func, ast.Name): - func_name = node.func.id - if func_name in _FORBIDDEN_CALLS: - raise SecurityError(f"Forbidden call: {func_name}()") - if func_name == "open": - # Evaluate mode argument safety (write modes contain 'w', 'a', '+'). - if node.args: - first_arg = node.args[0] - if isinstance(first_arg, ast.Constant) and isinstance(first_arg.value, str): - mode_arg = None - if len(node.args) > 1 and isinstance(node.args[1], ast.Constant): - mode_arg = node.args[1].value - elif node.keywords: - for kw in node.keywords: - if kw.arg == "mode" and isinstance(kw.value, ast.Constant): - mode_arg = kw.value.value - if mode_arg and any(ch in str(mode_arg) for ch in "wa+"): - raise SecurityError("open() in write/append mode is blocked") - if isinstance(node.func, ast.Attribute): - # Block os.system / subprocess.call etc even if imported under alias. - attr_chain = [] - current = node.func - while isinstance(current, ast.Attribute): - attr_chain.append(current.attr) - current = current.value - if isinstance(current, ast.Name): - attr_chain.append(current.id) - dotted = ".".join(reversed(attr_chain)) - if dotted.startswith("os.") or dotted.startswith("subprocess."): - raise SecurityError(f"Forbidden call: {dotted}()") - - # Additional rule: do not allow top-level exec/eval in any alias - names = _collect_names(tree) - if names & _FORBIDDEN_CALLS: - raise SecurityError(f"Forbidden builtins present: {', '.join(names & _FORBIDDEN_CALLS)}") + tree = _parse_source(source) + _check_imports(tree) + _check_calls(tree) + _check_forbidden_builtins(tree) diff --git a/src/wishful/static/__init__.py b/src/wishful/static/__init__.py new file mode 100644 index 0000000..1eb366d --- /dev/null +++ b/src/wishful/static/__init__.py @@ -0,0 +1,8 @@ +"""Wishful static namespace - cached code generation. + +Modules imported from wishful.static.* are generated once, cached to disk, +and reused on subsequent imports for optimal performance. + +Example: + from wishful.static.text import extract_emails +""" diff --git a/src/wishful/static/__init__.pyi b/src/wishful/static/__init__.pyi new file mode 100644 index 0000000..68a7943 --- /dev/null +++ b/src/wishful/static/__init__.pyi @@ -0,0 +1,7 @@ +"""Type stub for wishful.static namespace. + +This stub helps IDEs and type checkers understand that wishful.static.* +is a valid namespace for dynamically generated modules. +""" + +def __getattr__(name: str) -> object: ... diff --git a/src/wishful/types/__init__.py b/src/wishful/types/__init__.py new file mode 100644 index 0000000..797d631 --- /dev/null +++ b/src/wishful/types/__init__.py @@ -0,0 +1,19 @@ +"""Type registry for complex type hints in wishful.""" + +from wishful.types.registry import ( + TypeRegistry, + clear_type_registry, + get_all_type_schemas, + get_output_type_for_function, + get_type_schema, + type, +) + +__all__ = [ + "type", + "TypeRegistry", + "get_type_schema", + "get_all_type_schemas", + "get_output_type_for_function", + "clear_type_registry", +] diff --git a/src/wishful/types/registry.py b/src/wishful/types/registry.py new file mode 100644 index 0000000..e18d7df --- /dev/null +++ b/src/wishful/types/registry.py @@ -0,0 +1,333 @@ +"""Type registration system for wishful. + +Allows users to register complex types (Pydantic models, dataclasses, TypedDict) +that the LLM can use when generating code. +""" + +from __future__ import annotations + +import inspect +from dataclasses import fields as dataclass_fields +from dataclasses import is_dataclass +from typing import Any, Callable, TypeVar, get_type_hints + +T = TypeVar("T") + + +class TypeRegistry: + """Global registry for user-defined types.""" + + def __init__(self): + # Map: type_name -> serialized type definition + self._types: dict[str, str] = {} + # Map: function_name -> type_name (for output_for mapping) + self._function_outputs: dict[str, str] = {} + + def register( + self, type_class: type, *, output_for: str | list[str] | None = None + ) -> None: + """Register a type and optionally associate it with function(s).""" + schema = self._serialize_type(type_class) + self._types[type_class.__name__] = schema + + if output_for: + functions = [output_for] if isinstance(output_for, str) else output_for + for func_name in functions: + self._function_outputs[func_name] = type_class.__name__ + + def get_schema(self, type_name: str) -> str | None: + """Get the serialized schema for a registered type.""" + return self._types.get(type_name) + + def get_all_schemas(self) -> dict[str, str]: + """Get all registered type schemas.""" + return self._types.copy() + + def get_output_type(self, function_name: str) -> str | None: + """Get the registered output type for a function.""" + return self._function_outputs.get(function_name) + + def clear(self) -> None: + """Clear all registered types.""" + self._types.clear() + self._function_outputs.clear() + + def _serialize_type(self, type_class: type) -> str: + """Serialize a type to a string representation for the LLM.""" + # Check if it's a Pydantic model + if self._is_pydantic_model(type_class): + return self._serialize_pydantic(type_class) + + # Check if it's a dataclass + if is_dataclass(type_class): + return self._serialize_dataclass(type_class) + + # Check if it's a TypedDict + if self._is_typed_dict(type_class): + return self._serialize_typed_dict(type_class) + + # Fallback: get source code if available + try: + return inspect.getsource(type_class) + except (OSError, TypeError): + # Last resort: just return the class definition line + return f"class {type_class.__name__}: ..." + + def _is_pydantic_model(self, type_class: type) -> bool: + """Check if a class is a Pydantic BaseModel.""" + try: + # Check if BaseModel is in the MRO or has model_fields + return hasattr(type_class, "model_fields") or any( + "BaseModel" in base.__name__ for base in type_class.__mro__ + ) + except (AttributeError, TypeError): + return False + + def _serialize_pydantic(self, model_class: type) -> str: + """Serialize a Pydantic model to source code.""" + lines = [f"class {model_class.__name__}(BaseModel):"] + + # Add docstring if present + if model_class.__doc__: + lines.append(f' """{model_class.__doc__.strip()}"""') + + # Get model fields + if hasattr(model_class, "model_fields"): + # Pydantic v2 + for field_name, field_info in model_class.model_fields.items(): + annotation = self._format_annotation(field_info.annotation) + + # Check if field is required (has no default) + # Use callable check for robustness with mocks + is_required = field_info.is_required() if callable(getattr(field_info, 'is_required', None)) else (field_info.default is None and field_info.default_factory is None) + + # Check if field has metadata (Field() usage) + has_field_metadata = hasattr(field_info, 'metadata') and field_info.metadata + has_constraints = any( + hasattr(field_info, attr) and getattr(field_info, attr) is not None + for attr in ['description', 'min_length', 'max_length', 'gt', 'ge', 'lt', 'le', 'pattern'] + ) + + if is_required: + if has_field_metadata or has_constraints: + # Build Field() arguments + field_args = self._build_field_args(field_info) + lines.append(f" {field_name}: {annotation} = Field({field_args})") + else: + lines.append(f" {field_name}: {annotation}") + else: + # Field has a default value or default_factory + if field_info.default_factory is not None: + field_args = self._build_field_args(field_info) + if field_args: + lines.append( + f" {field_name}: {annotation} = Field(default_factory=..., {field_args})" + ) + else: + lines.append( + f" {field_name}: {annotation} = Field(default_factory=...)" + ) + else: + # Has a default value + if has_field_metadata or has_constraints: + field_args = self._build_field_args(field_info) + default_repr = repr(field_info.default) + lines.append(f" {field_name}: {annotation} = Field(default={default_repr}, {field_args})") + else: + default_repr = repr(field_info.default) + lines.append(f" {field_name}: {annotation} = {default_repr}") + elif hasattr(model_class, "__fields__"): + # Pydantic v1 + for field_name, field in model_class.__fields__.items(): + annotation = self._format_annotation(field.outer_type_) + if field.required: + lines.append(f" {field_name}: {annotation}") + else: + default_repr = repr(field.default) + lines.append(f" {field_name}: {annotation} = {default_repr}") + + return "\n".join(lines) + + def _build_field_args(self, field_info) -> str: + """Build Field() arguments from field_info metadata and constraints.""" + args = [] + + # Add description + if hasattr(field_info, 'description') and field_info.description: + args.append(f"description={repr(field_info.description)}") + + # Parse metadata for Pydantic v2 constraints + if hasattr(field_info, 'metadata') and field_info.metadata: + for meta_item in field_info.metadata: + meta_class = meta_item.__class__.__name__ + + # Handle common constraint types + if meta_class == 'MinLen' and hasattr(meta_item, 'min_length'): + args.append(f"min_length={meta_item.min_length}") + elif meta_class == 'MaxLen' and hasattr(meta_item, 'max_length'): + args.append(f"max_length={meta_item.max_length}") + elif meta_class == 'Gt' and hasattr(meta_item, 'gt'): + args.append(f"gt={meta_item.gt}") + elif meta_class == 'Ge' and hasattr(meta_item, 'ge'): + args.append(f"ge={meta_item.ge}") + elif meta_class == 'Lt' and hasattr(meta_item, 'lt'): + args.append(f"lt={meta_item.lt}") + elif meta_class == 'Le' and hasattr(meta_item, 'le'): + args.append(f"le={meta_item.le}") + elif meta_class == '_PydanticGeneralMetadata': + # Handle pattern and other general metadata + if hasattr(meta_item, 'pattern') and meta_item.pattern: + args.append(f"pattern={repr(meta_item.pattern)}") + + # Fallback: check direct attributes (Pydantic v1 or custom) + constraints = [ + ('min_length', 'min_length'), + ('max_length', 'max_length'), + ('gt', 'gt'), + ('ge', 'ge'), + ('lt', 'lt'), + ('le', 'le'), + ('pattern', 'pattern'), + ] + + for attr_name, arg_name in constraints: + if hasattr(field_info, attr_name): + value = getattr(field_info, attr_name) + if value is not None and arg_name not in ' '.join(args): # Avoid duplicates + args.append(f"{arg_name}={repr(value)}") + + return ", ".join(args) + + def _serialize_dataclass(self, dc_class: type) -> str: + """Serialize a dataclass to source code.""" + lines = ["@dataclass", f"class {dc_class.__name__}:"] + + # Add docstring if present + if dc_class.__doc__: + lines.append(f' """{dc_class.__doc__.strip()}"""') + + for field in dataclass_fields(dc_class): + annotation = self._format_annotation(field.type) + if field.default is not field.default_factory: # type: ignore + # Has a default value + default_repr = repr(field.default) + lines.append(f" {field.name}: {annotation} = {default_repr}") + elif field.default_factory is not field.default_factory: # type: ignore + lines.append( + f" {field.name}: {annotation} = field(default_factory=...)" + ) + else: + lines.append(f" {field.name}: {annotation}") + + return "\n".join(lines) + + def _is_typed_dict(self, type_class: type) -> bool: + """Check if a class is a TypedDict.""" + try: + return hasattr(type_class, "__annotations__") and hasattr( + type_class, "__total__" + ) + except AttributeError: + return False + + def _serialize_typed_dict(self, td_class: type) -> str: + """Serialize a TypedDict to source code.""" + lines = [f"class {td_class.__name__}(TypedDict):"] + + if td_class.__doc__: + lines.append(f' """{td_class.__doc__.strip()}"""') + + for field_name, field_type in get_type_hints(td_class).items(): + annotation = self._format_annotation(field_type) + lines.append(f" {field_name}: {annotation}") + + return "\n".join(lines) + + def _format_annotation(self, annotation: Any) -> str: + """Format a type annotation as a string.""" + if hasattr(annotation, "__name__"): + return annotation.__name__ + + # Handle typing generics + if hasattr(annotation, "__origin__"): + origin = annotation.__origin__ + args = getattr(annotation, "__args__", ()) + + if origin is list: + return ( + f"list[{self._format_annotation(args[0])}]" if args else "list" + ) + elif origin is dict: + key_type = self._format_annotation(args[0]) if args else "Any" + val_type = self._format_annotation(args[1]) if len(args) > 1 else "Any" + return f"dict[{key_type}, {val_type}]" + elif origin is tuple: + arg_strs = ", ".join(self._format_annotation(a) for a in args) + return f"tuple[{arg_strs}]" + # Handle Union/Optional + elif hasattr(origin, "__name__") and origin.__name__ == "UnionType": + arg_strs = " | ".join(self._format_annotation(a) for a in args) + return arg_strs + + return str(annotation).replace("typing.", "") + + +# Global registry instance +_registry = TypeRegistry() + + +def type( + cls: type[T] | None = None, *, output_for: str | list[str] | None = None +) -> type[T] | Callable[[type[T]], type[T]]: + """Decorator to register a type with wishful. + + Usage: + @wishful.type + class UserProfile(BaseModel): + name: str + email: str + + # Or with output type specification + @wishful.type(output_for='create_user') + class UserProfile(BaseModel): + name: str + email: str + + # Multiple functions + @wishful.type(output_for=['create_user', 'update_user']) + class UserProfile(BaseModel): + name: str + email: str + """ + + def decorator(type_class: type[T]) -> type[T]: + _registry.register(type_class, output_for=output_for) + return type_class + + # Handle both @wishful.type and @wishful.type(...) syntax + if cls is None: + # Called with arguments: @wishful.type(output_for='...') + return decorator + else: + # Called without arguments: @wishful.type + return decorator(cls) + + +def get_type_schema(type_name: str) -> str | None: + """Get the schema for a registered type.""" + return _registry.get_schema(type_name) + + +def get_all_type_schemas() -> dict[str, str]: + """Get all registered type schemas.""" + return _registry.get_all_schemas() + + +def get_output_type_for_function(function_name: str) -> str | None: + """Get the output type registered for a function.""" + return _registry.get_output_type(function_name) + + +def clear_type_registry() -> None: + """Clear all registered types (useful for testing).""" + _registry.clear() diff --git a/tests/test_cache.py b/tests/test_cache.py index 93e01a2..cbba503 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -1,5 +1,3 @@ -from pathlib import Path - from wishful.cache import manager diff --git a/tests/test_cli.py b/tests/test_cli.py index 914845c..e5a478b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,7 +1,6 @@ """Tests for the CLI interface.""" import sys -from io import StringIO import pytest diff --git a/tests/test_config.py b/tests/test_config.py index aefecd1..40bfad5 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,10 +1,7 @@ """Tests for configuration and settings.""" -import os from pathlib import Path -import pytest - from wishful.config import Settings, configure, reset_defaults, settings @@ -18,6 +15,7 @@ def test_default_settings(): assert s.spinner is True assert s.max_tokens == 4096 assert s.temperature == 1 + assert "Python code generator" in s.system_prompt def test_configure_model(): @@ -42,11 +40,13 @@ def test_configure_multiple_settings(): review=True, temperature=0.7, max_tokens=1000, + system_prompt="be concise", ) assert settings.spinner is True assert settings.review is True assert settings.temperature == 0.7 assert settings.max_tokens == 1000 + assert settings.system_prompt == "be concise" # Note: reset_wishful fixture will restore settings after test diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 7f250ee..374c79a 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -1,9 +1,10 @@ """Tests for context discovery and LLM prompt generation.""" -import pytest +from dataclasses import dataclass from wishful.core.discovery import ImportContext, _parse_imported_names, discover from wishful.core import discovery +from wishful.types import type as type_decorator, clear_type_registry def test_parse_imported_names_from_import(): @@ -71,3 +72,150 @@ def test_gather_usage_context_includes_calls(tmp_path): assert snippets, "expected at least one usage snippet" # radius=1 should include the line before and after the call assert any("# note" in s and "# trailing" in s for s in snippets) + + +def test_discover_includes_type_schemas_when_registered(monkeypatch): + """Test that discover includes type schemas from registry.""" + clear_type_registry() + + # Register a type + @type_decorator + @dataclass + class TestType: + """Test type for discovery.""" + name: str + value: int + + # Mock the frame iteration to simulate an import + def mock_iter_frames(fullname): + # Return a fake frame that looks like an import + import tempfile + import os + temp = tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) + try: + temp.write("from wishful.static.test import my_function\n") + temp.flush() + temp.close() + yield (temp.name, 1) + finally: + os.unlink(temp.name) + + monkeypatch.setattr(discovery, "_iter_relevant_frames", mock_iter_frames) + + ctx = discover("wishful.static.test") + + # Should have the registered type schema + assert "TestType" in ctx.type_schemas + assert "Test type for discovery" in ctx.type_schemas["TestType"] + assert "name: str" in ctx.type_schemas["TestType"] + + clear_type_registry() + + +def test_discover_includes_function_output_types(monkeypatch): + """Test that discover maps functions to their output types.""" + clear_type_registry() + + # Register a type with output_for + @type_decorator(output_for="my_function") + @dataclass + class OutputType: + """Output type.""" + result: str + + # Mock the frame iteration + def mock_iter_frames(fullname): + import tempfile + import os + temp = tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) + try: + temp.write("from wishful.static.test import my_function\n") + temp.flush() + temp.close() + yield (temp.name, 1) + finally: + os.unlink(temp.name) + + monkeypatch.setattr(discovery, "_iter_relevant_frames", mock_iter_frames) + + ctx = discover("wishful.static.test") + + # Should map the function to its output type + assert "my_function" in ctx.function_output_types + assert ctx.function_output_types["my_function"] == "OutputType" + + clear_type_registry() + + +def test_discover_handles_multiple_functions_with_types(monkeypatch): + """Test that discover correctly maps multiple functions to types.""" + clear_type_registry() + + # Register types with different output_for settings + @type_decorator(output_for=["func1", "func2"]) + @dataclass + class SharedType: + """Shared type.""" + data: str + + @type_decorator(output_for="func3") + @dataclass + class SpecificType: + """Specific type.""" + value: int + + # Mock importing multiple functions + def mock_iter_frames(fullname): + import tempfile + import os + temp = tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) + try: + temp.write("from wishful.static.test import func1, func2, func3\n") + temp.flush() + temp.close() + yield (temp.name, 1) + finally: + os.unlink(temp.name) + + monkeypatch.setattr(discovery, "_iter_relevant_frames", mock_iter_frames) + + ctx = discover("wishful.static.test") + + # Should have both types + assert "SharedType" in ctx.type_schemas + assert "SpecificType" in ctx.type_schemas + + # Should map functions correctly + assert ctx.function_output_types.get("func1") == "SharedType" + assert ctx.function_output_types.get("func2") == "SharedType" + assert ctx.function_output_types.get("func3") == "SpecificType" + + clear_type_registry() + + +def test_discover_works_without_registered_types(monkeypatch): + """Test that discover still works when no types are registered.""" + clear_type_registry() + + # Mock the frame iteration + def mock_iter_frames(fullname): + import tempfile + import os + temp = tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) + try: + temp.write("from wishful.static.test import some_function\n") + temp.flush() + temp.close() + yield (temp.name, 1) + finally: + os.unlink(temp.name) + + monkeypatch.setattr(discovery, "_iter_relevant_frames", mock_iter_frames) + + ctx = discover("wishful.static.test") + + # Should still work, just with empty type info + assert ctx.type_schemas == {} + assert ctx.function_output_types == {} + assert ctx.functions == ["some_function"] + diff --git a/tests/test_import_hook.py b/tests/test_import_hook.py index c3f88fd..0a6be05 100644 --- a/tests/test_import_hook.py +++ b/tests/test_import_hook.py @@ -1,8 +1,6 @@ import importlib import sys -import pytest - from wishful import regenerate from wishful.cache import manager from wishful.core import loader @@ -18,9 +16,9 @@ def _reset_modules(): def test_generates_and_caches_on_first_import(monkeypatch): call_count = {"n": 0} - def fake_generate(module, functions, context): + def fake_generate(module, functions, context, **kwargs): call_count["n"] += 1 - assert module == "wishful.utils" + assert module == "wishful.static.utils" # ensure discovery passes function names through assert "meaning_of_life" in functions return "def meaning_of_life():\n return 42\n" @@ -30,22 +28,22 @@ def fake_generate(module, functions, context): manager.clear_cache() _reset_modules() - from wishful.utils import meaning_of_life + from wishful.static.utils import meaning_of_life assert meaning_of_life() == 42 assert call_count["n"] == 1 - assert manager.has_cached("wishful.utils") + assert manager.has_cached("wishful.static.utils") def test_reimport_uses_cache(monkeypatch): # First import to populate cache - def first_generate(module, functions, context): + def first_generate(module, functions, context, **kwargs): return "def meaning_of_life():\n return 84\n" monkeypatch.setattr(loader, "generate_module_code", first_generate) manager.clear_cache() _reset_modules() - from wishful.utils import meaning_of_life + from wishful.static.utils import meaning_of_life assert meaning_of_life() == 84 # Second import should not invoke generator even if patched to blow up @@ -54,45 +52,45 @@ def boom(*args, **kwargs): monkeypatch.setattr(loader, "generate_module_code", boom) _reset_modules() - meaning_again = importlib.import_module("wishful.utils").meaning_of_life + meaning_again = importlib.import_module("wishful.static.utils").meaning_of_life assert meaning_again() == 84 def test_regenerate_forces_new_generation(monkeypatch): # Seed cache with one value - def gen_one(module, functions, context): + def gen_one(module, functions, context, **kwargs): return "def answer():\n return 1\n" monkeypatch.setattr(loader, "generate_module_code", gen_one) manager.clear_cache() _reset_modules() - from wishful.values import answer + from wishful.static.values import answer assert answer() == 1 # Change generator and regenerate - def gen_two(module, functions, context): + def gen_two(module, functions, context, **kwargs): return "def answer():\n return 2\n" monkeypatch.setattr(loader, "generate_module_code", gen_two) - regenerate("wishful.values") + regenerate("wishful.static.values") _reset_modules() - from wishful.values import answer as answer_two + from wishful.static.values import answer as answer_two assert answer_two() == 2 def test_missing_symbol_in_cache_triggers_regeneration(monkeypatch): """If cached module lacks requested name, loader regenerates once.""" - manager.write_cached("wishful.broken", "def other():\n return 'nope'\n") + manager.write_cached("wishful.static.broken", "def other():\n return 'nope'\n") - def gen_fixed(module, functions, context): + def gen_fixed(module, functions, context, **kwargs): assert "expect_me" in functions return "def expect_me():\n return 'fixed'\n" monkeypatch.setattr(loader, "generate_module_code", gen_fixed) _reset_modules() - from wishful.broken import expect_me + from wishful.static.broken import expect_me assert expect_me() == "fixed" @@ -100,7 +98,7 @@ def gen_fixed(module, functions, context): def test_dynamic_getattr_generates_additional_functions(monkeypatch): call_count = {"n": 0} - def gen(module, functions, context): + def gen(module, functions, context, **kwargs): call_count["n"] += 1 if call_count["n"] == 1: assert functions == ["foo"] @@ -113,23 +111,23 @@ def gen(module, functions, context): manager.clear_cache() _reset_modules() - from wishful.text import foo + from wishful.static.text import foo assert foo() == "foo" assert call_count["n"] == 1 - from wishful.text import bar + from wishful.static.text import bar assert bar() == "bar" assert call_count["n"] == 2 # Ensure original function still present after regeneration - from wishful.text import foo as foo_again + from wishful.static.text import foo as foo_again assert foo_again() == "foo" def test_multiple_imports_preserve_existing(monkeypatch): call_count = {"n": 0} - def gen(module, functions, context): + def gen(module, functions, context, **kwargs): call_count["n"] += 1 body = [] for name in sorted(set(functions)): @@ -140,15 +138,15 @@ def gen(module, functions, context): manager.clear_cache() _reset_modules() - from wishful.text import first + from wishful.static.text import first assert first() == "first" assert call_count["n"] == 1 - from wishful.text import second + from wishful.static.text import second assert second() == "second" assert call_count["n"] == 2 # Ensure the original function still works without new generation - from wishful.text import first as first_again + from wishful.static.text import first as first_again assert first_again() == "first" assert call_count["n"] == 2 diff --git a/tests/test_llm.py b/tests/test_llm.py index 01842d0..a148cb3 100644 --- a/tests/test_llm.py +++ b/tests/test_llm.py @@ -1,13 +1,8 @@ """Tests for LLM client and prompt generation.""" -import importlib -import os - -import pytest - -from wishful.llm import client -from wishful.llm.client import GenerationError, _fake_response, generate_module_code +from wishful.llm.client import _fake_response from wishful.llm.prompts import build_messages, strip_code_fences +from wishful.config import configure, reset_defaults def test_build_messages_basic(): @@ -28,6 +23,51 @@ def test_build_messages_with_context(): assert context in messages[1]["content"] +def test_build_messages_custom_system_prompt(): + """System prompt should come from settings/configure.""" + custom = "Custom system prompt for tests." + configure(system_prompt=custom) + messages = build_messages("wishful.data", ["parse_json"], None) + assert messages[0]["content"] == custom + reset_defaults() + + +def test_build_messages_with_type_schemas(): + """Test building messages with type schemas.""" + type_schemas = { + "UserProfile": "class UserProfile(BaseModel):\n name: str\n email: str" + } + messages = build_messages("wishful.users", ["create_user"], None, type_schemas=type_schemas) + assert "UserProfile" in messages[1]["content"] + assert "BaseModel" in messages[1]["content"] + assert "Type definitions to include" in messages[1]["content"] + assert "Do NOT import them" in messages[1]["content"] + + +def test_build_messages_with_function_output_types(): + """Test building messages with function output types.""" + function_output_types = {"create_user": "UserProfile"} + messages = build_messages("wishful.users", ["create_user"], None, function_output_types=function_output_types) + assert "create_user(...) -> UserProfile" in messages[1]["content"] + + +def test_build_messages_with_both_types_and_outputs(): + """Test building messages with both type schemas and output types.""" + type_schemas = { + "UserProfile": "class UserProfile(BaseModel):\n name: str" + } + function_output_types = {"create_user": "UserProfile"} + messages = build_messages( + "wishful.users", + ["create_user"], + None, + type_schemas=type_schemas, + function_output_types=function_output_types + ) + assert "UserProfile" in messages[1]["content"] + assert "create_user(...) -> UserProfile" in messages[1]["content"] + + def test_strip_code_fences_with_fences(): """Test stripping markdown code fences.""" text = "```python\ndef foo():\n pass\n```" diff --git a/tests/test_namespaces.py b/tests/test_namespaces.py new file mode 100644 index 0000000..12c8c76 --- /dev/null +++ b/tests/test_namespaces.py @@ -0,0 +1,134 @@ +"""Tests for static vs dynamic namespace behavior.""" + +import sys + +from wishful import regenerate +from wishful.cache import manager +from wishful.core import loader +from wishful.core.finder import STATIC_NAMESPACE, DYNAMIC_NAMESPACE +from wishful.cache.manager import module_path + + +def _reset_modules(): + for name in list(sys.modules): + if name.startswith("wishful.") or name == "wishful": + sys.modules.pop(name, None) + import importlib + importlib.invalidate_caches() + + +def test_static_uses_cache(monkeypatch): + """Static imports should cache and reuse generated code.""" + call_count = {"n": 0} + + def fake_generate(module, functions, context, **kwargs): + call_count["n"] += 1 + return "def test_func():\n return 'generated'\n" + + monkeypatch.setattr(loader, "generate_module_code", fake_generate) + + manager.clear_cache() + _reset_modules() + + # First import generates + from wishful.static.cached import test_func + assert test_func() == "generated" + assert call_count["n"] == 1 + + # Second import uses cache (shouldn't call generate again) + _reset_modules() + def boom(*args, **kwargs): + raise AssertionError("Should not generate - cache exists!") + + monkeypatch.setattr(loader, "generate_module_code", boom) + from wishful.static.cached import test_func as test_func2 + assert test_func2() == "generated" + assert call_count["n"] == 1 # Still 1 - didn't regenerate + + +def test_dynamic_skips_cache(monkeypatch): + """Dynamic imports should regenerate every time, never use cache.""" + call_count = {"n": 0} + + def fake_generate(module, functions, context, **kwargs): + call_count["n"] += 1 + return f"def test_func():\n return 'gen_{call_count['n']}'\n" + + monkeypatch.setattr(loader, "generate_module_code", fake_generate) + + manager.clear_cache() + _reset_modules() + + # First import generates + from wishful.dynamic.nocache import test_func + result1 = test_func() + assert "gen_1" in result1 + assert call_count["n"] == 1 + + # Second import regenerates (even though cache exists) + _reset_modules() + from wishful.dynamic.nocache import test_func as test_func2 + result2 = test_func2() + assert "gen_2" in result2 + assert call_count["n"] == 2 # Regenerated! + + +def test_static_and_dynamic_independent_caches(monkeypatch): + """Static and dynamic with same module name should have independent behavior.""" + + def fake_generate(module, functions, context, **kwargs): + if "static" in module: + return "def get_value():\n return 'static'\n" + else: + return "def get_value():\n return 'dynamic'\n" + + monkeypatch.setattr(loader, "generate_module_code", fake_generate) + + manager.clear_cache() + _reset_modules() + + # Import both static and dynamic versions of "data" module + from wishful.static.data import get_value as static_get + from wishful.dynamic.data import get_value as dynamic_get + + assert static_get() == "static" + assert dynamic_get() == "dynamic" + + +def test_namespace_isolation(): + """Ensure wishful.* internal modules are protected.""" + # The important test is that wishful.static/dynamic don't conflict + # with internal wishful.cache, wishful.config, etc. + # This is validated in the finder's _is_internal_module check. + + # Just ensure static/dynamic work (internal isolation is implicit) + assert STATIC_NAMESPACE == "wishful.static" + assert DYNAMIC_NAMESPACE == "wishful.dynamic" + + +def test_regenerate_defaults_to_static(): + """The regenerate() function should default to static namespace.""" + # Write a cached static module + manager.write_cached("wishful.static.test", "def foo(): return 1") + assert manager.has_cached("wishful.static.test") + + # Regenerate without prefix should work + regenerate("test") + assert not manager.has_cached("wishful.static.test") + + # Clean up + manager.clear_cache() + + +def test_cache_path_strips_namespaces(): + """Cache paths should strip static/dynamic prefixes.""" + # All these should map to the same cache file + path1 = module_path("wishful.static.utils") + path2 = module_path("wishful.dynamic.utils") + path3 = module_path("wishful.utils") # Legacy + + # They should all resolve to utils.py (without static/dynamic) + assert path1.name == "utils.py" + assert path2.name == "utils.py" + assert path3.name == "utils.py" + assert path1 == path2 == path3 diff --git a/tests/test_types.py b/tests/test_types.py new file mode 100644 index 0000000..2eb572e --- /dev/null +++ b/tests/test_types.py @@ -0,0 +1,440 @@ +"""Tests for the type registration system.""" + +from dataclasses import dataclass +from typing import TypedDict + +import pytest + +import wishful +from wishful.types import ( + TypeRegistry, + clear_type_registry, + get_all_type_schemas, + get_output_type_for_function, + get_type_schema, + type as type_decorator, +) + + +# Test fixtures - various type definitions +class SimpleTypedDict(TypedDict): + name: str + age: int + + +@dataclass +class SimpleDataclass: + """A simple dataclass for testing.""" + name: str + email: str + age: int = 0 + + +# Mock Pydantic for testing (in case it's not installed) +class MockBaseModel: + """Mock BaseModel for testing without requiring pydantic.""" + + model_fields = {} + + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + # Store annotations as model_fields + annotations = getattr(cls, '__annotations__', {}) + cls.model_fields = { + name: type( + 'MockFieldInfo', + (), + { + 'annotation': ann, + 'default': getattr(cls, name, None), + 'default_factory': None, + 'is_required': lambda self=None: getattr(cls, name, None) is None, + } + )() + for name, ann in annotations.items() + } + + +class UserProfile(MockBaseModel): + """User profile model.""" + name: str + email: str + age: int + + +class Product(MockBaseModel): + title: str + price: float + in_stock: bool + + +@pytest.fixture(autouse=True) +def clean_registry(): + """Clear the type registry before each test.""" + clear_type_registry() + yield + clear_type_registry() + + +class TestTypeRegistry: + """Test the TypeRegistry class.""" + + def test_registry_initialization(self): + """Test that a new registry is empty.""" + registry = TypeRegistry() + assert registry.get_all_schemas() == {} + assert registry.get_output_type("any_function") is None + + def test_register_simple_class(self): + """Test registering a simple class.""" + registry = TypeRegistry() + + class SimpleClass: + pass + + registry.register(SimpleClass) + assert "SimpleClass" in registry.get_all_schemas() + + def test_register_with_output_for_single(self): + """Test registering a type with a single output_for function.""" + registry = TypeRegistry() + registry.register(UserProfile, output_for="create_user") + + assert registry.get_output_type("create_user") == "UserProfile" + assert registry.get_output_type("other_function") is None + + def test_register_with_output_for_multiple(self): + """Test registering a type with multiple output_for functions.""" + registry = TypeRegistry() + registry.register(UserProfile, output_for=["create_user", "update_user", "get_user"]) + + assert registry.get_output_type("create_user") == "UserProfile" + assert registry.get_output_type("update_user") == "UserProfile" + assert registry.get_output_type("get_user") == "UserProfile" + assert registry.get_output_type("delete_user") is None + + def test_serialize_mock_pydantic(self): + """Test serialization of mock Pydantic models.""" + registry = TypeRegistry() + registry.register(UserProfile) + + schema = registry.get_schema("UserProfile") + assert schema is not None + assert "class UserProfile" in schema + assert "name: str" in schema + assert "email: str" in schema + assert "age: int" in schema + + def test_serialize_dataclass(self): + """Test serialization of dataclasses.""" + registry = TypeRegistry() + registry.register(SimpleDataclass) + + schema = registry.get_schema("SimpleDataclass") + assert schema is not None + assert "@dataclass" in schema + assert "class SimpleDataclass:" in schema + assert "name: str" in schema + assert "email: str" in schema + assert "age: int = 0" in schema + + def test_serialize_typed_dict(self): + """Test serialization of TypedDict.""" + registry = TypeRegistry() + registry.register(SimpleTypedDict) + + schema = registry.get_schema("SimpleTypedDict") + assert schema is not None + assert "class SimpleTypedDict" in schema + assert "name: str" in schema + assert "age: int" in schema + + def test_clear_registry(self): + """Test clearing the registry.""" + registry = TypeRegistry() + registry.register(UserProfile, output_for="create_user") + + assert len(registry.get_all_schemas()) > 0 + assert registry.get_output_type("create_user") is not None + + registry.clear() + + assert registry.get_all_schemas() == {} + assert registry.get_output_type("create_user") is None + + +class TestTypeDecorator: + """Test the @wishful.type decorator.""" + + def test_decorator_without_args(self): + """Test @wishful.type without arguments.""" + + @type_decorator + class TestModel(MockBaseModel): + field: str + + # Type should be registered + assert get_type_schema("TestModel") is not None + assert "TestModel" in get_all_type_schemas() + + def test_decorator_with_output_for_single(self): + """Test @wishful.type(output_for='function').""" + + @type_decorator(output_for="process_data") + class DataModel(MockBaseModel): + value: int + + assert get_type_schema("DataModel") is not None + assert get_output_type_for_function("process_data") == "DataModel" + + def test_decorator_with_output_for_multiple(self): + """Test @wishful.type(output_for=['func1', 'func2']).""" + + @type_decorator(output_for=["func1", "func2"]) + class MultiModel(MockBaseModel): + data: str + + assert get_output_type_for_function("func1") == "MultiModel" + assert get_output_type_for_function("func2") == "MultiModel" + + def test_decorator_preserves_class(self): + """Test that the decorator returns the original class.""" + + @type_decorator + class OriginalClass(MockBaseModel): + value: str + + # Should be able to instantiate and use the class normally + assert OriginalClass.__name__ == "OriginalClass" + assert hasattr(OriginalClass, 'model_fields') + + +class TestGlobalRegistryFunctions: + """Test the global registry helper functions.""" + + def test_get_type_schema(self): + """Test getting a type schema.""" + + @type_decorator + class TestType(MockBaseModel): + x: int + + schema = get_type_schema("TestType") + assert schema is not None + assert "TestType" in schema + + def test_get_type_schema_missing(self): + """Test getting a non-existent type schema.""" + assert get_type_schema("NonExistent") is None + + def test_get_all_type_schemas(self): + """Test getting all type schemas.""" + + @type_decorator + class Type1(MockBaseModel): + a: str + + @type_decorator + class Type2(MockBaseModel): + b: int + + schemas = get_all_type_schemas() + assert "Type1" in schemas + assert "Type2" in schemas + assert len(schemas) >= 2 + + def test_get_output_type_for_function(self): + """Test getting output type for a function.""" + + @type_decorator(output_for="my_function") + class OutputType(MockBaseModel): + result: str + + assert get_output_type_for_function("my_function") == "OutputType" + assert get_output_type_for_function("other_function") is None + + def test_clear_type_registry(self): + """Test clearing the global registry.""" + + @type_decorator + class SomeType(MockBaseModel): + field: str + + assert len(get_all_type_schemas()) > 0 + + clear_type_registry() + + assert get_all_type_schemas() == {} + + +class TestComplexAnnotations: + """Test handling of complex type annotations.""" + + def test_list_annotation(self): + """Test serialization with list annotations.""" + + @dataclass + class ListModel: + items: list[str] + numbers: list[int] + + registry = TypeRegistry() + registry.register(ListModel) + + schema = registry.get_schema("ListModel") + assert "list[str]" in schema or "list" in schema + + def test_dict_annotation(self): + """Test serialization with dict annotations.""" + + @dataclass + class DictModel: + mapping: dict[str, int] + + registry = TypeRegistry() + registry.register(DictModel) + + schema = registry.get_schema("DictModel") + assert "dict" in schema + + def test_optional_annotation(self): + """Test serialization with Optional/Union annotations.""" + + @dataclass + class OptionalModel: + name: str | None = None + + registry = TypeRegistry() + registry.register(OptionalModel) + + schema = registry.get_schema("OptionalModel") + assert schema is not None + + +class TestIntegrationWithWishful: + """Test integration with the main wishful module.""" + + def test_type_exported_from_wishful(self): + """Test that 'type' is exported from wishful.""" + assert hasattr(wishful, 'type') + + def test_decorator_via_wishful(self): + """Test using @wishful.type.""" + + @wishful.type + class WishfulModel(MockBaseModel): + field: str + + # Should be registered + assert get_type_schema("WishfulModel") is not None + + def test_decorator_with_args_via_wishful(self): + """Test using @wishful.type(output_for=...).""" + + @wishful.type(output_for="generate_thing") + class ThingModel(MockBaseModel): + thing: str + + assert get_output_type_for_function("generate_thing") == "ThingModel" + + +class TestEdgeCases: + """Test edge cases and error handling.""" + + def test_register_same_type_twice(self): + """Test registering the same type multiple times.""" + + @type_decorator + class DuplicateType(MockBaseModel): + value: str + + # Register again with different output_for + @type_decorator(output_for="func1") + class DuplicateType(MockBaseModel): + value: str + + # Should not raise, last registration wins + assert get_output_type_for_function("func1") == "DuplicateType" + + def test_empty_class(self): + """Test registering an empty class.""" + + @type_decorator + class EmptyClass: + pass + + schema = get_type_schema("EmptyClass") + assert schema is not None + + def test_class_with_docstring(self): + """Test that docstrings are preserved in serialization.""" + + @dataclass + class DocumentedClass: + """This class has documentation.""" + field: str + + registry = TypeRegistry() + registry.register(DocumentedClass) + + schema = registry.get_schema("DocumentedClass") + assert "This class has documentation" in schema + + +class TestSerializationEdgeCases: + """Test edge cases in type serialization.""" + + def test_pydantic_with_default_values(self): + """Test Pydantic models with default values.""" + + class ModelWithDefaults(MockBaseModel): + name: str + count: int + + # Add defaults to the mock + ModelWithDefaults.model_fields['count'].default = 0 + + registry = TypeRegistry() + registry.register(ModelWithDefaults) + schema = registry.get_schema("ModelWithDefaults") + assert "count" in schema + + def test_format_annotation_with_union(self): + """Test formatting Union type annotations.""" + from typing import Union + + registry = TypeRegistry() + # Test the _format_annotation method directly + result = registry._format_annotation(Union[str, int]) + # Should return a string representation + assert isinstance(result, str) + # Union formatting varies by Python version, just check it's not empty + assert len(result) > 0 + + def test_serialize_class_with_source_available(self): + """Test serialization when source code is available.""" + + # Define a simple class that inspect.getsource can read + @type_decorator + class SourceAvailableClass: + """A class with accessible source.""" + x: int + y: str + + schema = get_type_schema("SourceAvailableClass") + assert schema is not None + # Should contain the class name + assert "SourceAvailableClass" in schema + + def test_typed_dict_without_docstring(self): + """Test TypedDict serialization without docstring.""" + + class SimpleDict(TypedDict): + key: str + value: int + + registry = TypeRegistry() + registry.register(SimpleDict) + schema = registry.get_schema("SimpleDict") + assert "key: str" in schema + assert "value: int" in schema diff --git a/uv.lock b/uv.lock index edad282..e3fad93 100644 --- a/uv.lock +++ b/uv.lock @@ -141,6 +141,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, ] +[[package]] +name = "bandit" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "stevedore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/d5/82fc87a82ad9536215c1b5693bbb675439f6f2d0c2fca74b2df2cb9db925/bandit-1.9.1.tar.gz", hash = "sha256:6dbafd1a51e276e065404f06980d624bad142344daeac3b085121fcfd117b7cf", size = 4241552, upload-time = "2025-11-18T00:06:06.043Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/82/249a7710242b7a05f7f4245a0da3cdd4042e4377f5d00059619fa2b941f3/bandit-1.9.1-py3-none-any.whl", hash = "sha256:0a1f34c04f067ee28985b7854edaa659c9299bd71e1b7e18236e46cccc79720b", size = 134216, upload-time = "2025-11-18T00:06:04.645Z" }, +] + [[package]] name = "certifi" version = "2025.11.12" @@ -228,6 +243,80 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "coverage" +version = "7.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/89/26/4a96807b193b011588099c3b5c89fbb05294e5b90e71018e065465f34eb6/coverage-7.12.0.tar.gz", hash = "sha256:fc11e0a4e372cb5f282f16ef90d4a585034050ccda536451901abfb19a57f40c", size = 819341, upload-time = "2025-11-18T13:34:20.766Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/bf/638c0427c0f0d47638242e2438127f3c8ee3cfc06c7fdeb16778ed47f836/coverage-7.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:29644c928772c78512b48e14156b81255000dcfd4817574ff69def189bcb3647", size = 217704, upload-time = "2025-11-18T13:32:28.906Z" }, + { url = "https://files.pythonhosted.org/packages/08/e1/706fae6692a66c2d6b871a608bbde0da6281903fa0e9f53a39ed441da36a/coverage-7.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8638cbb002eaa5d7c8d04da667813ce1067080b9a91099801a0053086e52b736", size = 218064, upload-time = "2025-11-18T13:32:30.161Z" }, + { url = "https://files.pythonhosted.org/packages/a9/8b/eb0231d0540f8af3ffda39720ff43cb91926489d01524e68f60e961366e4/coverage-7.12.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:083631eeff5eb9992c923e14b810a179798bb598e6a0dd60586819fc23be6e60", size = 249560, upload-time = "2025-11-18T13:32:31.835Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a1/67fb52af642e974d159b5b379e4d4c59d0ebe1288677fbd04bbffe665a82/coverage-7.12.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:99d5415c73ca12d558e07776bd957c4222c687b9f1d26fa0e1b57e3598bdcde8", size = 252318, upload-time = "2025-11-18T13:32:33.178Z" }, + { url = "https://files.pythonhosted.org/packages/41/e5/38228f31b2c7665ebf9bdfdddd7a184d56450755c7e43ac721c11a4b8dab/coverage-7.12.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e949ebf60c717c3df63adb4a1a366c096c8d7fd8472608cd09359e1bd48ef59f", size = 253403, upload-time = "2025-11-18T13:32:34.45Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4b/df78e4c8188f9960684267c5a4897836f3f0f20a20c51606ee778a1d9749/coverage-7.12.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d907ddccbca819afa2cd014bc69983b146cca2735a0b1e6259b2a6c10be1e70", size = 249984, upload-time = "2025-11-18T13:32:35.747Z" }, + { url = "https://files.pythonhosted.org/packages/ba/51/bb163933d195a345c6f63eab9e55743413d064c291b6220df754075c2769/coverage-7.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b1518ecbad4e6173f4c6e6c4a46e49555ea5679bf3feda5edb1b935c7c44e8a0", size = 251339, upload-time = "2025-11-18T13:32:37.352Z" }, + { url = "https://files.pythonhosted.org/packages/15/40/c9b29cdb8412c837cdcbc2cfa054547dd83affe6cbbd4ce4fdb92b6ba7d1/coverage-7.12.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:51777647a749abdf6f6fd8c7cffab12de68ab93aab15efc72fbbb83036c2a068", size = 249489, upload-time = "2025-11-18T13:32:39.212Z" }, + { url = "https://files.pythonhosted.org/packages/c8/da/b3131e20ba07a0de4437a50ef3b47840dfabf9293675b0cd5c2c7f66dd61/coverage-7.12.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:42435d46d6461a3b305cdfcad7cdd3248787771f53fe18305548cba474e6523b", size = 249070, upload-time = "2025-11-18T13:32:40.598Z" }, + { url = "https://files.pythonhosted.org/packages/70/81/b653329b5f6302c08d683ceff6785bc60a34be9ae92a5c7b63ee7ee7acec/coverage-7.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5bcead88c8423e1855e64b8057d0544e33e4080b95b240c2a355334bb7ced937", size = 250929, upload-time = "2025-11-18T13:32:42.915Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/250ac3bca9f252a5fb1338b5ad01331ebb7b40223f72bef5b1b2cb03aa64/coverage-7.12.0-cp312-cp312-win32.whl", hash = "sha256:dcbb630ab034e86d2a0f79aefd2be07e583202f41e037602d438c80044957baa", size = 220241, upload-time = "2025-11-18T13:32:44.665Z" }, + { url = "https://files.pythonhosted.org/packages/64/1c/77e79e76d37ce83302f6c21980b45e09f8aa4551965213a10e62d71ce0ab/coverage-7.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:2fd8354ed5d69775ac42986a691fbf68b4084278710cee9d7c3eaa0c28fa982a", size = 221051, upload-time = "2025-11-18T13:32:46.008Z" }, + { url = "https://files.pythonhosted.org/packages/31/f5/641b8a25baae564f9e52cac0e2667b123de961985709a004e287ee7663cc/coverage-7.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:737c3814903be30695b2de20d22bcc5428fdae305c61ba44cdc8b3252984c49c", size = 219692, upload-time = "2025-11-18T13:32:47.372Z" }, + { url = "https://files.pythonhosted.org/packages/b8/14/771700b4048774e48d2c54ed0c674273702713c9ee7acdfede40c2666747/coverage-7.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47324fffca8d8eae7e185b5bb20c14645f23350f870c1649003618ea91a78941", size = 217725, upload-time = "2025-11-18T13:32:49.22Z" }, + { url = "https://files.pythonhosted.org/packages/17/a7/3aa4144d3bcb719bf67b22d2d51c2d577bf801498c13cb08f64173e80497/coverage-7.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ccf3b2ede91decd2fb53ec73c1f949c3e034129d1e0b07798ff1d02ea0c8fa4a", size = 218098, upload-time = "2025-11-18T13:32:50.78Z" }, + { url = "https://files.pythonhosted.org/packages/fc/9c/b846bbc774ff81091a12a10203e70562c91ae71badda00c5ae5b613527b1/coverage-7.12.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b365adc70a6936c6b0582dc38746b33b2454148c02349345412c6e743efb646d", size = 249093, upload-time = "2025-11-18T13:32:52.554Z" }, + { url = "https://files.pythonhosted.org/packages/76/b6/67d7c0e1f400b32c883e9342de4a8c2ae7c1a0b57c5de87622b7262e2309/coverage-7.12.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bc13baf85cd8a4cfcf4a35c7bc9d795837ad809775f782f697bf630b7e200211", size = 251686, upload-time = "2025-11-18T13:32:54.862Z" }, + { url = "https://files.pythonhosted.org/packages/cc/75/b095bd4b39d49c3be4bffbb3135fea18a99a431c52dd7513637c0762fecb/coverage-7.12.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:099d11698385d572ceafb3288a5b80fe1fc58bf665b3f9d362389de488361d3d", size = 252930, upload-time = "2025-11-18T13:32:56.417Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f3/466f63015c7c80550bead3093aacabf5380c1220a2a93c35d374cae8f762/coverage-7.12.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:473dc45d69694069adb7680c405fb1e81f60b2aff42c81e2f2c3feaf544d878c", size = 249296, upload-time = "2025-11-18T13:32:58.074Z" }, + { url = "https://files.pythonhosted.org/packages/27/86/eba2209bf2b7e28c68698fc13437519a295b2d228ba9e0ec91673e09fa92/coverage-7.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:583f9adbefd278e9de33c33d6846aa8f5d164fa49b47144180a0e037f0688bb9", size = 251068, upload-time = "2025-11-18T13:32:59.646Z" }, + { url = "https://files.pythonhosted.org/packages/ec/55/ca8ae7dbba962a3351f18940b359b94c6bafdd7757945fdc79ec9e452dc7/coverage-7.12.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b2089cc445f2dc0af6f801f0d1355c025b76c24481935303cf1af28f636688f0", size = 249034, upload-time = "2025-11-18T13:33:01.481Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d7/39136149325cad92d420b023b5fd900dabdd1c3a0d1d5f148ef4a8cedef5/coverage-7.12.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:950411f1eb5d579999c5f66c62a40961f126fc71e5e14419f004471957b51508", size = 248853, upload-time = "2025-11-18T13:33:02.935Z" }, + { url = "https://files.pythonhosted.org/packages/fe/b6/76e1add8b87ef60e00643b0b7f8f7bb73d4bf5249a3be19ebefc5793dd25/coverage-7.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b1aab7302a87bafebfe76b12af681b56ff446dc6f32ed178ff9c092ca776e6bc", size = 250619, upload-time = "2025-11-18T13:33:04.336Z" }, + { url = "https://files.pythonhosted.org/packages/95/87/924c6dc64f9203f7a3c1832a6a0eee5a8335dbe5f1bdadcc278d6f1b4d74/coverage-7.12.0-cp313-cp313-win32.whl", hash = "sha256:d7e0d0303c13b54db495eb636bc2465b2fb8475d4c8bcec8fe4b5ca454dfbae8", size = 220261, upload-time = "2025-11-18T13:33:06.493Z" }, + { url = "https://files.pythonhosted.org/packages/91/77/dd4aff9af16ff776bf355a24d87eeb48fc6acde54c907cc1ea89b14a8804/coverage-7.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:ce61969812d6a98a981d147d9ac583a36ac7db7766f2e64a9d4d059c2fe29d07", size = 221072, upload-time = "2025-11-18T13:33:07.926Z" }, + { url = "https://files.pythonhosted.org/packages/70/49/5c9dc46205fef31b1b226a6e16513193715290584317fd4df91cdaf28b22/coverage-7.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:bcec6f47e4cb8a4c2dc91ce507f6eefc6a1b10f58df32cdc61dff65455031dfc", size = 219702, upload-time = "2025-11-18T13:33:09.631Z" }, + { url = "https://files.pythonhosted.org/packages/9b/62/f87922641c7198667994dd472a91e1d9b829c95d6c29529ceb52132436ad/coverage-7.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:459443346509476170d553035e4a3eed7b860f4fe5242f02de1010501956ce87", size = 218420, upload-time = "2025-11-18T13:33:11.153Z" }, + { url = "https://files.pythonhosted.org/packages/85/dd/1cc13b2395ef15dbb27d7370a2509b4aee77890a464fb35d72d428f84871/coverage-7.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:04a79245ab2b7a61688958f7a855275997134bc84f4a03bc240cf64ff132abf6", size = 218773, upload-time = "2025-11-18T13:33:12.569Z" }, + { url = "https://files.pythonhosted.org/packages/74/40/35773cc4bb1e9d4658d4fb669eb4195b3151bef3bbd6f866aba5cd5dac82/coverage-7.12.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:09a86acaaa8455f13d6a99221d9654df249b33937b4e212b4e5a822065f12aa7", size = 260078, upload-time = "2025-11-18T13:33:14.037Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ee/231bb1a6ffc2905e396557585ebc6bdc559e7c66708376d245a1f1d330fc/coverage-7.12.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:907e0df1b71ba77463687a74149c6122c3f6aac56c2510a5d906b2f368208560", size = 262144, upload-time = "2025-11-18T13:33:15.601Z" }, + { url = "https://files.pythonhosted.org/packages/28/be/32f4aa9f3bf0b56f3971001b56508352c7753915345d45fab4296a986f01/coverage-7.12.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b57e2d0ddd5f0582bae5437c04ee71c46cd908e7bc5d4d0391f9a41e812dd12", size = 264574, upload-time = "2025-11-18T13:33:17.354Z" }, + { url = "https://files.pythonhosted.org/packages/68/7c/00489fcbc2245d13ab12189b977e0cf06ff3351cb98bc6beba8bd68c5902/coverage-7.12.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58c1c6aa677f3a1411fe6fb28ec3a942e4f665df036a3608816e0847fad23296", size = 259298, upload-time = "2025-11-18T13:33:18.958Z" }, + { url = "https://files.pythonhosted.org/packages/96/b4/f0760d65d56c3bea95b449e02570d4abd2549dc784bf39a2d4721a2d8ceb/coverage-7.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4c589361263ab2953e3c4cd2a94db94c4ad4a8e572776ecfbad2389c626e4507", size = 262150, upload-time = "2025-11-18T13:33:20.644Z" }, + { url = "https://files.pythonhosted.org/packages/c5/71/9a9314df00f9326d78c1e5a910f520d599205907432d90d1c1b7a97aa4b1/coverage-7.12.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:91b810a163ccad2e43b1faa11d70d3cf4b6f3d83f9fd5f2df82a32d47b648e0d", size = 259763, upload-time = "2025-11-18T13:33:22.189Z" }, + { url = "https://files.pythonhosted.org/packages/10/34/01a0aceed13fbdf925876b9a15d50862eb8845454301fe3cdd1df08b2182/coverage-7.12.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:40c867af715f22592e0d0fb533a33a71ec9e0f73a6945f722a0c85c8c1cbe3a2", size = 258653, upload-time = "2025-11-18T13:33:24.239Z" }, + { url = "https://files.pythonhosted.org/packages/8d/04/81d8fd64928acf1574bbb0181f66901c6c1c6279c8ccf5f84259d2c68ae9/coverage-7.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:68b0d0a2d84f333de875666259dadf28cc67858bc8fd8b3f1eae84d3c2bec455", size = 260856, upload-time = "2025-11-18T13:33:26.365Z" }, + { url = "https://files.pythonhosted.org/packages/f2/76/fa2a37bfaeaf1f766a2d2360a25a5297d4fb567098112f6517475eee120b/coverage-7.12.0-cp313-cp313t-win32.whl", hash = "sha256:73f9e7fbd51a221818fd11b7090eaa835a353ddd59c236c57b2199486b116c6d", size = 220936, upload-time = "2025-11-18T13:33:28.165Z" }, + { url = "https://files.pythonhosted.org/packages/f9/52/60f64d932d555102611c366afb0eb434b34266b1d9266fc2fe18ab641c47/coverage-7.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:24cff9d1f5743f67db7ba46ff284018a6e9aeb649b67aa1e70c396aa1b7cb23c", size = 222001, upload-time = "2025-11-18T13:33:29.656Z" }, + { url = "https://files.pythonhosted.org/packages/77/df/c303164154a5a3aea7472bf323b7c857fed93b26618ed9fc5c2955566bb0/coverage-7.12.0-cp313-cp313t-win_arm64.whl", hash = "sha256:c87395744f5c77c866d0f5a43d97cc39e17c7f1cb0115e54a2fe67ca75c5d14d", size = 220273, upload-time = "2025-11-18T13:33:31.415Z" }, + { url = "https://files.pythonhosted.org/packages/bf/2e/fc12db0883478d6e12bbd62d481210f0c8daf036102aa11434a0c5755825/coverage-7.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a1c59b7dc169809a88b21a936eccf71c3895a78f5592051b1af8f4d59c2b4f92", size = 217777, upload-time = "2025-11-18T13:33:32.86Z" }, + { url = "https://files.pythonhosted.org/packages/1f/c1/ce3e525d223350c6ec16b9be8a057623f54226ef7f4c2fee361ebb6a02b8/coverage-7.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8787b0f982e020adb732b9f051f3e49dd5054cebbc3f3432061278512a2b1360", size = 218100, upload-time = "2025-11-18T13:33:34.532Z" }, + { url = "https://files.pythonhosted.org/packages/15/87/113757441504aee3808cb422990ed7c8bcc2d53a6779c66c5adef0942939/coverage-7.12.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5ea5a9f7dc8877455b13dd1effd3202e0bca72f6f3ab09f9036b1bcf728f69ac", size = 249151, upload-time = "2025-11-18T13:33:36.135Z" }, + { url = "https://files.pythonhosted.org/packages/d9/1d/9529d9bd44049b6b05bb319c03a3a7e4b0a8a802d28fa348ad407e10706d/coverage-7.12.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fdba9f15849534594f60b47c9a30bc70409b54947319a7c4fd0e8e3d8d2f355d", size = 251667, upload-time = "2025-11-18T13:33:37.996Z" }, + { url = "https://files.pythonhosted.org/packages/11/bb/567e751c41e9c03dc29d3ce74b8c89a1e3396313e34f255a2a2e8b9ebb56/coverage-7.12.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a00594770eb715854fb1c57e0dea08cce6720cfbc531accdb9850d7c7770396c", size = 253003, upload-time = "2025-11-18T13:33:39.553Z" }, + { url = "https://files.pythonhosted.org/packages/e4/b3/c2cce2d8526a02fb9e9ca14a263ca6fc074449b33a6afa4892838c903528/coverage-7.12.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5560c7e0d82b42eb1951e4f68f071f8017c824ebfd5a6ebe42c60ac16c6c2434", size = 249185, upload-time = "2025-11-18T13:33:42.086Z" }, + { url = "https://files.pythonhosted.org/packages/0e/a7/967f93bb66e82c9113c66a8d0b65ecf72fc865adfba5a145f50c7af7e58d/coverage-7.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d6c2e26b481c9159c2773a37947a9718cfdc58893029cdfb177531793e375cfc", size = 251025, upload-time = "2025-11-18T13:33:43.634Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b2/f2f6f56337bc1af465d5b2dc1ee7ee2141b8b9272f3bf6213fcbc309a836/coverage-7.12.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6e1a8c066dabcde56d5d9fed6a66bc19a2883a3fe051f0c397a41fc42aedd4cc", size = 248979, upload-time = "2025-11-18T13:33:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7a/bf4209f45a4aec09d10a01a57313a46c0e0e8f4c55ff2965467d41a92036/coverage-7.12.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f7ba9da4726e446d8dd8aae5a6cd872511184a5d861de80a86ef970b5dacce3e", size = 248800, upload-time = "2025-11-18T13:33:47.546Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b7/1e01b8696fb0521810f60c5bbebf699100d6754183e6cc0679bf2ed76531/coverage-7.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e0f483ab4f749039894abaf80c2f9e7ed77bbf3c737517fb88c8e8e305896a17", size = 250460, upload-time = "2025-11-18T13:33:49.537Z" }, + { url = "https://files.pythonhosted.org/packages/71/ae/84324fb9cb46c024760e706353d9b771a81b398d117d8c1fe010391c186f/coverage-7.12.0-cp314-cp314-win32.whl", hash = "sha256:76336c19a9ef4a94b2f8dc79f8ac2da3f193f625bb5d6f51a328cd19bfc19933", size = 220533, upload-time = "2025-11-18T13:33:51.16Z" }, + { url = "https://files.pythonhosted.org/packages/e2/71/1033629deb8460a8f97f83e6ac4ca3b93952e2b6f826056684df8275e015/coverage-7.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:7c1059b600aec6ef090721f8f633f60ed70afaffe8ecab85b59df748f24b31fe", size = 221348, upload-time = "2025-11-18T13:33:52.776Z" }, + { url = "https://files.pythonhosted.org/packages/0a/5f/ac8107a902f623b0c251abdb749be282dc2ab61854a8a4fcf49e276fce2f/coverage-7.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:172cf3a34bfef42611963e2b661302a8931f44df31629e5b1050567d6b90287d", size = 219922, upload-time = "2025-11-18T13:33:54.316Z" }, + { url = "https://files.pythonhosted.org/packages/79/6e/f27af2d4da367f16077d21ef6fe796c874408219fa6dd3f3efe7751bd910/coverage-7.12.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:aa7d48520a32cb21c7a9b31f81799e8eaec7239db36c3b670be0fa2403828d1d", size = 218511, upload-time = "2025-11-18T13:33:56.343Z" }, + { url = "https://files.pythonhosted.org/packages/67/dd/65fd874aa460c30da78f9d259400d8e6a4ef457d61ab052fd248f0050558/coverage-7.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:90d58ac63bc85e0fb919f14d09d6caa63f35a5512a2205284b7816cafd21bb03", size = 218771, upload-time = "2025-11-18T13:33:57.966Z" }, + { url = "https://files.pythonhosted.org/packages/55/e0/7c6b71d327d8068cb79c05f8f45bf1b6145f7a0de23bbebe63578fe5240a/coverage-7.12.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ca8ecfa283764fdda3eae1bdb6afe58bf78c2c3ec2b2edcb05a671f0bba7b3f9", size = 260151, upload-time = "2025-11-18T13:33:59.597Z" }, + { url = "https://files.pythonhosted.org/packages/49/ce/4697457d58285b7200de6b46d606ea71066c6e674571a946a6ea908fb588/coverage-7.12.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:874fe69a0785d96bd066059cd4368022cebbec1a8958f224f0016979183916e6", size = 262257, upload-time = "2025-11-18T13:34:01.166Z" }, + { url = "https://files.pythonhosted.org/packages/2f/33/acbc6e447aee4ceba88c15528dbe04a35fb4d67b59d393d2e0d6f1e242c1/coverage-7.12.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b3c889c0b8b283a24d721a9eabc8ccafcfc3aebf167e4cd0d0e23bf8ec4e339", size = 264671, upload-time = "2025-11-18T13:34:02.795Z" }, + { url = "https://files.pythonhosted.org/packages/87/ec/e2822a795c1ed44d569980097be839c5e734d4c0c1119ef8e0a073496a30/coverage-7.12.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8bb5b894b3ec09dcd6d3743229dc7f2c42ef7787dc40596ae04c0edda487371e", size = 259231, upload-time = "2025-11-18T13:34:04.397Z" }, + { url = "https://files.pythonhosted.org/packages/72/c5/a7ec5395bb4a49c9b7ad97e63f0c92f6bf4a9e006b1393555a02dae75f16/coverage-7.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:79a44421cd5fba96aa57b5e3b5a4d3274c449d4c622e8f76882d76635501fd13", size = 262137, upload-time = "2025-11-18T13:34:06.068Z" }, + { url = "https://files.pythonhosted.org/packages/67/0c/02c08858b764129f4ecb8e316684272972e60777ae986f3865b10940bdd6/coverage-7.12.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:33baadc0efd5c7294f436a632566ccc1f72c867f82833eb59820ee37dc811c6f", size = 259745, upload-time = "2025-11-18T13:34:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/5a/04/4fd32b7084505f3829a8fe45c1a74a7a728cb251aaadbe3bec04abcef06d/coverage-7.12.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c406a71f544800ef7e9e0000af706b88465f3573ae8b8de37e5f96c59f689ad1", size = 258570, upload-time = "2025-11-18T13:34:09.676Z" }, + { url = "https://files.pythonhosted.org/packages/48/35/2365e37c90df4f5342c4fa202223744119fe31264ee2924f09f074ea9b6d/coverage-7.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e71bba6a40883b00c6d571599b4627f50c360b3d0d02bfc658168936be74027b", size = 260899, upload-time = "2025-11-18T13:34:11.259Z" }, + { url = "https://files.pythonhosted.org/packages/05/56/26ab0464ca733fa325e8e71455c58c1c374ce30f7c04cebb88eabb037b18/coverage-7.12.0-cp314-cp314t-win32.whl", hash = "sha256:9157a5e233c40ce6613dead4c131a006adfda70e557b6856b97aceed01b0e27a", size = 221313, upload-time = "2025-11-18T13:34:12.863Z" }, + { url = "https://files.pythonhosted.org/packages/da/1c/017a3e1113ed34d998b27d2c6dba08a9e7cb97d362f0ec988fcd873dcf81/coverage-7.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e84da3a0fd233aeec797b981c51af1cabac74f9bd67be42458365b30d11b5291", size = 222423, upload-time = "2025-11-18T13:34:15.14Z" }, + { url = "https://files.pythonhosted.org/packages/4c/36/bcc504fdd5169301b52568802bb1b9cdde2e27a01d39fbb3b4b508ab7c2c/coverage-7.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:01d24af36fedda51c2b1aca56e4330a3710f83b02a5ff3743a6b015ffa7c9384", size = 220459, upload-time = "2025-11-18T13:34:17.222Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a3/43b749004e3c09452e39bb56347a008f0a0668aad37324a99b5c8ca91d9e/coverage-7.12.0-py3-none-any.whl", hash = "sha256:159d50c0b12e060b15ed3d39f87ed43d4f7f7ad40b8a534f4dd331adbb51104a", size = 209503, upload-time = "2025-11-18T13:34:18.892Z" }, +] + [[package]] name = "distro" version = "1.9.0" @@ -632,6 +721,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ea/53/aa31e4d057b3746b3c323ca993003d6cf15ef987e7fe7ceb53681695ae87/litellm-1.80.0-py3-none-any.whl", hash = "sha256:fd0009758f4772257048d74bf79bb64318859adb4ea49a8b66fdbc718cd80b6e", size = 10492975, upload-time = "2025-11-16T00:03:49.182Z" }, ] +[[package]] +name = "mando" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/35/24/cd70d5ae6d35962be752feccb7dca80b5e0c2d450e995b16abd6275f3296/mando-0.7.1.tar.gz", hash = "sha256:18baa999b4b613faefb00eac4efadcf14f510b59b924b66e08289aa1de8c3500", size = 37868, upload-time = "2022-02-24T08:12:27.316Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/f0/834e479e47e499b6478e807fb57b31cc2db696c4db30557bb6f5aea4a90b/mando-0.7.1-py2.py3-none-any.whl", hash = "sha256:26ef1d70928b6057ee3ca12583d73c63e05c49de8972d620c278a7b206581a8a", size = 28149, upload-time = "2022-02-24T08:12:25.24Z" }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -815,6 +916,47 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/da/7d22601b625e241d4f23ef1ebff8acfc60da633c9e7e7922e24d10f592b3/multidict-6.7.0-py3-none-any.whl", hash = "sha256:394fc5c42a333c9ffc3e421a4c85e08580d990e08b99f6bf35b4132114c5dcb3", size = 12317, upload-time = "2025-10-06T14:52:29.272Z" }, ] +[[package]] +name = "mypy" +version = "1.18.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/77/8f0d0001ffad290cef2f7f216f96c814866248a0b92a722365ed54648e7e/mypy-1.18.2.tar.gz", hash = "sha256:06a398102a5f203d7477b2923dda3634c36727fa5c237d8f859ef90c42a9924b", size = 3448846, upload-time = "2025-09-19T00:11:10.519Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/06/dfdd2bc60c66611dd8335f463818514733bc763e4760dee289dcc33df709/mypy-1.18.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:33eca32dd124b29400c31d7cf784e795b050ace0e1f91b8dc035672725617e34", size = 12908273, upload-time = "2025-09-19T00:10:58.321Z" }, + { url = "https://files.pythonhosted.org/packages/81/14/6a9de6d13a122d5608e1a04130724caf9170333ac5a924e10f670687d3eb/mypy-1.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a3c47adf30d65e89b2dcd2fa32f3aeb5e94ca970d2c15fcb25e297871c8e4764", size = 11920910, upload-time = "2025-09-19T00:10:20.043Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a9/b29de53e42f18e8cc547e38daa9dfa132ffdc64f7250e353f5c8cdd44bee/mypy-1.18.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d6c838e831a062f5f29d11c9057c6009f60cb294fea33a98422688181fe2893", size = 12465585, upload-time = "2025-09-19T00:10:33.005Z" }, + { url = "https://files.pythonhosted.org/packages/77/ae/6c3d2c7c61ff21f2bee938c917616c92ebf852f015fb55917fd6e2811db2/mypy-1.18.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01199871b6110a2ce984bde85acd481232d17413868c9807e95c1b0739a58914", size = 13348562, upload-time = "2025-09-19T00:10:11.51Z" }, + { url = "https://files.pythonhosted.org/packages/4d/31/aec68ab3b4aebdf8f36d191b0685d99faa899ab990753ca0fee60fb99511/mypy-1.18.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a2afc0fa0b0e91b4599ddfe0f91e2c26c2b5a5ab263737e998d6817874c5f7c8", size = 13533296, upload-time = "2025-09-19T00:10:06.568Z" }, + { url = "https://files.pythonhosted.org/packages/9f/83/abcb3ad9478fca3ebeb6a5358bb0b22c95ea42b43b7789c7fb1297ca44f4/mypy-1.18.2-cp312-cp312-win_amd64.whl", hash = "sha256:d8068d0afe682c7c4897c0f7ce84ea77f6de953262b12d07038f4d296d547074", size = 9828828, upload-time = "2025-09-19T00:10:28.203Z" }, + { url = "https://files.pythonhosted.org/packages/5f/04/7f462e6fbba87a72bc8097b93f6842499c428a6ff0c81dd46948d175afe8/mypy-1.18.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:07b8b0f580ca6d289e69209ec9d3911b4a26e5abfde32228a288eb79df129fcc", size = 12898728, upload-time = "2025-09-19T00:10:01.33Z" }, + { url = "https://files.pythonhosted.org/packages/99/5b/61ed4efb64f1871b41fd0b82d29a64640f3516078f6c7905b68ab1ad8b13/mypy-1.18.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ed4482847168439651d3feee5833ccedbf6657e964572706a2adb1f7fa4dfe2e", size = 11910758, upload-time = "2025-09-19T00:10:42.607Z" }, + { url = "https://files.pythonhosted.org/packages/3c/46/d297d4b683cc89a6e4108c4250a6a6b717f5fa96e1a30a7944a6da44da35/mypy-1.18.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3ad2afadd1e9fea5cf99a45a822346971ede8685cc581ed9cd4d42eaf940986", size = 12475342, upload-time = "2025-09-19T00:11:00.371Z" }, + { url = "https://files.pythonhosted.org/packages/83/45/4798f4d00df13eae3bfdf726c9244bcb495ab5bd588c0eed93a2f2dd67f3/mypy-1.18.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a431a6f1ef14cf8c144c6b14793a23ec4eae3db28277c358136e79d7d062f62d", size = 13338709, upload-time = "2025-09-19T00:11:03.358Z" }, + { url = "https://files.pythonhosted.org/packages/d7/09/479f7358d9625172521a87a9271ddd2441e1dab16a09708f056e97007207/mypy-1.18.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7ab28cc197f1dd77a67e1c6f35cd1f8e8b73ed2217e4fc005f9e6a504e46e7ba", size = 13529806, upload-time = "2025-09-19T00:10:26.073Z" }, + { url = "https://files.pythonhosted.org/packages/71/cf/ac0f2c7e9d0ea3c75cd99dff7aec1c9df4a1376537cb90e4c882267ee7e9/mypy-1.18.2-cp313-cp313-win_amd64.whl", hash = "sha256:0e2785a84b34a72ba55fb5daf079a1003a34c05b22238da94fcae2bbe46f3544", size = 9833262, upload-time = "2025-09-19T00:10:40.035Z" }, + { url = "https://files.pythonhosted.org/packages/5a/0c/7d5300883da16f0063ae53996358758b2a2df2a09c72a5061fa79a1f5006/mypy-1.18.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:62f0e1e988ad41c2a110edde6c398383a889d95b36b3e60bcf155f5164c4fdce", size = 12893775, upload-time = "2025-09-19T00:10:03.814Z" }, + { url = "https://files.pythonhosted.org/packages/50/df/2cffbf25737bdb236f60c973edf62e3e7b4ee1c25b6878629e88e2cde967/mypy-1.18.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8795a039bab805ff0c1dfdb8cd3344642c2b99b8e439d057aba30850b8d3423d", size = 11936852, upload-time = "2025-09-19T00:10:51.631Z" }, + { url = "https://files.pythonhosted.org/packages/be/50/34059de13dd269227fb4a03be1faee6e2a4b04a2051c82ac0a0b5a773c9a/mypy-1.18.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ca1e64b24a700ab5ce10133f7ccd956a04715463d30498e64ea8715236f9c9c", size = 12480242, upload-time = "2025-09-19T00:11:07.955Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/040983fad5132d85914c874a2836252bbc57832065548885b5bb5b0d4359/mypy-1.18.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d924eef3795cc89fecf6bedc6ed32b33ac13e8321344f6ddbf8ee89f706c05cb", size = 13326683, upload-time = "2025-09-19T00:09:55.572Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ba/89b2901dd77414dd7a8c8729985832a5735053be15b744c18e4586e506ef/mypy-1.18.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20c02215a080e3a2be3aa50506c67242df1c151eaba0dcbc1e4e557922a26075", size = 13514749, upload-time = "2025-09-19T00:10:44.827Z" }, + { url = "https://files.pythonhosted.org/packages/25/bc/cc98767cffd6b2928ba680f3e5bc969c4152bf7c2d83f92f5a504b92b0eb/mypy-1.18.2-cp314-cp314-win_amd64.whl", hash = "sha256:749b5f83198f1ca64345603118a6f01a4e99ad4bf9d103ddc5a3200cc4614adf", size = 9982959, upload-time = "2025-09-19T00:10:37.344Z" }, + { url = "https://files.pythonhosted.org/packages/87/e3/be76d87158ebafa0309946c4a73831974d4d6ab4f4ef40c3b53a385a66fd/mypy-1.18.2-py3-none-any.whl", hash = "sha256:22a1748707dd62b58d2ae53562ffc4d7f8bcc727e8ac7cbc69c053ddc874d47e", size = 2352367, upload-time = "2025-09-19T00:10:15.489Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + [[package]] name = "openai" version = "2.8.1" @@ -843,6 +985,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -1047,6 +1198,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/8b/6300fb80f858cda1c51ffa17075df5d846757081d11ab4aa35cef9e6258b/pytest-9.0.1-py3-none-any.whl", hash = "sha256:67be0030d194df2dfa7b556f2e56fb3c3315bd5c8822c6951162b92b32ce7dad", size = 373668, upload-time = "2025-11-12T13:05:07.379Z" }, ] +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.1" @@ -1102,6 +1267,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "radon" +version = "6.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama" }, + { name = "mando" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/6d/98e61600febf6bd929cf04154537c39dc577ce414bafbfc24a286c4fa76d/radon-6.0.1.tar.gz", hash = "sha256:d1ac0053943a893878940fedc8b19ace70386fc9c9bf0a09229a44125ebf45b5", size = 1874992, upload-time = "2023-03-26T06:24:38.868Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/f7/d00d9b4a0313a6be3a3e0818e6375e15da6d7076f4ae47d1324e7ca986a1/radon-6.0.1-py2.py3-none-any.whl", hash = "sha256:632cc032364a6f8bb1010a2f6a12d0f14bc7e5ede76585ef29dc0cecf4cd8859", size = 52784, upload-time = "2023-03-26T06:24:33.949Z" }, +] + [[package]] name = "referencing" version = "0.37.0" @@ -1303,6 +1481,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c9/7f/1a65ae870bc9d0576aebb0c501ea5dccf1ae2178fe2821042150ebd2e707/rpds_py-0.29.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2023473f444752f0f82a58dfcbee040d0a1b3d1b3c2ec40e884bd25db6d117d2", size = 225919, upload-time = "2025-11-16T14:50:14.734Z" }, ] +[[package]] +name = "ruff" +version = "0.14.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/f0/62b5a1a723fe183650109407fa56abb433b00aa1c0b9ba555f9c4efec2c6/ruff-0.14.6.tar.gz", hash = "sha256:6f0c742ca6a7783a736b867a263b9a7a80a45ce9bee391eeda296895f1b4e1cc", size = 5669501, upload-time = "2025-11-21T14:26:17.903Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/d2/7dd544116d107fffb24a0064d41a5d2ed1c9d6372d142f9ba108c8e39207/ruff-0.14.6-py3-none-linux_armv6l.whl", hash = "sha256:d724ac2f1c240dbd01a2ae98db5d1d9a5e1d9e96eba999d1c48e30062df578a3", size = 13326119, upload-time = "2025-11-21T14:25:24.2Z" }, + { url = "https://files.pythonhosted.org/packages/36/6a/ad66d0a3315d6327ed6b01f759d83df3c4d5f86c30462121024361137b6a/ruff-0.14.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9f7539ea257aa4d07b7ce87aed580e485c40143f2473ff2f2b75aee003186004", size = 13526007, upload-time = "2025-11-21T14:25:26.906Z" }, + { url = "https://files.pythonhosted.org/packages/a3/9d/dae6db96df28e0a15dea8e986ee393af70fc97fd57669808728080529c37/ruff-0.14.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7f6007e55b90a2a7e93083ba48a9f23c3158c433591c33ee2e99a49b889c6332", size = 12676572, upload-time = "2025-11-21T14:25:29.826Z" }, + { url = "https://files.pythonhosted.org/packages/76/a4/f319e87759949062cfee1b26245048e92e2acce900ad3a909285f9db1859/ruff-0.14.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a8e7b9d73d8728b68f632aa8e824ef041d068d231d8dbc7808532d3629a6bef", size = 13140745, upload-time = "2025-11-21T14:25:32.788Z" }, + { url = "https://files.pythonhosted.org/packages/95/d3/248c1efc71a0a8ed4e8e10b4b2266845d7dfc7a0ab64354afe049eaa1310/ruff-0.14.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d50d45d4553a3ebcbd33e7c5e0fe6ca4aafd9a9122492de357205c2c48f00775", size = 13076486, upload-time = "2025-11-21T14:25:35.601Z" }, + { url = "https://files.pythonhosted.org/packages/a5/19/b68d4563fe50eba4b8c92aa842149bb56dd24d198389c0ed12e7faff4f7d/ruff-0.14.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:118548dd121f8a21bfa8ab2c5b80e5b4aed67ead4b7567790962554f38e598ce", size = 13727563, upload-time = "2025-11-21T14:25:38.514Z" }, + { url = "https://files.pythonhosted.org/packages/47/ac/943169436832d4b0e867235abbdb57ce3a82367b47e0280fa7b4eabb7593/ruff-0.14.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:57256efafbfefcb8748df9d1d766062f62b20150691021f8ab79e2d919f7c11f", size = 15199755, upload-time = "2025-11-21T14:25:41.516Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b9/288bb2399860a36d4bb0541cb66cce3c0f4156aaff009dc8499be0c24bf2/ruff-0.14.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ff18134841e5c68f8e5df1999a64429a02d5549036b394fafbe410f886e1989d", size = 14850608, upload-time = "2025-11-21T14:25:44.428Z" }, + { url = "https://files.pythonhosted.org/packages/ee/b1/a0d549dd4364e240f37e7d2907e97ee80587480d98c7799d2d8dc7a2f605/ruff-0.14.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29c4b7ec1e66a105d5c27bd57fa93203637d66a26d10ca9809dc7fc18ec58440", size = 14118754, upload-time = "2025-11-21T14:25:47.214Z" }, + { url = "https://files.pythonhosted.org/packages/13/ac/9b9fe63716af8bdfddfacd0882bc1586f29985d3b988b3c62ddce2e202c3/ruff-0.14.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:167843a6f78680746d7e226f255d920aeed5e4ad9c03258094a2d49d3028b105", size = 13949214, upload-time = "2025-11-21T14:25:50.002Z" }, + { url = "https://files.pythonhosted.org/packages/12/27/4dad6c6a77fede9560b7df6802b1b697e97e49ceabe1f12baf3ea20862e9/ruff-0.14.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:16a33af621c9c523b1ae006b1b99b159bf5ac7e4b1f20b85b2572455018e0821", size = 14106112, upload-time = "2025-11-21T14:25:52.841Z" }, + { url = "https://files.pythonhosted.org/packages/6a/db/23e322d7177873eaedea59a7932ca5084ec5b7e20cb30f341ab594130a71/ruff-0.14.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1432ab6e1ae2dc565a7eea707d3b03a0c234ef401482a6f1621bc1f427c2ff55", size = 13035010, upload-time = "2025-11-21T14:25:55.536Z" }, + { url = "https://files.pythonhosted.org/packages/a8/9c/20e21d4d69dbb35e6a1df7691e02f363423658a20a2afacf2a2c011800dc/ruff-0.14.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:4c55cfbbe7abb61eb914bfd20683d14cdfb38a6d56c6c66efa55ec6570ee4e71", size = 13054082, upload-time = "2025-11-21T14:25:58.625Z" }, + { url = "https://files.pythonhosted.org/packages/66/25/906ee6a0464c3125c8d673c589771a974965c2be1a1e28b5c3b96cb6ef88/ruff-0.14.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:efea3c0f21901a685fff4befda6d61a1bf4cb43de16da87e8226a281d614350b", size = 13303354, upload-time = "2025-11-21T14:26:01.816Z" }, + { url = "https://files.pythonhosted.org/packages/4c/58/60577569e198d56922b7ead07b465f559002b7b11d53f40937e95067ca1c/ruff-0.14.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:344d97172576d75dc6afc0e9243376dbe1668559c72de1864439c4fc95f78185", size = 14054487, upload-time = "2025-11-21T14:26:05.058Z" }, + { url = "https://files.pythonhosted.org/packages/67/0b/8e4e0639e4cc12547f41cb771b0b44ec8225b6b6a93393176d75fe6f7d40/ruff-0.14.6-py3-none-win32.whl", hash = "sha256:00169c0c8b85396516fdd9ce3446c7ca20c2a8f90a77aa945ba6b8f2bfe99e85", size = 13013361, upload-time = "2025-11-21T14:26:08.152Z" }, + { url = "https://files.pythonhosted.org/packages/fb/02/82240553b77fd1341f80ebb3eaae43ba011c7a91b4224a9f317d8e6591af/ruff-0.14.6-py3-none-win_amd64.whl", hash = "sha256:390e6480c5e3659f8a4c8d6a0373027820419ac14fa0d2713bd8e6c3e125b8b9", size = 14432087, upload-time = "2025-11-21T14:26:10.891Z" }, + { url = "https://files.pythonhosted.org/packages/a5/1f/93f9b0fad9470e4c829a5bb678da4012f0c710d09331b860ee555216f4ea/ruff-0.14.6-py3-none-win_arm64.whl", hash = "sha256:d43c81fbeae52cfa8728d8766bbf46ee4298c888072105815b392da70ca836b2", size = 13520930, upload-time = "2025-11-21T14:26:13.951Z" }, +] + [[package]] name = "shellingham" version = "1.5.4" @@ -1312,6 +1516,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + [[package]] name = "sniffio" version = "1.3.1" @@ -1321,6 +1534,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] +[[package]] +name = "stevedore" +version = "5.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/5b/496f8abebd10c3301129abba7ddafd46c71d799a70c44ab080323987c4c9/stevedore-5.6.0.tar.gz", hash = "sha256:f22d15c6ead40c5bbfa9ca54aa7e7b4a07d59b36ae03ed12ced1a54cf0b51945", size = 516074, upload-time = "2025-11-20T10:06:07.264Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/40/8561ce06dc46fd17242c7724ab25b257a2ac1b35f4ebf551b40ce6105cfa/stevedore-5.6.0-py3-none-any.whl", hash = "sha256:4a36dccefd7aeea0c70135526cecb7766c4c84c473b1af68db23d541b6dc1820", size = 54428, upload-time = "2025-11-20T10:06:05.946Z" }, +] + [[package]] name = "tiktoken" version = "0.12.0" @@ -1450,28 +1672,44 @@ wheels = [ [[package]] name = "wishful" -version = "0.1.6" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "litellm" }, + { name = "pydantic" }, { name = "python-dotenv" }, { name = "rich" }, ] [package.dev-dependencies] dev = [ + { name = "bandit" }, + { name = "coverage" }, + { name = "mypy" }, { name = "pytest" }, + { name = "pytest-cov" }, + { name = "radon" }, + { name = "ruff" }, ] [package.metadata] requires-dist = [ { name = "litellm", specifier = ">=1.40.0" }, + { name = "pydantic", specifier = ">=2.12.4" }, { name = "python-dotenv", specifier = ">=1.0.1" }, { name = "rich", specifier = ">=13.7.0" }, ] [package.metadata.requires-dev] -dev = [{ name = "pytest", specifier = ">=9.0.1" }] +dev = [ + { name = "bandit", specifier = ">=1.9.1" }, + { name = "coverage", specifier = ">=7.12.0" }, + { name = "mypy", specifier = ">=1.18.2" }, + { name = "pytest", specifier = ">=9.0.1" }, + { name = "pytest-cov", specifier = ">=7.0.0" }, + { name = "radon", specifier = ">=6.0.1" }, + { name = "ruff", specifier = ">=0.14.6" }, +] [[package]] name = "yarl"