Skip to content

[FIX]: Restrict PromptTemplate construction to YAML paths - #144

Open
spencrr wants to merge 2 commits into
microsoft:mainfrom
spencrr:spencrr-prompt-template-path-init
Open

[FIX]: Restrict PromptTemplate construction to YAML paths#144
spencrr wants to merge 2 commits into
microsoft:mainfrom
spencrr:spencrr-prompt-template-path-init

Conversation

@spencrr

@spencrr spencrr commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Description

This takes a simpler route than #132: instead of keeping the dataclass and adding a guarded factory path, PromptTemplate is now a plain final class whose only constructor requires a YAML Path through the named path argument.

Construction loads the file, validates the schema and parameter contract, and compiles Jinja before assigning instance state. name, description, and parameter_keys remain public as read-only properties. The compiled template stays private, and __slots__ prevents accidental attributes. Identity equality and hashing replace the dataclass's misleading structural equality.

The LLM driver and judge now use PromptTemplate(path=path). Tests cover the keyword-only path contract, component injection rejection, read-only metadata, slots, identity semantics, representation, and the existing YAML and render failure cases.

The read-only contract applies to the public API. Private slots remain conventional Python internals; this intentionally avoids adding custom runtime freezing machinery.

Breaking changes

PromptTemplate.from_yaml(path) is replaced with PromptTemplate(path=path). This API was introduced after v0.1.0 and has not shipped in a release. Direct component-wise dataclass construction is no longer accepted.

Checklist

  • pre-commit run --all-files passes
  • Tests added or updated for constructor invariants, the keyword-only path, read-only properties, identity semantics, and existing template behavior
  • Documentation updated in the API docstrings

Replace the injectable dataclass initializer with one validated Path constructor and expose template metadata through read-only properties.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@spencrr
spencrr requested a review from a team August 3, 2026 23:20
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
There may be pipelines that require an authorized user to comment /azp run to run.

Make path keyword-only so construction remains explicit at every call site.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

@nina-msft nina-msft left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like simplifying away the dataclass machinery, but I don’t think __init__(path=...) is the right boundary. It hides filesystem I/O in construction and couples PromptTemplate directly to YAML.

I’d keep __init__ source-neutral—accepting the template metadata and source text, then enforcing the Jinja/parameter invariants—and retain from_yaml(path) for file I/O and YAML parsing. That separation also leaves room for future sources such as JSON, package resources, or remote storage without adding source-specific branches to the constructor.

A shared normalized definition (e.g. PromptTemplateDefinition) could be passed into the canonical construction path, with each loader responsible only for producing that definition. This doesn’t feel messy to me; it makes the responsibilities explicit:

  • __init__: validate and compile a template.
  • from_yaml: read and deserialize YAML, then delegate.

I’d therefore prefer this shape over replacing from_yaml(path) with PromptTemplate(path=path).

e.g.

class PromptTemplateDefinition(BaseModel):
    model_config = ConfigDict(extra="forbid", frozen=True, strict=True)

    name: str
    parameters: _UniqueList[str]
    value: str
    description: str | None = None


@final
class PromptTemplate:
    __slots__ = ("_description", "_name", "_parameter_keys", "_template")

    def __init__(
        self,
        *,
        definition: PromptTemplateDefinition,
        source: str = "<memory>",
    ) -> None:
        self._template = _compile_template(definition, source=source)
        self._name = definition.name
        self._description = definition.description
        self._parameter_keys = tuple(definition.parameters)

    @classmethod
    def from_yaml(cls, path: Path) -> Self:
        try:
            with path.open(encoding="utf-8") as stream:
                data = yaml.safe_load(stream)
            definition = PromptTemplateDefinition.model_validate(data)
        except (UnicodeError, yaml.YAMLError, ValidationError) as exc:
            raise PromptTemplateDefinitionError(
                source=str(path),
                details=str(exc),
            ) from exc

        return cls(definition=definition, source=str(path))

Then another source only needs to produce the normalized definition:

definition = PromptTemplateDefinition.model_validate(json_data)
template = PromptTemplate(definition=definition, source="config.json")

This keeps parsing and I/O out of __init__, preserves one invariant-enforcing construction path, and avoids changing PromptTemplate whenever a new source is introduced.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants