Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/prompts/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,7 @@ class PromptValidationError(PromptError):

class PromptNotFoundError(PromptError):
"""No prompt exists for the given identifier."""


class PromptRenderError(PromptError):
"""Prompt rendering error."""
33 changes: 10 additions & 23 deletions src/prompts/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,25 +6,12 @@
from app.database import get_session
from log import get_logger
from models.database.prompts import Prompt, PromptVersion
from prompts.exceptions import PromptNotFoundError, PromptValidationError
from prompts.exceptions import PromptNotFoundError
from prompts.template import validate_template

logger = get_logger(__name__)


def _validate_variables(variables: list[str]) -> None:
"""Reject duplicate variable names.

Parameters:
variables: Declared variable names to validate.

Raises:
PromptValidationError: If the list contains duplicate names.

"""
if len(variables) != len(set(variables)):
raise PromptValidationError("The variables contain duplicate names")


def create_prompt(name: str, template: str, variables: list[str]) -> PromptVersion:
"""Create a new prompt with its initial version.

Expand All @@ -37,12 +24,12 @@ def create_prompt(name: str, template: str, variables: list[str]) -> PromptVersi
PromptVersion: The initial version row.

Raises:
PromptValidationError: If variables contain duplicates.
PromptValidationError: If the template is empty, contains invalid
placeholder names, has duplicate or invalid variable names,
or has undeclared placeholders.

"""
# TODO: template validation # pylint: disable=fixme

_validate_variables(variables)
validate_template(template, variables)

prompt_id = f"pmpt_{uuid4()}"
version = 1
Expand Down Expand Up @@ -132,12 +119,12 @@ def update_prompt(

Raises:
PromptNotFoundError: If the prompt does not exist.
PromptValidationError: If variables contain duplicates.
PromptValidationError: If the template is empty, contains invalid
placeholder names, has duplicate or invalid variable names,
or has undeclared placeholders.

"""
# TODO: validate template # pylint: disable=fixme

_validate_variables(variables)
validate_template(template, variables)

with get_session() as session:
max_prompt_version = (
Expand Down
127 changes: 127 additions & 0 deletions src/prompts/template.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
"""Mustache-style template validation and rendering.

Placeholders use ``{{name}}`` syntax with optional inner whitespace.
Only plain text substitution is supported; Jinja features (filters,
control flow, expressions) are not allowed.

Valid placeholder names must match ``[a-zA-Z_][a-zA-Z0-9_-]*``.
Variables are text-only: each value is a plain string that replaces
its corresponding placeholder verbatim.
"""

import re

from models.database.prompts import PromptVersion
from prompts.exceptions import PromptRenderError, PromptValidationError

_PLACEHOLDER_RE = re.compile(r"\{\{\s*(.*?)\s*\}\}")

_VALID_NAME_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_-]*$")


def _validate_variables(variables: list[str]) -> set[str]:
"""Validate variable names and reject duplicates.

Parameters:
variables: Declared variable names to validate.

Returns:
The validated variable names as a set.

Raises:
PromptValidationError: If the list contains duplicate names or
any name has an invalid format.

"""
unique = set(variables)
if len(variables) != len(unique):
raise PromptValidationError("The variables contain duplicate names")

for variable in unique:
if not variable or not _VALID_NAME_RE.match(variable):
raise PromptValidationError(
f"The variable list contains invalid name format: {variable}"
)

return unique


def _extract_placeholders(template: str) -> set[str]:
"""Extract and validate all ``{{name}}`` placeholders from template text.

Parameters:
template: The template string to scan.

Returns:
Set of valid placeholder names found in the template.

Raises:
PromptValidationError: If any placeholder has an invalid name format.

"""
raw_names = _PLACEHOLDER_RE.findall(template)
valid = set()
for raw_name in raw_names:
if not raw_name or not _VALID_NAME_RE.match(raw_name):
raise PromptValidationError(
f"The template contains invalid name format for the placeholder: {raw_name}"
)
valid.add(raw_name)
return valid


def validate_template(template: str, variables: list[str]) -> None:
"""Validate a template and its declared variables on create or update.

Every placeholder in the template must appear in the declared variables
list. Unused declared variables are allowed.

Parameters:
template: The template text to validate.
variables: Declared variable names for the template.

Raises:
PromptValidationError: If the template is empty, contains invalid
placeholder names, has duplicate variables, or has undeclared
placeholders.

"""
if not template.strip():
raise PromptValidationError("The template must not be empty")

declared_variables = _validate_variables(variables)
placeholders = _extract_placeholders(template)
undeclared = placeholders - declared_variables

if undeclared:
raise PromptValidationError(f"Undeclared placeholders: {undeclared}")


def render_template(prompt_version: PromptVersion, variables: dict[str, str]) -> str:
"""Render a prompt version by substituting placeholder values.

Requires a value for every declared variable and rejects any extra keys.
The stored PromptVersion is not modified.

Parameters:
prompt_version: The validated prompt version to render.
variables: Mapping of variable names to their text substitution values.

Returns:
The rendered plain-text string.

Raises:
PromptRenderError: If required variables are missing or unexpected
variables are provided.

"""
declared = set(prompt_version.variables)
provided = set(variables.keys())

missing = declared - provided
if missing:
raise PromptRenderError(f"Missing variables: {missing}")

return _PLACEHOLDER_RE.sub(
lambda m: variables[m.group(1).strip()], prompt_version.template
)
13 changes: 13 additions & 0 deletions tests/unit/prompts/test_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,12 @@ def test_create_prompt_with_invalid_variables(self, db_session: Session) -> None
with pytest.raises(PromptValidationError, match="duplicate names"):
create_prompt(name, template, variables)

def test_create_prompt_with_empty_template(self, db_session: Session) -> None:
"""Test creating a prompt with empty template raises PromptValidationError."""
_ = db_session
with pytest.raises(PromptValidationError, match="must not be empty"):
create_prompt("test-prompt", "", ["name"])

def test_get_prompt(self, db_session: Session) -> None:
"""Test retrieving a prompt by default version and by explicit version."""
prompt, expected_pv_v1, expected_pv_v2 = _setup_db(db_session, True)
Expand Down Expand Up @@ -291,6 +297,13 @@ def test_update_prompt_with_duplicate_variables(self, db_session: Session) -> No
with pytest.raises(PromptValidationError, match="duplicate names"):
update_prompt(prompt.id, "{{x}} {{x}}", ["x", "x"])

def test_update_prompt_with_empty_template(self, db_session: Session) -> None:
"""Test updating a prompt with empty template raises PromptValidationError."""
prompt, _, _ = _setup_db(db_session)

with pytest.raises(PromptValidationError, match="must not be empty"):
update_prompt(prompt.id, "", ["x"])

def test_list_prompts(self, db_session: Session) -> None:
"""Test listing prompts returns all prompt identities."""
_setup_db(db_session, True)
Expand Down
Loading
Loading