Skip to content

[FIX]: Enforce PromptTemplate construction invariants - #132

Closed
Spencer Schoenberg (spencrr) wants to merge 1 commit into
microsoft:mainfrom
spencrr:dev/spencrr/fixup-template-init
Closed

Spencer Schoenberg (spencrr) wants to merge 1 commit into
microsoft:mainfrom
spencrr:dev/spencrr/fixup-template-init

Conversation

@spencrr

@spencrr Spencer Schoenberg (spencrr) commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Description

Fixes construction and equality invariants for PromptTemplate introduced in #120.

The dataclass-generated initializer exposed every field, including _template. A leading underscore is only a Python naming convention, so a caller could combine declared parameter metadata with an unrelated Jinja template and bypass PromptTemplate.from_yaml() validation:

PromptTemplate(
    name="x",
    description=None,
    parameter_keys=("declared",),
    _template=Template("{{ actual }}"),
).render(declared="provided")

This renders an empty string: argument validation succeeds against parameter_keys, while the injected template references a different variable and was compiled outside RAMPART's StrictUndefined environment.

This PR:

  • disables the generated dataclass initializer and explicitly rejects direct construction, including the otherwise-valid empty PromptTemplate() call left available by init=False alone;
  • creates instances only from a schema-validated YAML definition;
  • compiles the Jinja template inside the validated-definition factory so the declared parameters and compiled behavior cannot drift independently;
  • uses object.__new__() and object.__setattr__() for the narrow initialization phase required by a frozen, slotted dataclass;
  • disables generated structural equality, which previously treated templates with identical metadata but different bodies as equal because _template was excluded from comparison, while preserving identity-based hashing; and
  • marks PromptTemplate as final for static type checkers and IDEs.

PromptTemplate.from_yaml(path) remains the supported construction API and retains its Self return type. Separate instances now use identity equality:

first = PromptTemplate.from_yaml(path)
second = PromptTemplate.from_yaml(path)

assert first is not second
assert first != second
assert len({first, second}) == 2

Breaking changes

Direct PromptTemplate(...) construction is now rejected. Use PromptTemplate.from_yaml(path) instead. Equality between separate instances is now identity-based rather than metadata-based. Existing RAMPART production call sites already construct templates through from_yaml().

Checklist

  • pre-commit run --all-files passes
  • Tests added or updated for changes: direct construction, the former generated-constructor arguments, identity equality, and hashability
  • Documentation updated: factory-only construction and migration are documented above and in the API docstrings

@spencrr
Spencer Schoenberg (spencrr) requested a review from a team July 27, 2026 18:28
@azure-pipelines

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

Comment thread rampart/common/templates.py
Comment on lines +157 to +170
instance = object.__new__(cls)
object.__setattr__(instance, "name", definition.name) # ruff:ignore[unnecessary-dunder-call]
object.__setattr__( # ruff:ignore[unnecessary-dunder-call]
instance,
"description",
definition.description,
)
object.__setattr__( # ruff:ignore[unnecessary-dunder-call]
instance,
"parameter_keys",
tuple(definition.parameters),
)
object.__setattr__(instance, "_template", compiled) # ruff:ignore[unnecessary-dunder-call]
return instance

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.

Was considering your comment on whether dataclass is still necessary since we are limiting some of its core usage...what do you think about this approach?

The object.__new__ + four object.__setattr__ reconstruction here only exists to work around init=False on a frozen, slotted dataclass — the class opts into the dataclass machinery and then fights it. You can keep the dataclass (its generated __init__, __repr__, frozen guard, and slots) and just gate construction with a module-private sentinel:

_FROM_YAML: Final = object()

@final
@dataclass(frozen=True, kw_only=True, slots=True, eq=False)
class PromptTemplate:
    name: str
    description: str | None
    parameter_keys: tuple[str, ...]
    _template: Template = field(repr=False)
    _token: InitVar[object]

    def __post_init__(self, _token: object) -> None:
        if _token is not _FROM_YAML:
            raise TypeError("Use PromptTemplate.from_yaml()")

from_yaml then just calls cls(name=..., description=..., parameter_keys=tuple(definition.parameters), _template=_compile_template(definition, path=path), _token=_FROM_YAML). That deletes _from_validated_definition, both object.* calls, the custom raising __init__, and all four lint suppressions — for the same guarantees.

I checked this on 3.11: direct construction, bare PromptTemplate(), and a wrong/absent token all raise TypeError; instances stay frozen (FrozenInstanceError); equality stays identity-based; the repr still hides _template; and because _token is an InitVar it never lands in __slots__ or on the instance. If you'd rather not carry a sentinel field, the other option is dropping @dataclass for a plain @final + __slots__ class — but then you hand-roll frozen and __repr__, so this is the smaller change.

@spencrr Spencer Schoenberg (spencrr) Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Nina Chikanov (@nina-msft) Yeah I am not a big fan of the token/sentinel thing. Can you PTAL at #144 - maybe a cleaner approach?

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.

If we go with #144 - can you close this out as stale?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Closing both in favor of your comment! Stay tuned!

@spencrr

Copy link
Copy Markdown
Contributor Author

See #156

Spencer Schoenberg (spencrr) added a commit that referenced this pull request Aug 7, 2026
## Description

This supersedes the constructor approaches in #132 and #144 and follows
the review feedback on #144.

This PR makes prompt-template construction source-neutral while
retaining `PromptTemplate.from_yaml(path)` as the YAML and filesystem
adapter.

- Adds a public, strict, frozen `PromptTemplateDefinition`.
- Normalizes declared parameters to an immutable, ordered tuple.
- Prevents prompt source from appearing in definition or template
representations.
- Replaces injectable dataclass construction with
`PromptTemplate(definition=...)`.
- Compiles and validates Jinja exclusively inside `PromptTemplate`.
- Preserves `from_yaml(path)` and both existing production call sites.
- Keeps YAML paths outside the definition and compiled runtime object.
- Preserves direct underlying exception causes while adding YAML path
context.
- Reports Jinja line numbers explicitly as template-value lines rather
than misleading physical YAML lines.
- Replaces metadata-based equality with object identity.

Jinja receives neither the YAML path nor the logical template name. This
avoids incorrect YAML file/line attribution and prevents names such as
`summary.html` from unexpectedly enabling autoescape.

## Breaking changes

Direct component-wise construction is no longer accepted:

```python
PromptTemplate(
    name="Greeting",
    description=None,
    parameter_keys=("subject",),
    _template=compiled_template,
)
```

Construct from a validated definition instead:

```python
PromptTemplate(
    definition=PromptTemplateDefinition(
        name="Greeting",
        description=None,
        parameters=("subject",),
        value="Hello, {{ subject }}!",
    ),
)
```

`PromptTemplate.from_yaml(path)` is unchanged, so existing supported
call sites require no migration.

Separate `PromptTemplate` instances now use identity equality and
hashing rather than metadata-based structural equality.
`PromptTemplateDefinitionError.path` is optional for in-memory
construction.

## Checklist

- [x] `pre-commit run --all-files` passes
- [x] Tests added or updated for changes
  - 34 focused prompt-template tests pass
  - 652 tracked unit tests pass
- [x] Documentation updated
- Module and public API docstrings describe the source-neutral
construction and error contracts
@spencrr
Spencer Schoenberg (spencrr) deleted the dev/spencrr/fixup-template-init branch August 14, 2026 01:25
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