Metadata-Version: 2.4
Name: prompt-builder-py
Version: 1.0.0
Summary: Fluent prompt builder for LLM APIs - templates, variables, token counting, chat management
Project-URL: Homepage, https://sarmkadan.com
Project-URL: Repository, https://github.com/Sarmkadan/prompt-builder-py
Author-email: Vladyslav Zaiets <rutova2@gmail.com>
License: MIT
Keywords: ai,builder,engineering,llm,prompt,template,tokens
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.9
Description-Content-Type: text/markdown

# prompt-builder-py

**Fluent prompt builder for LLM APIs** — templates, variable substitution, token counting, and chat history management in one zero-dependency Python library.

[![PyPI version](https://img.shields.io/pypi/v/prompt-builder-py)](https://pypi.org/project/prompt-builder-py/)
[![Python 3.9+](https://img.shields.io/badge/python-3.9%2B-blue)](https://python.org)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)

## Installation

```bash
pip install prompt-builder-py
```

## Why prompt-builder-py?

- **Fluent API** — chain `.system()`, `.user()`, `.assistant()`, and `.var()` calls naturally.
- **Variable substitution** — `{name}` placeholders resolved at build time, not at declaration.
- **Handlebars templates** — `{{variable}}`, `{{#if}}`, `{{#each}}`, partials, and a global registry.
- **Chat history** — add messages, auto-trim to token/count limits, export to JSON/Markdown.
- **Provider formatters** — emit the exact payload shape for OpenAI, Anthropic, Cohere, Mistral, or plain text.
- **Token counting** — dependency-free approximation using a hybrid char/word heuristic.
- **Zero dependencies** — pure Python 3.9+, stdlib only.

---

## Quick Start

```python
from prompt_builder_py import PromptBuilder, OpenAIFormatter

messages = (
    PromptBuilder()
    .system("You are an expert in {domain}.")
    .user("Explain {concept} with a short example.")
    .var("domain", "Python")
    .var("concept", "decorators")
    .build()
)

payload = OpenAIFormatter(model="gpt-4o").format(messages, max_tokens=512)
# → {"model": "gpt-4o", "messages": [...], "max_tokens": 512}
```

---

## PromptBuilder

### Building messages

```python
from prompt_builder_py import PromptBuilder

builder = (
    PromptBuilder()
    .system("You are a {role}.")
    .user("What is {topic}?")
    .assistant("Here is what I know about {topic}…")
    .user("Can you elaborate?")
)
```

### Variable binding

```python
# Bind one at a time
builder.var("role", "historian").var("topic", "the Roman Empire")

# Or bind many at once
builder.vars(role="historian", topic="the Roman Empire")
builder.vars({"role": "historian", "topic": "the Roman Empire"})
```

### Strict mode

```python
# Raise VariableNotFoundError for any unbound {variable}
builder.strict()

from prompt_builder_py import VariableNotFoundError
try:
    builder.build()
except VariableNotFoundError as e:
    print(e.variable_name)
```

### Forking a builder

```python
base = (
    PromptBuilder()
    .system("Answer in {language}.")
    .user("{question}")
    .strict()
)

en = base.copy().vars(language="English", question="What is entropy?")
fr = base.copy().vars(language="French",  question="What is entropy?")

print(en.build())
print(fr.build())
```

### Output methods

| Method | Returns |
|---|---|
| `.build()` | `list[dict]` — all messages as `{role, content}` dicts |
| `.build_chat()` | `list[dict]` — non-system messages only |
| `.build_system()` | `str \| None` — rendered system message |
| `.build_string()` | `str` — all messages concatenated |
| `.build_last_user()` | `str \| None` — last user message |
| `.token_count(model)` | `int` — estimated token count |
| `.missing_vars()` | `list[str]` — unbound variable names |

---

## Template Engine

Uses `{{double-brace}}` syntax to distinguish templates from builder variables.

```python
from prompt_builder_py import Template, TemplateRegistry

# Basic substitution with optional default
t = Template("Hello, {{name}}! You have {{count|0}} messages.")
print(t.render(name="Alice", count=3))
# Hello, Alice! You have 3 messages.

print(t.render(name="Bob"))
# Hello, Bob! You have 0 messages.
```

### Conditionals

```python
t = Template(
    "{{#if premium}}You have unlimited access.{{/if}}"
    "{{#unless premium}}Upgrade to unlock all features.{{/unless}}"
)
print(t.render(premium=True))   # You have unlimited access.
print(t.render(premium=False))  # Upgrade to unlock all features.
```

### Loops

```python
t = Template(
    "Languages:\n{{#each langs}}- {{@index}}. {{.}}\n{{/each}}"
)
print(t.render(langs=["Python", "Go", "Rust"]))
# Languages:
# - 0. Python
# - 1. Go
# - 2. Rust

# Loop over dicts
t2 = Template("{{#each users}}{{name}} ({{role}})\n{{/each}}")
print(t2.render(users=[
    {"name": "Alice", "role": "admin"},
    {"name": "Bob",   "role": "viewer"},
]))
```

### Partials and registry

```python
TemplateRegistry.register("footer", "\n---\nprompt-builder-py")
TemplateRegistry.register(
    "reply",
    "Answer in {{lang|English}}:\n{{question}}\n{{> footer}}"
)

result = TemplateRegistry.render("reply", question="What is Python?", lang="French")
```

### Introspection

```python
t = Template("Hello {{name}}, your score is {{score|N/A}}.")
print(t.required_vars())  # ["name"]
print(t.optional_vars())  # ["score"]
print(t.validate({"score": 42}))  # ["name"]  ← missing
```

---

## Chat History

```python
from prompt_builder_py import ChatHistory

history = ChatHistory(max_tokens=4096, model="gpt-4o")
history.add_system("You are a concise assistant.")
history.add_user("What is Python?")
history.add_assistant("A high-level, interpreted programming language.")
history.add_user("And Rust?")

print(len(history))          # 4
print(history.token_count()) # ≈ estimated tokens

# Export
print(history.to_markdown())
json_str = history.to_json()

# Restore from JSON
history2 = ChatHistory.from_json(json_str, max_tokens=4096)
```

### Truncation strategies

```python
# oldest (default): drop oldest non-system messages
history = ChatHistory(max_messages=10, truncation_strategy="oldest")

# pairs: drop oldest user/assistant exchange pairs
history = ChatHistory(max_tokens=2048, truncation_strategy="pairs")

# middle: keep first and last messages, drop the middle
history = ChatHistory(max_tokens=2048, truncation_strategy="middle")

# Manual truncation
history.truncate(strategy="oldest")
```

### Search

```python
results = history.search("Python")
for msg in results:
    print(msg.role, msg.content[:50])
```

---

## Token Counting

```python
from prompt_builder_py import TokenCounter, TokenBudget

counter = TokenCounter(model="gpt-4o")
print(counter.count("Hello, world!"))           # ≈ 4
print(counter.count_messages([
    {"role": "user", "content": "Hello there"}
]))                                              # ≈ 11

# Truncate a long string to fit in a budget
short = counter.truncate_to_budget(long_text, budget=1000)

# Split into chunks
chunks = counter.split_by_budget(long_text, budget=500)
```

```python
budget = TokenBudget(max_tokens=8192, model="gpt-4o")
budget.consume("System prompt text here")
budget.consume_messages(messages)
print(budget.remaining)    # tokens left
print(budget.usage_pct)    # e.g. 23.4
print(budget.is_exhausted) # False
```

---

## Provider Formatters

### OpenAI

```python
from prompt_builder_py import OpenAIFormatter

payload = OpenAIFormatter(model="gpt-4o").format(
    messages,
    max_tokens=1024,
    temperature=0.7,
    response_format={"type": "json_object"},
)
```

### Anthropic

```python
from prompt_builder_py import AnthropicFormatter

payload = AnthropicFormatter(model="claude-sonnet-4-5").format(
    messages,
    max_tokens=2048,
    system="Override system prompt here",
)
```

### Cohere

```python
from prompt_builder_py import CohereFormatter

payload = CohereFormatter(model="command-r-plus").format(messages, max_tokens=512)
```

### Mistral

```python
from prompt_builder_py import MistralFormatter

payload = MistralFormatter(model="mistral-large-latest").format(messages, max_tokens=1024)
```

### Plain text

```python
from prompt_builder_py import TextFormatter

payload = TextFormatter().format(messages, separator="\n\n", role_prefix=True)
print(payload["text"])
```

---

## Validation

```python
from prompt_builder_py import validate_messages, MessageValidator, ValidationResult

# Simple helper — raises ValidationError on failure
validate_messages(messages, provider="anthropic", max_tokens=4096)

# Full control
result: ValidationResult = MessageValidator().validate(
    messages,
    provider="openai",
    max_tokens=8192,
    strict_alternation=True,
    require_user_first=True,
)
print(result.is_valid)   # True / False
print(result.errors)     # list of error strings
print(result.warnings)   # list of warning strings
result.raise_if_invalid()
```

---

## Complete Example

```python
from prompt_builder_py import (
    PromptBuilder,
    ChatHistory,
    Template,
    TemplateRegistry,
    AnthropicFormatter,
    TokenBudget,
    validate_messages,
)

# 1. Register reusable templates
TemplateRegistry.register(
    "code_review",
    "Review the following {{lang|Python}} code for correctness, "
    "performance, and security.\\n\\n```{{lang|python}}\\n{{code}}\\n```"
    "{{#if focus}}\\n\\nFocus on: {{focus}}{{/if}}"
)

# 2. Build the prompt
builder = (
    PromptBuilder()
    .system("You are a senior software engineer conducting a thorough code review.")
    .user(TemplateRegistry.render(
        "code_review",
        lang="Python",
        code="def add(a, b): return a + b",
        focus="type safety",
    ))
    .strict()
)

# 3. Track token usage
budget = TokenBudget(max_tokens=4096)
messages = builder.build()
budget.consume_messages(messages)
print(f"Tokens used: {budget.used} / {budget.max_tokens} ({budget.usage_pct}%)")

# 4. Validate before sending
validate_messages(messages, provider="anthropic", max_tokens=4096)

# 5. Format for Anthropic
payload = AnthropicFormatter().format(messages, max_tokens=1024)

# 6. Persist the conversation
history = ChatHistory(max_tokens=4096)
history.extend(messages)
history.add_assistant("The code looks correct. Consider adding type hints…")
print(history.to_markdown())
```

---

## API Reference

### PromptBuilder

| Method | Description |
|---|---|
| `.system(content, **meta)` | Append a system message |
| `.user(content, **meta)` | Append a user message |
| `.assistant(content, **meta)` | Append an assistant message |
| `.add(role, content, **meta)` | Append a message with any role |
| `.extend(messages)` | Append a list of dicts |
| `.var(name, value)` | Bind one variable |
| `.vars(mapping, **kw)` | Bind multiple variables |
| `.unset(*names)` | Remove variable bindings |
| `.strict(enabled)` | Toggle strict variable mode |
| `.copy()` | Deep-copy the builder |
| `.reset()` | Clear all state |
| `.build()` | Build all messages |
| `.build_chat()` | Build non-system messages |
| `.build_system()` | Get rendered system prompt |
| `.build_string(sep)` | Build as single string |
| `.token_count(model)` | Estimate token count |
| `.missing_vars()` | List unbound variables |

### Template

| Method | Description |
|---|---|
| `.render(context, **kw)` | Render with context |
| `.required_vars()` | Variables with no default |
| `.optional_vars()` | Variables with defaults |
| `.all_vars()` | All referenced variables |
| `.validate(context)` | List missing required vars |

### ChatHistory

| Method | Description |
|---|---|
| `.add_system(content)` | Set the system message |
| `.add_user(content)` | Append a user message |
| `.add_assistant(content)` | Append an assistant message |
| `.extend(messages)` | Append a list of dicts |
| `.to_messages()` | Export as `list[dict]` |
| `.to_json()` | Serialize to JSON string |
| `.from_json(data)` | Deserialize from JSON |
| `.to_markdown()` | Format as Markdown |
| `.search(query)` | Find messages by content |
| `.token_count()` | Estimate token count |
| `.truncate(strategy)` | Trim to configured limits |

---

## License

MIT © [Vladyslav Zaiets](https://sarmkadan.com)
