Skip to main content

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

  1. Decorate a function with @ai
  2. Write a docstring describing the function's behavior
  3. Leave the body as ...
  4. On first call, speq synthesizes an implementation via LLM
  5. 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 functionsreturn 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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

speq-0.1.0.tar.gz (21.3 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

speq-0.1.0-py3-none-any.whl (17.8 kB view details)

Uploaded Python 3

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

Hashes for speq-0.1.0.tar.gz
Algorithm Hash digest
SHA256 86633507ce24d29c351b839ba8028d2c0fe2782c288fa0e3f4d27fe5281dd5ae
MD5 78af6c5dc379f2d50a497d1c6d123197
BLAKE2b-256 bcafa280b908697d28ece61c96e4e7de7f5963e92806c1412bd407e0d5bd186f

See more details on using hashes here.

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

Hashes for speq-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 0ce3a0ad1b6a08c80f3e25fdb141b837b52475ca99e691f37024f18493d2ea35
MD5 d7eaf7369cc5642619b83297025c628d
BLAKE2b-256 08f2e54a1e425a96edc9376ec4d17e22a9001d1cda0a71c1aa55b31ad0344a74

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page