diff --git a/src/prompts/exceptions.py b/src/prompts/exceptions.py index 109bdec58..54d19d92c 100644 --- a/src/prompts/exceptions.py +++ b/src/prompts/exceptions.py @@ -11,3 +11,7 @@ class PromptValidationError(PromptError): class PromptNotFoundError(PromptError): """No prompt exists for the given identifier.""" + + +class PromptRenderError(PromptError): + """Prompt rendering error.""" diff --git a/src/prompts/store.py b/src/prompts/store.py index 1cd88e858..1386ddeda 100644 --- a/src/prompts/store.py +++ b/src/prompts/store.py @@ -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. @@ -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 @@ -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 = ( diff --git a/src/prompts/template.py b/src/prompts/template.py new file mode 100644 index 000000000..bed274a16 --- /dev/null +++ b/src/prompts/template.py @@ -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 + ) diff --git a/tests/unit/prompts/test_store.py b/tests/unit/prompts/test_store.py index 5885fdea6..4c608667f 100644 --- a/tests/unit/prompts/test_store.py +++ b/tests/unit/prompts/test_store.py @@ -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) @@ -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) diff --git a/tests/unit/prompts/test_template.py b/tests/unit/prompts/test_template.py new file mode 100644 index 000000000..7f6f1b826 --- /dev/null +++ b/tests/unit/prompts/test_template.py @@ -0,0 +1,272 @@ +"""Unit tests for prompts template validation and rendering.""" + +import pytest + +from models.database.prompts import PromptVersion +from prompts.exceptions import PromptRenderError, PromptValidationError +from prompts.template import ( + _extract_placeholders, + _validate_variables, + render_template, + validate_template, +) + + +class TestValidateVariables: + """Tests for _validate_variables.""" + + def test_unique_variables(self) -> None: + """Return a set when all variable names are unique.""" + result = _validate_variables(["name", "age", "city"]) + assert result == {"name", "age", "city"} + + def test_empty_list(self) -> None: + """Return an empty set for an empty list.""" + result = _validate_variables([]) + assert result == set() + + def test_single_variable(self) -> None: + """Return a set with one element.""" + result = _validate_variables(["name"]) + assert result == {"name"} + + def test_duplicate_variables(self) -> None: + """Raise PromptValidationError when duplicates exist.""" + with pytest.raises(PromptValidationError, match="duplicate names"): + _validate_variables(["name", "age", "name"]) + + def test_invalid_name_starting_with_digit(self) -> None: + """Raise PromptValidationError for name starting with digit.""" + with pytest.raises(PromptValidationError, match="invalid name format"): + _validate_variables(["123bad"]) + + def test_invalid_empty_name(self) -> None: + """Raise PromptValidationError for empty string variable name.""" + with pytest.raises(PromptValidationError, match="invalid name format"): + _validate_variables([""]) + + def test_invalid_name_with_spaces(self) -> None: + """Raise PromptValidationError for name containing spaces.""" + with pytest.raises(PromptValidationError, match="invalid name format"): + _validate_variables(["foo bar"]) + + def test_invalid_name_with_dot(self) -> None: + """Raise PromptValidationError for name with dot.""" + with pytest.raises(PromptValidationError, match="invalid name format"): + _validate_variables(["user.name"]) + + def test_invalid_among_valid(self) -> None: + """Raise PromptValidationError when one invalid name is mixed with valid ones.""" + with pytest.raises(PromptValidationError, match="invalid name format"): + _validate_variables(["name", "123bad", "age"]) + + +class TestExtractPlaceholders: + """Tests for _extract_placeholders.""" + + def test_single_placeholder(self) -> None: + """Extract a single placeholder.""" + result = _extract_placeholders("Hello {{name}}!") + assert result == {"name"} + + def test_multiple_placeholders(self) -> None: + """Extract multiple distinct placeholders.""" + result = _extract_placeholders("{{greeting}}, {{name}}! You are {{age}}.") + assert result == {"greeting", "name", "age"} + + def test_repeated_placeholder(self) -> None: + """Deduplicate repeated placeholders.""" + result = _extract_placeholders("{{name}} and {{name}} again") + assert result == {"name"} + + def test_whitespace_inside_braces(self) -> None: + """Strip whitespace inside braces.""" + result = _extract_placeholders("{{ name }} and {{ age }}") + assert result == {"name", "age"} + + def test_no_placeholders(self) -> None: + """Return empty set for plain text.""" + result = _extract_placeholders("Hello world!") + assert result == set() + + def test_underscore_and_hyphen_names(self) -> None: + """Accept underscores and hyphens in names.""" + result = _extract_placeholders("{{first_name}} {{last-name}}") + assert result == {"first_name", "last-name"} + + def test_leading_underscore(self) -> None: + """Accept names starting with underscore.""" + result = _extract_placeholders("{{_private}}") + assert result == {"_private"} + + def test_incomplete_opening_braces(self) -> None: + """Treat incomplete opening braces as plain text.""" + result = _extract_placeholders("Hello {name}}") + assert result == set() + + def test_incomplete_closing_braces(self) -> None: + """Treat incomplete closing braces as plain text.""" + result = _extract_placeholders("Hello {{name}") + assert result == set() + + def test_single_brace(self) -> None: + """Treat single braces as plain text.""" + result = _extract_placeholders("Hello {name}") + assert result == set() + + def test_empty_placeholder(self) -> None: + """Raise PromptValidationError for empty placeholder.""" + with pytest.raises(PromptValidationError, match="invalid name format"): + _extract_placeholders("{{}}") + + def test_whitespace_only_placeholder(self) -> None: + """Raise PromptValidationError for whitespace-only placeholder.""" + with pytest.raises(PromptValidationError, match="invalid name format"): + _extract_placeholders("{{ }}") + + def test_name_starting_with_digit(self) -> None: + """Raise PromptValidationError for name starting with digit.""" + with pytest.raises(PromptValidationError, match="invalid name format"): + _extract_placeholders("{{123bad}}") + + def test_name_with_spaces(self) -> None: + """Raise PromptValidationError for name containing spaces.""" + with pytest.raises(PromptValidationError, match="invalid name format"): + _extract_placeholders("{{foo bar}}") + + def test_name_with_pipe(self) -> None: + """Raise PromptValidationError for Jinja-style filter syntax.""" + with pytest.raises(PromptValidationError, match="invalid name format"): + _extract_placeholders("{{name | upper}}") + + def test_name_with_dot(self) -> None: + """Raise PromptValidationError for dotted path.""" + with pytest.raises(PromptValidationError, match="invalid name format"): + _extract_placeholders("{{user.name}}") + + +class TestValidateTemplate: + """Tests for validate_template.""" + + def test_empty_template(self) -> None: + """Raise PromptValidationError for empty template text.""" + with pytest.raises(PromptValidationError, match="must not be empty"): + validate_template("", []) + + def test_whitespace_only_template(self) -> None: + """Raise PromptValidationError for whitespace-only template text.""" + with pytest.raises(PromptValidationError, match="must not be empty"): + validate_template(" ", []) + + def test_tabs_and_newlines_only_template(self) -> None: + """Raise PromptValidationError for template with only tabs and newlines.""" + with pytest.raises(PromptValidationError, match="must not be empty"): + validate_template("\t\n\r", []) + + def test_all_placeholders_declared(self) -> None: + """Pass when every placeholder has a matching variable.""" + validate_template("Hello {{name}}, age {{age}}", ["name", "age"]) + + def test_extra_declared_variables(self) -> None: + """Pass when declared variables include unused names.""" + validate_template("Hello {{name}}", ["name", "age", "city"]) + + def test_no_placeholders_no_variables(self) -> None: + """Pass for plain text with no variables.""" + validate_template("Hello world!", []) + + def test_no_placeholders_with_variables(self) -> None: + """Pass for plain text with declared but unused variables.""" + validate_template("Hello world!", ["name"]) + + def test_undeclared_placeholder(self) -> None: + """Raise PromptValidationError for undeclared placeholder.""" + with pytest.raises(PromptValidationError, match="Undeclared placeholders"): + validate_template("Hello {{name}}", []) + + def test_partially_undeclared(self) -> None: + """Raise PromptValidationError when some placeholders are undeclared.""" + with pytest.raises(PromptValidationError, match="Undeclared placeholders"): + validate_template("{{greeting}} {{name}}", ["greeting"]) + + def test_duplicate_variables_rejected(self) -> None: + """Raise PromptValidationError for duplicate variable names.""" + with pytest.raises(PromptValidationError, match="duplicate names"): + validate_template("{{name}}", ["name", "name"]) + + def test_invalid_placeholder_rejected(self) -> None: + """Raise PromptValidationError for invalid placeholder name.""" + with pytest.raises(PromptValidationError, match="invalid name format"): + validate_template("{{123}}", ["123"]) + + +def _make_prompt_version(template: str, variables: list[str]) -> PromptVersion: + """Create a PromptVersion instance for testing.""" + return PromptVersion( + prompt_id="pmpt_test", + version=1, + template=template, + variables=variables, + ) + + +class TestRenderTemplate: + """Tests for render_template.""" + + def test_simple_render(self) -> None: + """Render a template with one placeholder.""" + version = _make_prompt_version("Hello {{name}}!", ["name"]) + result = render_template(version, {"name": "Alice"}) + assert result == "Hello Alice!" + + def test_multiple_placeholders(self) -> None: + """Render a template with multiple placeholders.""" + version = _make_prompt_version("{{greeting}}, {{name}}!", ["greeting", "name"]) + result = render_template(version, {"greeting": "Hi", "name": "Bob"}) + assert result == "Hi, Bob!" + + def test_repeated_placeholder(self) -> None: + """Render a template where the same placeholder appears twice.""" + version = _make_prompt_version("{{name}} and {{name}}", ["name"]) + result = render_template(version, {"name": "Alice"}) + assert result == "Alice and Alice" + + def test_whitespace_in_placeholder(self) -> None: + """Render a template with whitespace inside braces.""" + version = _make_prompt_version("Hello {{ name }}!", ["name"]) + result = render_template(version, {"name": "Alice"}) + assert result == "Hello Alice!" + + def test_no_placeholders(self) -> None: + """Render plain text with no variables.""" + version = _make_prompt_version("Hello world!", []) + result = render_template(version, {}) + assert result == "Hello world!" + + def test_missing_variable_value(self) -> None: + """Raise PromptRenderError when a required variable is not provided.""" + version = _make_prompt_version("{{name}} {{age}}", ["name", "age"]) + with pytest.raises(PromptRenderError, match="Missing variables"): + render_template(version, {"name": "Alice"}) + + def test_does_not_modify_stored_version(self) -> None: + """Rendering does not mutate the PromptVersion object.""" + version = _make_prompt_version("Hello {{name}}!", ["name"]) + original_template = version.template + # Create a shallow copy we we're not referencing the same object + original_variables = list(version.variables) + render_template(version, {"name": "Alice"}) + assert version.template == original_template + assert version.variables == original_variables + + def test_incomplete_braces_treated_as_text(self) -> None: + """Incomplete braces in a stored template are left as plain text.""" + version = _make_prompt_version("Hello {{name} world", []) + result = render_template(version, {}) + assert result == "Hello {{name} world" + + def test_special_characters_in_value(self) -> None: + """Values containing braces or special characters are inserted literally.""" + version = _make_prompt_version("Output: {{value}}", ["value"]) + result = render_template(version, {"value": "{{not_a_placeholder}}"}) + assert result == "Output: {{not_a_placeholder}}"