promptstrings
Strict prompt-template composition with provenance tracking and FastAPI-style dependency injection. Pure standard library, zero runtime dependencies.
Why
Prompt construction code tends to drift into ad-hoc string formatting that
silently swallows missing variables, hides where each fragment came from, and
makes it hard to know which parameters were actually consumed. promptstrings
gives you:
- Strict rendering: missing placeholders raise; unused parameters raise too
(opt-in via
strict=True, default for@promptstring). - Provenance: each rendered message carries a
PromptSourceProvenancedescribing where its template came from (id, version, hash, provider). - Dependency injection: declare prompt parameters with
Annotated[T, PromptDepends(...)]orAnnotated[T, AwaitPromptDepends(...)]and resolve them from aPromptContextat render time. - Two render shapes: a single string, or a list of
PromptMessageobjects for chat-style APIs.
Install
pip install promptstrings
Requires Python 3.14+.
See runnable examples in examples/ — one file per concept,
runnable with python examples/01_basic_render.py.
Quickstart
import asyncio
from promptstrings import promptstring, PromptContext
@promptstring
def greet(name: str) -> None:
"""Hello, {name}. Welcome to promptstrings."""
async def main() -> None:
text = await greet.render(PromptContext(values={"name": "Ada"}))
print(text) # → "Hello, Ada. Welcome to promptstrings."
asyncio.run(main())
The docstring is the template. Placeholders use the minimal {identifier}
grammar — no format specs, no conversions, no surprises.
Strictness
By default @promptstring is strict: every resolved parameter must appear in
the template, and every placeholder must be resolved. This prevents the
"silently dropped variable" class of prompt bugs.
@promptstring
def example(name: str, unused: int = 42) -> None:
"""Hello {name}."""
await example.render(PromptContext(values={"name": "Ada", "unused": 1}))
# raises PromptStrictnessError: 'unused' was resolved but not consumed
Pass strict=False to opt out.
Three kinds of prompt, three sets of guarantees
Which guarantees you get depends on where the prompt text comes from. The library recognises three shapes, selected by what the decorated function returns. They are not interchangeable, and the differences are structural rather than incidental.
| Owned-static docstring |
Owned-dynamic-> Template |
Delegated-> PromptSource |
|
|---|---|---|---|
| Who renders it | the library | the library | you |
placeholders known at decoration |
yes | no | no |
| Strict mode (missing / unused) | yes, before render | yes, at render | no |
response_schema |
yes | no | no |
| Provenance | no | no | yes |
Works with @promptstring_generator |
yes | yes | no |
Read the table as a set of trades:
- Owned-static is the strongest and should be the default. Placeholders are known before any model call, so a missing or unused parameter is caught at the cheapest possible moment.
- Owned-dynamic keeps strict mode. If a template arrives from a database or a
prompt-management system,
parse_trusted_templatestill gives you missing-parameter and unused-parameter checking — you only lose decoration-time introspection, because the text does not exist yet. - Delegated gives rendering away entirely. Return a
PromptSourceand any engine works — Jinja2, Mustache, plain concatenation — with no library support and no version coupling. The library never parses what you return, so strict mode over placeholders is not merely unimplemented here, it is impossible. In exchange this is the only path that carries provenance.
Two consequences worth knowing before you pick:
You cannot have both strictness and provenance on one prompt. The owned paths check placeholders and set no provenance; the delegated path carries provenance and checks nothing. Choose per prompt.
Structured output is owned-static only. response_schema is derived from the
return annotation, and -> Template / -> PromptSource are exactly the
annotations that select the other two shapes. A Jinja2-rendered prompt cannot
also declare a typed response schema on the same function.
A fourth shape does not exist: a function with no docstring annotated -> str
raises PromptCompileError at decoration rather than rendering anything.
See examples/13_prompt_classes.py for all
three side by side.
Dependency injection
Use PromptDepends when a parameter needs to be resolved from application
state (authenticated user, loaded config, traced span) rather than passed
directly by the caller.
Declare resolver dependencies using typing.Annotated:
from typing import Annotated
from promptstrings import promptstring, PromptDepends, AwaitPromptDepends, PromptContext
def current_user(ctx: PromptContext) -> str:
return ctx.require("user_name")
async def load_profile(ctx: PromptContext) -> str:
return await fetch_profile(ctx.require("user_id"))
@promptstring
def hello(
user: Annotated[str, PromptDepends(current_user)],
profile: Annotated[str, AwaitPromptDepends(load_profile)],
) -> None:
"""Hello, {user}. {profile}"""
Multiple AwaitPromptDepends resolvers run concurrently. If one raises, the rest are cancelled before the exception propagates — resolvers can use try/finally for cleanup.
Structured output
When a prompt function is annotated with a user-defined return type, the
response_schema property exposes that type — no need to repeat it at the
call site. Works with any structured-output framework (instructor, litellm,
OpenAI structured outputs).
from pydantic import BaseModel
from promptstrings import promptstring
class Invoice(BaseModel):
vendor: str
amount: float
@promptstring
def extract(text: str) -> Invoice:
"""Extract invoice data from: {text}"""
messages = await extract.render_messages(ctx)
# Single source of truth — Invoice is not repeated here:
result = client.chat(response_model=extract.response_schema, messages=...)
response_schema is None for internal return types (None, ..., str,
Template, PromptSource) and when no return annotation is present.
Integrations
dishka
pip install promptstrings[dishka]
Use DishkaContext to pass a dishka AsyncContainer, and From(Type) as an Annotated marker to resolve from it:
from typing import Annotated
from promptstrings import promptstring, PromptContext
from promptstrings.integrations.dishka import DishkaContext, From
@promptstring
def greet(username: Annotated[str, From(User)]) -> None:
"""Hello, {username}!"""
ctx = DishkaContext(container=my_container)
result = await greet.render(ctx)
pydantic
pip install promptstrings[pydantic]
PydanticPromptContext.from_model() populates context values from a Pydantic v2 model:
from pydantic import BaseModel
from promptstrings.integrations.pydantic import PydanticPromptContext
class Request(BaseModel):
user: str
topic: str
ctx = PydanticPromptContext.from_model(Request(user="Ada", topic="AI"))
result = await my_prompt.render(ctx)
Pass dump_mode='json' to serialize datetimes and other types to JSON-compatible values.
Generator form
For multi-message prompts (system + user, or alternating turns), use
@promptstring_generator. Yield Role(...) to switch role, yield strings to
append, yield PromptMessage(...) to emit a fully-formed message.
from promptstrings import promptstring_generator, Role
@promptstring_generator
def conversation(topic: str):
yield Role("system")
yield f"You are an expert on {topic}."
yield Role("user")
yield f"Tell me about {topic}."
Join semantics: render() joins multiple messages with "\n\n" (double
newline). Within a single message, consecutive string yields are joined with
"\n". Use render_messages() to get individual PromptMessage objects and
join them yourself.
Type annotations
Use the Promptstring Protocol to annotate prompt objects in function
signatures — it is stable across 1.x and does not expose internal classes:
from promptstrings import Promptstring, PromptContext
async def call_llm(prompt: Promptstring, ctx: PromptContext) -> str:
messages = await prompt.render_messages(ctx)
...
Dynamic templates (t-strings)
For prompts built at runtime — for example, from a function argument or
database-loaded string — return a Python 3.14 t-string (t"...") annotated
-> Template:
from string.templatelib import Template
from promptstrings import promptstring, PromptContext
@promptstring
def greet(name: str) -> Template:
return t"Hello, {name}."
text = await greet.render(PromptContext({"name": "Ada"}))
The t-string path is injection-safe: Python evaluates all expressions before the function returns; the framework never re-parses the resulting string.
For externally loaded template strings (database, config), use
parse_trusted_template:
from string.templatelib import Template
from promptstrings import promptstring, parse_trusted_template, PromptContext
template_from_db = "You are an expert on {topic}." # trusted, not user-supplied
@promptstring
def system(topic: str) -> Template:
return parse_trusted_template(template_from_db)
Security: only pass trusted strings to
parse_trusted_template. User-controlled input containing{param_name}syntax will be substituted.
Provenance
Attach provenance metadata to rendered messages by returning a PromptSource
with a PromptSourceProvenance. The content field of PromptSource is a
literal string — no placeholder substitution occurs. For dynamic content
with provenance, use @promptstring_generator and yield PromptMessage
objects directly:
from promptstrings import promptstring_generator, Role, PromptMessage, PromptSourceProvenance
prov = PromptSourceProvenance(source_id="system-v2", version="2026-04-27")
@promptstring_generator
def system_prompt(topic: str):
yield PromptMessage(
role="system",
content=f"You are an expert on {topic}.",
source=prov,
)
When the template lives in a file, provenance_from_file derives the identity
and a content hash for you, so the boilerplate above does not have to be repeated
per prompt:
from promptstrings import promptstring, PromptSource, provenance_from_file
HERE = pathlib.Path(__file__).parent
@promptstring(strict=False)
def system_prompt(topic: str) -> PromptSource:
template = HERE / "prompts" / "system.jinja2"
return PromptSource(
content=render_however_you_like(template, topic=topic),
provenance=provenance_from_file(
template,
source_id="prompts/system.jinja2", # keep the identity repo-relative
version="2026-07-27",
),
)
hash is sha256 over the file's raw bytes, with no newline normalisation —
normalising would hide a real difference in what was sent to the model. version
is never assigned by the library. Pass source_id explicitly whenever the path
you read from is absolute, since an absolute path is machine-specific and would
make the same template compare unequal across checkouts.
For a static template with provenance, use PromptSource with literal content:
from promptstrings import promptstring, PromptSource, PromptSourceProvenance
@promptstring(strict=False)
def static_prompt() -> PromptSource:
return PromptSource(
content="You are a helpful assistant.",
provenance=PromptSourceProvenance(source_id="assistant-v1"),
)
Observers
Promptstrings is a configuration carrier that attaches a shared Observer
to multiple prompt functions. Observers receive RenderStartEvent,
RenderEndEvent, and RenderErrorEvent objects for every render call — useful
for logging, metrics, and tracing.
from promptstrings import Promptstrings, Observer, RenderStartEvent, RenderEndEvent, RenderErrorEvent
class LogObserver:
def on_render_start(self, event: RenderStartEvent) -> None:
print(f"[start] {event.prompt_name}")
def on_render_end(self, event: RenderEndEvent) -> None:
print(f"[end] {event.prompt_name} ({event.elapsed_ns // 1_000_000}ms)")
def on_render_error(self, event: RenderErrorEvent) -> None:
print(f"[error] {event.prompt_name}: {event.error}")
ps = Promptstrings(observer=LogObserver())
@ps.promptstring
def greet(name: str) -> None:
"""Hello, {name}."""
@ps.promptstring_generator
def chat(topic: str):
yield Role("system")
yield f"You are an expert on {topic}."
Stability
Stable. The library follows SemVer from 1.0 — breaking changes require a major
version bump. The full API contract is documented in
design/decisions/0001.
Design
The API contract (stability guarantees, 13 promises, DX rubric) is documented
in design/decisions/0001.
Full design documentation lives in design/.
License
MIT.
Who builds this
Built by Daniyar Supiyev. I consult on AI agent systems — building them, and fixing the ones that misbehave in production — at Forbidden Fundamentals.
Release files for promptstrings 1.3.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| promptstrings-1.3.0.tar.gz | 243.5 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| promptstrings-1.3.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 273.6 kB
Release files / promptstrings-1.3.0.tar.gz
| Download URL | promptstrings-1.3.0.tar.gz |
|---|---|
| Size | 243.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
5e770130730394f0bac66a9fa37f9c3ddc8221a136e7b35abf32c2c940ab6de6
|
|
BLAKE2b-256 checksum How to use checksums |
2cf1a4bd766cc72f5acc7fa38e9a3fd3be56bbef106cf2b3c320f389772b9113
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Jul 27, 2026.
Transparency logRelease files / promptstrings-1.3.0-py3-none-any.whl
| Download URL | promptstrings-1.3.0-py3-none-any.whl |
|---|---|
| Size | 30.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
8116f835d69c8841e8faa43980064dc287f7e1b358e5b88f5f08e55f5e6bbdf5
|
|
BLAKE2b-256 checksum How to use checksums |
0c6038125aa687e091fe4268c51b814759140b3d4d697653bbf4fe7f3f2ef065
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Jul 27, 2026.
Transparency log