From 02f5148a77bc7d5495735a3e80bbb49ff74f97cc Mon Sep 17 00:00:00 2001 From: Jazzcort Date: Wed, 28 Oct 2026 15:45:31 -0400 Subject: [PATCH 1/4] LCORE-4741: Add prompts module skeleton and persistence models Introduce Prompt and PromptVersion ORM models for the local prompt catalog. Prompt holds identity and a default-version pointer; PromptVersion uses a (prompt_id, version) composite primary key for immutable version rows with template text and declared variables. Includes the prompts module skeleton with domain exceptions and unit tests covering schema definition, constraints, and variable validation. --- src/models/database/prompts.py | 60 ++++ src/prompts/__init__.py | 1 + src/prompts/exceptions.py | 9 + tests/unit/models/database/test_prompts.py | 311 +++++++++++++++++++++ 4 files changed, 381 insertions(+) create mode 100644 src/models/database/prompts.py create mode 100644 src/prompts/__init__.py create mode 100644 src/prompts/exceptions.py create mode 100644 tests/unit/models/database/test_prompts.py diff --git a/src/models/database/prompts.py b/src/models/database/prompts.py new file mode 100644 index 0000000000..a10d979134 --- /dev/null +++ b/src/models/database/prompts.py @@ -0,0 +1,60 @@ +"""Persistence models for the versioned prompt catalog.""" + +from datetime import datetime + +from sqlalchemy import JSON, DateTime, ForeignKey, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column, validates + +from models.database.base import Base +from prompts.exceptions import PromptValidationError + + +class Prompt(Base): # pylint: disable=too-few-public-methods + """Prompt identity with a pointer to the default version.""" + + __tablename__ = "prompt" + + id: Mapped[str] = mapped_column(primary_key=True) + + name: Mapped[str] = mapped_column(String(255)) + + default_version: Mapped[int] = mapped_column() + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + +class PromptVersion(Base): # pylint: disable=too-few-public-methods + """Immutable version row containing the template text and declared variables.""" + + __tablename__ = "prompt_version" + + # Foreign key is not turned on by default for sqlite, but a PromptVersion will always + # be created after a Prompt is created in the database operation logic. Therefore, + # a PromptVersion with a nonexistent prompt.id will never be created. + prompt_id: Mapped[str] = mapped_column( + ForeignKey("prompt.id", ondelete="CASCADE"), primary_key=True + ) + + version: Mapped[int] = mapped_column(primary_key=True) + + template: Mapped[str] = mapped_column(Text()) + + variables: Mapped[list[str]] = mapped_column(JSON, default=list) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + + @validates("variables") + def validate_unique_variables(self, _key: str, value: list[str]) -> list[str]: + """Ensure no duplicate variable names.""" + if len(value) != len(set(value)): + raise PromptValidationError("The variables contain duplicate names") + + return value diff --git a/src/prompts/__init__.py b/src/prompts/__init__.py new file mode 100644 index 0000000000..14fcd39dd1 --- /dev/null +++ b/src/prompts/__init__.py @@ -0,0 +1 @@ +"""Prompts module for the versioned prompt catalog.""" diff --git a/src/prompts/exceptions.py b/src/prompts/exceptions.py new file mode 100644 index 0000000000..8b23ce1133 --- /dev/null +++ b/src/prompts/exceptions.py @@ -0,0 +1,9 @@ +"""Domain exceptions for the prompts module.""" + + +class PromptError(Exception): + """Base class for prompt domain errors.""" + + +class PromptValidationError(PromptError): + """Invalid prompt field values.""" diff --git a/tests/unit/models/database/test_prompts.py b/tests/unit/models/database/test_prompts.py new file mode 100644 index 0000000000..6e998d20ab --- /dev/null +++ b/tests/unit/models/database/test_prompts.py @@ -0,0 +1,311 @@ +"""Unit tests for Prompt and PromptVersion database models.""" + +from collections.abc import Generator + +import pytest +from sqlalchemy import String, create_engine, inspect, text +from sqlalchemy.engine import Engine +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.pool import StaticPool + +from models.database.base import Base +from models.database.prompts import Prompt, PromptVersion +from prompts.exceptions import PromptValidationError + + +@pytest.fixture(name="sqlite_engine") +def sqlite_engine_fixture() -> Generator[Engine, None, None]: + """Provide an in-memory SQLite engine with all tables created. + + Yields: + Engine: A SQLAlchemy Engine bound to an in-memory SQLite database with + tables created from Base.metadata. + + """ + engine = create_engine( + "sqlite:///:memory:", + echo=False, + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(engine) + yield engine + engine.dispose() + + +@pytest.fixture(name="db_session") +def db_session_fixture(sqlite_engine: Engine) -> Generator[Session, None, None]: + """Provide a database session for tests. + + Parameters: + sqlite_engine: In-memory SQLite engine fixture. + + Yields: + Session: A SQLAlchemy Session bound to the test engine. + + """ + session_factory = sessionmaker( + autocommit=False, autoflush=False, bind=sqlite_engine + ) + session = session_factory() + try: + yield session + finally: + session.close() + + +@pytest.fixture(name="sample_prompt") +def sample_prompt_fixture(db_session: Session) -> Prompt: + """Insert and return a sample Prompt row. + + Parameters: + db_session: Active database session. + + Returns: + Prompt: The persisted prompt instance. + + """ + prompt = Prompt(id="pmpt_001", name="greeting", default_version=1) + db_session.add(prompt) + db_session.commit() + return prompt + + +class TestPromptModelDefinition: + """Test cases for Prompt model schema definition.""" + + def test_tablename(self) -> None: + """Test Prompt uses the expected table name.""" + assert Prompt.__tablename__ == "prompt" + + def test_expected_columns_exist(self) -> None: + """Test Prompt defines all expected columns.""" + column_names = {col.name for col in Prompt.__table__.columns} + expected = {"id", "name", "default_version", "created_at", "updated_at"} + assert column_names == expected + + def test_name_column_max_length(self) -> None: + """Test name column has max length 255.""" + name_column = Prompt.__table__.c.name + assert isinstance(name_column.type, String) + assert name_column.type.length == 255 + + def test_id_is_primary_key(self) -> None: + """Test id column is the primary key.""" + assert Prompt.__table__.c.id.primary_key + + +class TestPromptVersionModelDefinition: + """Test cases for PromptVersion model schema definition.""" + + def test_tablename(self) -> None: + """Test PromptVersion uses the expected table name.""" + assert PromptVersion.__tablename__ == "prompt_version" + + def test_expected_columns_exist(self) -> None: + """Test PromptVersion defines all expected columns.""" + column_names = {col.name for col in PromptVersion.__table__.columns} + expected = {"prompt_id", "version", "template", "variables", "created_at"} + assert column_names == expected + + def test_composite_primary_key(self) -> None: + """Test primary key is (prompt_id, version).""" + pk_columns = {col.name for col in PromptVersion.__table__.primary_key.columns} + assert pk_columns == {"prompt_id", "version"} + + def test_prompt_id_has_foreign_key(self) -> None: + """Test prompt_id references prompt.id.""" + fk_targets = { + fk.target_fullname + for fk in PromptVersion.__table__.c.prompt_id.foreign_keys + } + assert "prompt.id" in fk_targets + + +class TestPromptDatabaseOperations: + """Test cases for Prompt database operations.""" + + def test_tables_created_by_metadata(self, sqlite_engine: Engine) -> None: + """Test prompt and prompt_version tables are created via Base.metadata.""" + inspector = inspect(sqlite_engine) + table_names = inspector.get_table_names() + assert "prompt" in table_names + assert "prompt_version" in table_names + + def test_insert_and_read_prompt(self, db_session: Session) -> None: + """Test inserting and reading back a Prompt row.""" + prompt = Prompt(id="pmpt_001", name="greeting", default_version=1) + db_session.add(prompt) + db_session.commit() + + result = db_session.get(Prompt, "pmpt_001") + assert result is not None + assert result.id == "pmpt_001" + assert result.name == "greeting" + assert result.default_version == 1 + assert result.created_at is not None + assert result.updated_at is not None + + def test_duplicate_prompt_id_raises_integrity_error( + self, db_session: Session + ) -> None: + """Test inserting duplicate prompt id raises IntegrityError.""" + db_session.add(Prompt(id="pmpt_001", name="first", default_version=1)) + db_session.commit() + + db_session.add(Prompt(id="pmpt_001", name="second", default_version=1)) + with pytest.raises(IntegrityError): + db_session.commit() + + +class TestPromptVersionDatabaseOperations: + """Test cases for PromptVersion database operations.""" + + def test_insert_and_read_version( + self, db_session: Session, sample_prompt: Prompt + ) -> None: + """Test inserting and reading back a PromptVersion row.""" + version = PromptVersion( + prompt_id=sample_prompt.id, + version=1, + template="Hello {{name}}", + variables=["name"], + ) + db_session.add(version) + db_session.commit() + + result = db_session.get(PromptVersion, (sample_prompt.id, 1)) + assert result is not None + assert result.prompt_id == sample_prompt.id + assert result.version == 1 + assert result.template == "Hello {{name}}" + assert result.variables == ["name"] + assert result.created_at is not None + + def test_multiple_versions_for_same_prompt( + self, db_session: Session, sample_prompt: Prompt + ) -> None: + """Test that a prompt can have multiple versions.""" + v1 = PromptVersion( + prompt_id=sample_prompt.id, + version=1, + template="Hello {{name}}", + variables=["name"], + ) + v2 = PromptVersion( + prompt_id=sample_prompt.id, + version=2, + template="Hi {{name}}, welcome to {{place}}", + variables=["name", "place"], + ) + db_session.add_all([v1, v2]) + db_session.commit() + + results = ( + db_session.query(PromptVersion) + .filter_by(prompt_id=sample_prompt.id) + .order_by(PromptVersion.version) + .all() + ) + assert len(results) == 2 + assert results[0].version == 1 + assert results[1].version == 2 + + def test_duplicate_prompt_id_and_version_raises_integrity_error( + self, db_session: Session, sample_prompt: Prompt + ) -> None: + """Test inserting duplicate (prompt_id, version) raises IntegrityError.""" + db_session.add( + PromptVersion( + prompt_id=sample_prompt.id, + version=1, + template="first", + variables=[], + ) + ) + db_session.commit() + + db_session.add( + PromptVersion( + prompt_id=sample_prompt.id, + version=1, + template="second", + variables=[], + ) + ) + with pytest.raises(IntegrityError): + db_session.commit() + + def test_foreign_key_enforced(self, db_session: Session) -> None: + """Test inserting a version for a non-existent prompt raises IntegrityError.""" + db_session.execute(text("PRAGMA foreign_keys = ON")) + version = PromptVersion( + prompt_id="nonexistent", + version=1, + template="Hello", + variables=[], + ) + db_session.add(version) + with pytest.raises(IntegrityError): + db_session.commit() + + def test_empty_variables_defaults_to_list( + self, db_session: Session, sample_prompt: Prompt + ) -> None: + """Test that variables defaults to an empty list.""" + version = PromptVersion( + prompt_id=sample_prompt.id, + version=1, + template="No placeholders here", + ) + db_session.add(version) + db_session.commit() + + result = db_session.get(PromptVersion, (sample_prompt.id, 1)) + assert result is not None + assert result.variables == [] + + +class TestPromptVersionVariableValidation: + """Test cases for PromptVersion variable uniqueness validation.""" + + def test_unique_variables_accepted(self) -> None: + """Test that a list of unique variable names is accepted.""" + version = PromptVersion( + prompt_id="pmpt_001", + version=1, + template="{{a}} {{b}}", + variables=["a", "b"], + ) + assert version.variables == ["a", "b"] + + def test_duplicate_variables_raises_validation_error(self) -> None: + """Test that duplicate variable names raise PromptValidationError.""" + with pytest.raises(PromptValidationError, match="duplicate names"): + PromptVersion( + prompt_id="pmpt_001", + version=1, + template="{{a}} {{a}}", + variables=["a", "a"], + ) + + def test_empty_variables_accepted(self) -> None: + """Test that an empty variable list is accepted.""" + version = PromptVersion( + prompt_id="pmpt_001", + version=1, + template="No variables", + variables=[], + ) + assert version.variables == [] + + def test_single_variable_accepted(self) -> None: + """Test that a single variable is accepted.""" + version = PromptVersion( + prompt_id="pmpt_001", + version=1, + template="{{name}}", + variables=["name"], + ) + assert version.variables == ["name"] From 7236ed2791949d8151e3cc7ed3258038142d406a Mon Sep 17 00:00:00 2001 From: Jazzcort Date: Thu, 29 Oct 2026 09:39:26 -0400 Subject: [PATCH 2/4] Import Prompt and PromptVersion at __init__.py This makes sure both tables are registered when we import Base at src/app/database.py. All the other tables (i.e. SavedPrompt, UserConversation, and UserTurn) are registered transitively, so it will be better we also import those tables at __init__.py. --- src/models/database/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/models/database/__init__.py b/src/models/database/__init__.py index a01a86e5c3..9a4d012fbb 100644 --- a/src/models/database/__init__.py +++ b/src/models/database/__init__.py @@ -1 +1,5 @@ """Database models package.""" + +from models.database.prompts import Prompt, PromptVersion + +__all__ = ["Prompt", "PromptVersion"] From 15661787d43ba45f352e7f0a4f5b870f63377bf0 Mon Sep 17 00:00:00 2001 From: Jazzcort Date: Mon, 2 Nov 2026 15:53:42 -0500 Subject: [PATCH 3/4] LCORE-4742: Implement prompt store operations Implement CRUD operations in the prompts module: create (mint pmpt_ id, version 1 as default), get by id with optional version (falls back to default), list all prompts, update (appends immutable version, optionally sets as default), and delete (prompt and all versions). Domain errors surface PromptNotFoundError and PromptValidationError for endpoint handlers to map to HTTP responses. Unit tests cover happy paths, not-found cases, and validation errors. --- src/models/database/prompts.py | 11 +- src/prompts/exceptions.py | 4 + src/prompts/store.py | 201 ++++++++++++++++++ tests/unit/prompts/__init__.py | 1 + tests/unit/prompts/test_store.py | 354 +++++++++++++++++++++++++++++++ 5 files changed, 561 insertions(+), 10 deletions(-) create mode 100644 src/prompts/store.py create mode 100644 tests/unit/prompts/__init__.py create mode 100644 tests/unit/prompts/test_store.py diff --git a/src/models/database/prompts.py b/src/models/database/prompts.py index a10d979134..4368174a6e 100644 --- a/src/models/database/prompts.py +++ b/src/models/database/prompts.py @@ -3,10 +3,9 @@ from datetime import datetime from sqlalchemy import JSON, DateTime, ForeignKey, String, Text, func -from sqlalchemy.orm import Mapped, mapped_column, validates +from sqlalchemy.orm import Mapped, mapped_column from models.database.base import Base -from prompts.exceptions import PromptValidationError class Prompt(Base): # pylint: disable=too-few-public-methods @@ -50,11 +49,3 @@ class PromptVersion(Base): # pylint: disable=too-few-public-methods created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now() ) - - @validates("variables") - def validate_unique_variables(self, _key: str, value: list[str]) -> list[str]: - """Ensure no duplicate variable names.""" - if len(value) != len(set(value)): - raise PromptValidationError("The variables contain duplicate names") - - return value diff --git a/src/prompts/exceptions.py b/src/prompts/exceptions.py index 8b23ce1133..109bdec588 100644 --- a/src/prompts/exceptions.py +++ b/src/prompts/exceptions.py @@ -7,3 +7,7 @@ class PromptError(Exception): class PromptValidationError(PromptError): """Invalid prompt field values.""" + + +class PromptNotFoundError(PromptError): + """No prompt exists for the given identifier.""" diff --git a/src/prompts/store.py b/src/prompts/store.py new file mode 100644 index 0000000000..7fc0425e7e --- /dev/null +++ b/src/prompts/store.py @@ -0,0 +1,201 @@ +"""Store operations for the versioned prompt catalog.""" + +from typing import Optional +from uuid import uuid4 + +from app.database import get_session +from log import get_logger +from models.database.prompts import Prompt, PromptVersion +from prompts.exceptions import PromptNotFoundError, PromptValidationError + +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. + + Parameters: + name: Display name for the prompt. + template: Mustache template text. + variables: Declared variable names for the template. + + Returns: + PromptVersion: The initial version row. + + Raises: + PromptValidationError: If variables contain duplicates. + + """ + # TODO: template validation + + _validate_variables(variables) + + prompt_id = f"pmpt_{uuid4()}" + version = 1 + + with get_session() as session: + prompt = Prompt(id=prompt_id, name=name, default_version=version) + prompt_version = PromptVersion( + prompt_id=prompt_id, version=version, template=template, variables=variables + ) + + session.add(prompt) + session.add(prompt_version) + session.commit() + logger.debug("Created prompt id=%s with version %s", prompt_id, version) + session.refresh(prompt_version) + + return prompt_version + + +def get_prompt(prompt_id: str, version: Optional[int] = None) -> PromptVersion: + """Retrieve a prompt version by identifier. + + When version is None the prompt's default version is used. + + Parameters: + prompt_id: The prompt identifier. + version: Specific version number, or None for the default. + + Returns: + PromptVersion: The matching version row. + + Raises: + PromptNotFoundError: If the prompt or requested version does not exist. + + """ + with get_session() as session: + + # Grab default version from Prompt if version is not provided + if version is None: + prompt = session.query(Prompt).filter_by(id=prompt_id).first() + if prompt is None: + logger.debug("Prompt id=%s not found", prompt_id) + raise PromptNotFoundError(f"Prompt id={prompt_id} not found") + + version = prompt.default_version + + prompt_version = ( + session.query(PromptVersion) + .filter_by(prompt_id=prompt_id, version=version) + .first() + ) + + if prompt_version is None: + logger.debug("Version %s of prompt id=%s not found", version, prompt_id) + raise PromptNotFoundError( + f"Version {version} of prompt id={prompt_id} not found" + ) + + return prompt_version + + +def list_prompts() -> list[Prompt]: + """List all prompts in the catalog. + + Returns: + list[Prompt]: All prompt identity rows. + + """ + with get_session() as session: + prompts = session.query(Prompt).all() + return prompts + + +def update_prompt( + prompt_id: str, template: str, variables: list[str], *, default: bool = False +) -> PromptVersion: + """Create a new immutable version for an existing prompt. + + Parameters: + prompt_id: The prompt identifier. + template: Mustache template text for the new version. + variables: Declared variable names for the template. + default: If True, set the new version as the prompt's default. + + Returns: + PromptVersion: The newly created version row. + + Raises: + PromptNotFoundError: If the prompt does not exist. + PromptValidationError: If variables contain duplicates. + + """ + # TODO: validate template + + _validate_variables(variables) + + with get_session() as session: + max_prompt_version = ( + session.query(PromptVersion) + .filter_by(prompt_id=prompt_id) + .order_by(PromptVersion.version.desc()) + .first() + ) + if max_prompt_version is None: + logger.debug("No available version found for prompt id=%s", prompt_id) + raise PromptNotFoundError( + f"No available version found for prompt id={prompt_id}" + ) + + max_version = max_prompt_version.version + + prompt_version = PromptVersion( + prompt_id=prompt_id, + version=max_version + 1, + template=template, + variables=variables, + ) + session.add(prompt_version) + logger.debug( + "Created version %s for prompt id=%s", prompt_version.version, prompt_id + ) + + if default: + prompt = session.query(Prompt).filter_by(id=prompt_id).first() + if prompt is None: + logger.debug("Prompt id=%s not found", prompt_id) + raise PromptNotFoundError(f"Prompt id={prompt_id} not found") + + prompt.default_version = prompt_version.version + + session.commit() + session.refresh(prompt_version) + + return prompt_version + + +def delete_prompt(prompt_id: str) -> None: + """Delete a prompt and all its versions. + + Parameters: + prompt_id: The prompt identifier. + + Raises: + PromptNotFoundError: If the prompt does not exist. + + """ + with get_session() as session: + prompt = session.query(Prompt).filter_by(id=prompt_id).first() + if prompt is None: + raise PromptNotFoundError(f"Prompt id={prompt_id} not found") + + # This cover the situation when FK enforcement is not on + session.query(PromptVersion).filter_by(prompt_id=prompt_id).delete() + session.delete(prompt) + session.commit() + logger.debug("Deleted prompt id=%s", prompt_id) diff --git a/tests/unit/prompts/__init__.py b/tests/unit/prompts/__init__.py new file mode 100644 index 0000000000..6fc051c4a2 --- /dev/null +++ b/tests/unit/prompts/__init__.py @@ -0,0 +1 @@ +"""Unit tests for the prompts module.""" diff --git a/tests/unit/prompts/test_store.py b/tests/unit/prompts/test_store.py new file mode 100644 index 0000000000..2fb072bab4 --- /dev/null +++ b/tests/unit/prompts/test_store.py @@ -0,0 +1,354 @@ +"""Unit tests for prompts store operations.""" + +from collections.abc import Generator +from contextlib import contextmanager +from typing import Literal, Optional, overload + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.engine import Engine +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.pool import StaticPool + +from models.database.base import Base +from models.database.prompts import Prompt, PromptVersion +from prompts.exceptions import PromptNotFoundError, PromptValidationError +from prompts.store import ( + create_prompt, + delete_prompt, + get_prompt, + list_prompts, + update_prompt, +) + +DUMMY_CREATE_PROMPT_PARAMS = ( + "test-prompt", + "Hello {{name}}! {{greeting}}!", + ["name", "greeting"], +) +DUMMY_UPDATE_PROMPT_PARAMS = ("{{a}} {{b}}", ["a", "b"]) +DUMMY_ID = "pmpt_testing" + + +@overload +def _setup_db( + session: Session, + with_v2: Literal[True], +) -> tuple[Prompt, PromptVersion, PromptVersion]: ... + + +@overload +def _setup_db( + session: Session, with_v2: Literal[False] = False +) -> tuple[Prompt, PromptVersion, None]: ... + + +def _setup_db( + session: Session, with_v2: bool = False +) -> tuple[Prompt, PromptVersion, Optional[PromptVersion]]: + """Insert a Prompt with one or two versions for test setup. + + Parameters: + session: Active database session. + with_v2: If True, also insert a second version. + + Returns: + A tuple of (Prompt, PromptVersion v1, PromptVersion v2 or None). + + """ + name, template_v1, variables_v1 = DUMMY_CREATE_PROMPT_PARAMS + template_v2, variables_v2 = DUMMY_UPDATE_PROMPT_PARAMS + version = 1 + + prompt = Prompt(id=DUMMY_ID, name=name, default_version=version) + prompt_version_v1 = PromptVersion( + prompt_id=DUMMY_ID, + version=version, + template=template_v1, + variables=variables_v1, + ) + + session.add(prompt) + session.add(prompt_version_v1) + session.commit() + + session.refresh(prompt) + session.refresh(prompt_version_v1) + + if with_v2: + prompt_version_v2 = PromptVersion( + prompt_id=DUMMY_ID, + version=version + 1, + template=template_v2, + variables=variables_v2, + ) + session.add(prompt_version_v2) + session.commit() + session.refresh(prompt_version_v2) + + return (prompt, prompt_version_v1, prompt_version_v2) + + return (prompt, prompt_version_v1, None) + + +@pytest.fixture(name="sqlite_engine") +def sqlite_engine_fixture() -> Generator[Engine, None, None]: + """Provide an in-memory SQLite engine with all tables created. + + Yields: + Engine: A SQLAlchemy Engine bound to an in-memory SQLite database with + tables created from Base.metadata. + + """ + engine = create_engine( + "sqlite:///:memory:", + echo=False, + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(engine) + yield engine + engine.dispose() + + +@pytest.fixture(name="db_session") +def db_session_fixture(mocker, sqlite_engine: Engine) -> Generator[Session, None, None]: + """Provide a database session for tests. + + Parameters: + sqlite_engine: In-memory SQLite engine fixture. + + Yields: + Session: A SQLAlchemy Session bound to the test engine. + + """ + session_factory = sessionmaker( + autocommit=False, autoflush=False, bind=sqlite_engine + ) + session = session_factory() + + @contextmanager + def mock_get_session(): + yield session + + mocker.patch("prompts.store.get_session", mock_get_session) + + try: + yield session + finally: + session.close() + + +class TestPromptStoreOperation: + """Test cases for prompt store CRUD operations.""" + + def test_create_prompt(self, db_session: Session) -> None: + """Test creating a prompt inserts both Prompt and PromptVersion rows.""" + name, template, variables = DUMMY_CREATE_PROMPT_PARAMS + + prompt_version = create_prompt(*DUMMY_CREATE_PROMPT_PARAMS) + + # Prompt + prompt_qy_result = ( + db_session.query(Prompt).filter_by(id=prompt_version.prompt_id).all() + ) + assert len(prompt_qy_result) == 1 + prompt_in_db = prompt_qy_result[0] + + assert prompt_in_db.name == name + assert prompt_in_db.default_version == 1 + + # PromptVersion + prompt_version_qy_result = ( + db_session.query(PromptVersion) + .filter_by(prompt_id=prompt_version.prompt_id) + .all() + ) + assert len(prompt_version_qy_result) == 1 + prompt_version_in_db = prompt_version_qy_result[0] + + assert prompt_version_in_db.created_at == prompt_version.created_at + assert prompt_version_in_db.template == template + assert prompt_version_in_db.variables == variables + assert prompt_version_in_db.version == 1 + + def test_create_prompt_with_invalid_variables(self, db_session: Session) -> None: + """Test creating a prompt with duplicate variables raises PromptValidationError.""" + name, template, _ = DUMMY_CREATE_PROMPT_PARAMS + variables = ["name", "name"] + with pytest.raises(PromptValidationError, match="duplicate names"): + create_prompt(name, template, variables) + + 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) + + # Version 1 + prompt_version_v1 = get_prompt(prompt.id) + assert prompt_version_v1.version == expected_pv_v1.version + assert prompt_version_v1.template == expected_pv_v1.template + assert prompt_version_v1.variables == expected_pv_v1.variables + assert prompt_version_v1.prompt_id == expected_pv_v1.prompt_id + assert prompt_version_v1.created_at == expected_pv_v1.created_at + + # Version 2 + prompt_version_v2 = get_prompt(prompt.id, expected_pv_v2.version) + assert prompt_version_v2.version == expected_pv_v2.version + assert prompt_version_v2.template == expected_pv_v2.template + assert prompt_version_v2.variables == expected_pv_v2.variables + assert prompt_version_v2.prompt_id == expected_pv_v2.prompt_id + + def test_get_prompt_with_invalid_version(self, db_session: Session) -> None: + """Test retrieving a nonexistent version raises PromptNotFoundError.""" + prompt, _, _ = _setup_db(db_session) + + with pytest.raises( + PromptNotFoundError, match=r"Version \d+ of prompt id=\bpmpt_\w+ not found" + ): + get_prompt(prompt.id, 101) + + def test_get_prompt_with_nonexistent_id(self, db_session: Session) -> None: + """Test retrieving a prompt with a nonexistent id raises PromptNotFoundError.""" + _setup_db(db_session) + + with pytest.raises( + PromptNotFoundError, match=r"Prompt id=\bpmpt_\w+ not found" + ): + get_prompt("pmpt_nonexistent") + + def test_update_prompt(self, db_session: Session) -> None: + """Test updating a prompt appends an immutable version and optionally sets default.""" + prompt, pv_1, _ = _setup_db(db_session) + template_v2, variables_v2 = DUMMY_UPDATE_PROMPT_PARAMS + + # Since the session is not closed during the entire test, we need to save the values + # before the operation for the comparison. Otherwise, we will always get the newest + # values by the lazily reload. + ori_default_version = prompt.default_version + + cur_pmpt_version_counts = ( + db_session.query(PromptVersion).filter_by(prompt_id=prompt.id).count() + ) + # Only one PromptVersion exist before the update + assert cur_pmpt_version_counts == 1 + + pv_2 = update_prompt(prompt.id, *DUMMY_UPDATE_PROMPT_PARAMS) + + # Without default=True, the default_version should stay the same + assert prompt.default_version == ori_default_version + + cur_pmpt_version_counts = ( + db_session.query(PromptVersion).filter_by(prompt_id=prompt.id).count() + ) + # update_prompt creates a new PromptVersion in the database + assert cur_pmpt_version_counts == 2 + + assert pv_2.version == pv_1.version + 1 + assert pv_2.prompt_id == prompt.id + assert pv_2.template == template_v2 + assert pv_2.variables == variables_v2 + + template_v3, variables_v3 = ("{{c}} {{d}}", ["c", "d"]) + pv_3 = update_prompt(prompt.id, template_v3, variables_v3, default=True) + # With default=True, the default version should be updated to the new version + assert prompt.default_version == pv_3.version + + assert pv_3.template == template_v3 + assert pv_3.variables == variables_v3 + + def test_update_prompt_with_no_prompt_version_exists( + self, db_session: Session + ) -> None: + """Test updating a prompt with no existing versions raises PromptNotFoundError.""" + prompt, _, _ = _setup_db(db_session) + + db_session.query(PromptVersion).filter_by(prompt_id=prompt.id).delete() + + with pytest.raises( + PromptNotFoundError, + match=r"No available version found for prompt id=\bpmpt_\w+", + ): + update_prompt(prompt.id, "{{x}} {{y}}", ["x", "y"]) + + def test_update_prompt_with_no_prompt_exists_when_default_is_set_to_true( + self, db_session: Session + ) -> None: + """Test updating with default=True when prompt is deleted raises PromptNotFoundError.""" + prompt, _, _ = _setup_db(db_session) + + db_session.query(Prompt).filter_by(id=prompt.id).delete() + + with pytest.raises( + PromptNotFoundError, match=r"Prompt id=\bpmpt_\w+ not found" + ): + update_prompt(prompt.id, "{{x}} {{y}}", ["x", "y"], default=True) + + def test_update_prompt_with_duplicate_variables(self, db_session: Session) -> None: + """Test updating a prompt with duplicate variables raises PromptValidationError.""" + prompt, _, _ = _setup_db(db_session) + + with pytest.raises(PromptValidationError, match="duplicate names"): + update_prompt(prompt.id, "{{x}} {{x}}", ["x", "x"]) + + def test_list_prompts(self, db_session: Session) -> None: + """Test listing prompts returns all prompt identities.""" + _setup_db(db_session, True) + + prompts = list_prompts() + + assert len(prompts) == 1 + + second_prompt_id = "pmpt_testing2" + second_prompt_name = "Second Prompt" + new_prompt = Prompt( + id=second_prompt_id, name=second_prompt_name, default_version=1 + ) + db_session.add(new_prompt) + db_session.commit() + + prompts = list_prompts() + + assert len(prompts) == 2 + filtered_prompts = [pv for pv in prompts if pv.id == second_prompt_id] + assert len(filtered_prompts) == 1 + second_prompt = filtered_prompts[0] + assert second_prompt.name == second_prompt_name + + def test_list_prompts_empty(self, db_session: Session) -> None: + """Test listing prompts returns an empty list when no prompts exist.""" + prompts = list_prompts() + + assert len(prompts) == 0 + + def test_delete_prompt(self, db_session: Session) -> None: + """Test deleting a prompt removes the prompt and all its versions.""" + prompt, _, _ = _setup_db(db_session, True) + + prompt_counts = db_session.query(Prompt).filter_by(id=prompt.id).count() + prompt_version_counts = ( + db_session.query(PromptVersion).filter_by(prompt_id=prompt.id).count() + ) + + assert prompt_counts == 1 + assert prompt_version_counts == 2 + + delete_prompt(prompt.id) + + prompt_counts = db_session.query(Prompt).filter_by(id=prompt.id).count() + prompt_version_counts = ( + db_session.query(PromptVersion).filter_by(prompt_id=prompt.id).count() + ) + + assert prompt_counts == 0 + assert prompt_version_counts == 0 + + def test_delete_prompt_with_nonexistent_prompt_id( + self, db_session: Session + ) -> None: + """Test deleting a nonexistent prompt raises PromptNotFoundError.""" + _setup_db(db_session) + + with pytest.raises( + PromptNotFoundError, match=r"Prompt id=\bpmpt_\w+ not found" + ): + delete_prompt("pmpt_nonexistent") From 9b4ea4006bf9c1ba846180db3254fff1f1dceaf4 Mon Sep 17 00:00:00 2001 From: Jazzcort Date: Wed, 4 Nov 2026 15:51:12 -0500 Subject: [PATCH 4/4] LCORE-4743: Add Mustache-style template validation and rendering Implement {{name}} placeholder validation on create/update and text-only substitution at render time. Empty templates, invalid placeholder names, and missing or extra variables are rejected with clear errors. --- src/prompts/exceptions.py | 4 + src/prompts/store.py | 33 +--- src/prompts/template.py | 131 +++++++++++++ tests/unit/prompts/test_store.py | 12 ++ tests/unit/prompts/test_template.py | 278 ++++++++++++++++++++++++++++ 5 files changed, 435 insertions(+), 23 deletions(-) create mode 100644 src/prompts/template.py create mode 100644 tests/unit/prompts/test_template.py diff --git a/src/prompts/exceptions.py b/src/prompts/exceptions.py index 109bdec588..557311991c 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 7fc0425e7e..1386ddeda0 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 - - _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 - - _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 0000000000..271b5e83f1 --- /dev/null +++ b/src/prompts/template.py @@ -0,0 +1,131 @@ +"""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}") + + extra = provided - declared + if extra: + raise PromptRenderError(f"Unexpected variables: {extra}") + + 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 2fb072bab4..25d06e1b82 100644 --- a/tests/unit/prompts/test_store.py +++ b/tests/unit/prompts/test_store.py @@ -179,6 +179,11 @@ 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.""" + 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) @@ -290,6 +295,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 0000000000..50798e50a2 --- /dev/null +++ b/tests/unit/prompts/test_template.py @@ -0,0 +1,278 @@ +"""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_extra_variable_value(self) -> None: + """Raise PromptRenderError when extra variables are provided.""" + version = _make_prompt_version("{{name}}", ["name"]) + with pytest.raises(PromptRenderError, match="Unexpected variables"): + render_template(version, {"name": "Alice", "age": "30"}) + + 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}}"