speq — write specs, not code
Project description
speq — the speqification is the code
Docstrings as code.
from speq import ai
@ai
def int_to_english(n: int) -> str:
"""
Convert an integer to English words. Supports -999999 to 999999.
Lowercase, words separated by single spaces.
Examples: 0 -> "zero", 42 -> "forty two", -15 -> "negative fifteen",
11 -> "eleven", 100 -> "one hundred".
"""
...
print(int_to_english(999999))
# "nine hundred ninety nine thousand nine hundred ninety nine"
Write a function signature, describe what it should do in the docstring, leave the body as .... The implementation is synthesized by an LLM and cached to disk. You never write — or read — the generated code.
How it works
- Decorate a function with
@ai - Write a docstring describing the function's behavior
- Leave the body as
... - On first call,
speqsynthesizes an implementation via LLM - The implementation is cached — subsequent calls don't hit the LLM
Install
git clone https://github.com/c-shanty/speq.git
cd speq
python3 -m venv .venv
source .venv/bin/activate
pip install ".[dev]"
You'll need an Anthropic API key:
export ANTHROPIC_API_KEY=your-key-here
To use a specific model (defaults to Claude Sonnet):
export SPEQ_MODEL=claude-sonnet-4-20250514
Or per-function:
@ai(model="claude-sonnet-4-20250514")
def my_function(x: int) -> int:
"""Double the input."""
...
Why not just use Claude Code / Copilot / ChatGPT?
You can ask any AI to write a function. The difference is what happens next.
With Claude Code, the AI writes an implementation and you paste it into your codebase. Now you have code that you didn't write and a conversation that you'll never find again. When the requirements change, you go back to the AI, re-explain the context, and paste again. The code and the intent are in two different places, and they drift immediately.
With @ai, the docstring is the source code. The intent and the implementation can't drift because one generates the other. Change the docstring, and the implementation regenerates. Upgrade to a better model, delete the cache, and every function in your project gets better automatically. The specs you write today appreciate in value over time.
Examples
Every function below was synthesized correctly on the first try.
Functions where the spec is simple but the implementation is not
@ai
def validate_brackets(s: str) -> tuple[bool, int]:
"""
Check if a string has balanced brackets: (), [], {}.
Non-bracket characters are ignored.
If balanced, return (True, -1).
If unbalanced, return (False, position) where position is the
index of the first problematic bracket.
An unmatched closing bracket is problematic at its own position.
An unclosed opening bracket is problematic at its position.
"""
...
validate_brackets("({[]})") # (True, -1)
validate_brackets("({[}])") # (False, 3)
validate_brackets("(()") # (False, 0)
@ai
def merge_overlapping_ranges(ranges: list[tuple[int, int]]) -> list[tuple[int, int]]:
"""
Merge a list of potentially overlapping integer ranges into
non-overlapping ranges. Each range is (start inclusive, end exclusive).
Adjacent ranges that touch are also merged. Input may not be sorted.
Output must be sorted by start value.
Examples:
[(0,5), (3,8), (10,15)] -> [(0,8), (10,15)]
[(0,5), (5,10)] -> [(0,10)]
"""
...
merge_overlapping_ranges([(0, 5), (3, 8), (10, 15)]) # [(0, 8), (10, 15)]
@ai
def time_ago(seconds: int) -> str:
"""
Convert a number of seconds into a human-readable relative time string.
Examples: 30 -> "just now", 90 -> "1 minute ago", 3600 -> "1 hour ago",
7200 -> "2 hours ago", 86400 -> "yesterday", 172800 -> "2 days ago",
2592000 -> "1 month ago", 31536000 -> "1 year ago".
Use singular when count is 1, plural otherwise.
seconds is always non-negative.
"""
...
time_ago(90) # "1 minute ago"
time_ago(7200) # "2 hours ago"
time_ago(86400) # "yesterday"
@ai
def rgb_to_hsl(r: int, g: int, b: int) -> tuple[int, int, int]:
"""
Convert RGB color values (each 0-255) to HSL.
Returns (hue 0-359, saturation 0-100, lightness 0-100).
If saturation is 0 (achromatic), hue should be 0.
"""
...
rgb_to_hsl(255, 0, 0) # (0, 100, 50)
Gradually adopt in existing code
@ai works alongside regular Python. Adopt it one function at a time — no rewrite required:
from speq import ai
import db
# existing code — untouched
def get_user(user_id: str) -> User:
return db.query("SELECT * FROM users WHERE id = %s", user_id)
# new function — let speq handle the fiddly logic
@ai
def format_user_display_name(first: str, last: str, nickname: str | None,
prefer_nickname: bool) -> str:
"""
Format a user's display name. If prefer_nickname is True and nickname
is not None or empty, return the nickname. Otherwise return
"First Last". If first or last is empty, omit it (don't leave
leading/trailing spaces). If all are empty, return "Anonymous".
"""
...
# existing code — calls the @ai function normally
def render_profile(user_id: str):
user = get_user(user_id)
name = format_user_display_name(user.first, user.last, user.nickname, True)
return f"<h1>{name}</h1>"
When to use this
@ai shines when the spec is simpler than the implementation:
- Tricky string formatting — number-to-words, relative time ("2 hours ago"), display name logic
- Algorithmic utilities — range merging, bracket validation, deduplication
- Conversion functions — color spaces, unit conversions, coordinate transforms
- Business rule calculations — prorated refunds, tiered pricing, tax rounding
- Parsing — phone numbers, addresses, log lines, CSV edge cases
When NOT to use this
- Trivial functions —
return min(a, b)doesn't need@ai - Performance-critical hot paths — you need to control the exact implementation
- Complex stateful logic — database transactions, API call chains, anything where execution order and side effects matter (future roadmap)
CLI
Pre-synthesize functions without running your program:
speq synthesize myfile.py # synthesize all @ai functions
speq synthesize myfile.py --force # re-synthesize even if cached
Run auto-generated tests (derived from the spec, not the implementation):
speq test myfile.py # run all generated tests
speq test myfile.py --func time_ago # test one function
See what changed after editing a docstring:
speq diff myfile.py # diff old vs new implementation
Manage the cache:
speq cache list myfile.py # show cached functions with scores
speq cache clear myfile.py # clear all cached implementations
speq cache clear myfile.py --func time_ago # clear one function
Confidence scores
Every synthesis is scored for spec clarity and implementation confidence. Enable debug logging to see them:
import logging
logging.basicConfig(level=logging.DEBUG)
from speq import ai
@ai
def my_function(x: int) -> int:
"""Double the input."""
...
my_function(5)
DEBUG:speq: [my_function] spec clarity: 0.95
DEBUG:speq: [my_function] impl confidence: 0.98
For vague specs, you'll get warnings and actionable feedback:
DEBUG:speq: [process_data] spec clarity: 0.42
DEBUG:speq: [process_data] impl confidence: 0.51
DEBUG:speq: [process_data] assumptions made:
DEBUG:speq: [process_data] - input list contains only positive numbers
DEBUG:speq: [process_data] - empty list should return 0
DEBUG:speq: [process_data] ambiguities found:
DEBUG:speq: [process_data] - "process" could mean filter, transform, or aggregate
WARNING:speq: [process_data] low spec clarity (0.42) — consider adding more detail
Scores are also available programmatically:
scores = my_function.get_scores()
print(scores.spec_clarity) # 0.95
print(scores.assumptions) # []
print(scores.missing_edge_cases) # ["behavior for negative input not specified"]
Caching
Synthesized implementations are cached in .speq_cache/ next to your source file. The cache key is a hash of the function name, signature, and docstring. Change the docstring and the implementation re-synthesizes on next call.
Delete .speq_cache/ to force re-synthesis of everything.
Writing good specs
The better your docstring, the better the implementation.
- Include examples.
"Examples: 30 -> 'just now', 90 -> '1 minute ago'"anchors the implementation. - Be specific about edge cases. "If all are empty, return 'Anonymous'" prevents guessing.
- State constraints explicitly. "Use singular when count is 1, plural otherwise" prevents subtle bugs.
- Describe what, not how. "Output must be sorted by start value" not "sort the list first then iterate."
What's next
- Interactive spec review — accept, reject, or clarify the LLM's assumptions
- Module-level context — shared conventions across functions
License
MIT
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file speq-0.1.0.tar.gz.
File metadata
- Download URL: speq-0.1.0.tar.gz
- Upload date:
- Size: 21.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
86633507ce24d29c351b839ba8028d2c0fe2782c288fa0e3f4d27fe5281dd5ae
|
|
| MD5 |
78af6c5dc379f2d50a497d1c6d123197
|
|
| BLAKE2b-256 |
bcafa280b908697d28ece61c96e4e7de7f5963e92806c1412bd407e0d5bd186f
|
File details
Details for the file speq-0.1.0-py3-none-any.whl.
File metadata
- Download URL: speq-0.1.0-py3-none-any.whl
- Upload date:
- Size: 17.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0ce3a0ad1b6a08c80f3e25fdb141b837b52475ca99e691f37024f18493d2ea35
|
|
| MD5 |
d7eaf7369cc5642619b83297025c628d
|
|
| BLAKE2b-256 |
08f2e54a1e425a96edc9376ec4d17e22a9001d1cda0a71c1aa55b31ad0344a74
|