[FIX]: Restrict PromptTemplate construction to YAML paths - #144
Conversation
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>
|
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
left a comment
There was a problem hiding this comment.
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.
Description
This takes a simpler route than #132: instead of keeping the dataclass and adding a guarded factory path,
PromptTemplateis now a plain final class whose only constructor requires a YAMLPaththrough the namedpathargument.Construction loads the file, validates the schema and parameter contract, and compiles Jinja before assigning instance state.
name,description, andparameter_keysremain 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 withPromptTemplate(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-filespasses