fix(engine): actionable error when a Jinja field is missing/None/empty#633
Conversation
Empty-render and missing-attribute failures used to surface as the generic "User provided prompt generation template is invalid." either because `sanitize_user_exceptions` stripped the detail or because Jinja's raw `UndefinedError` leaked through. Both now raise a new `EmptyTemplateRenderError` carrying a row-level diagnostic that names the offending chain and includes copy-pasteable Jinja conditional and SkipConfig fix patterns. Closes #629.
PR #633 Review —
|
Greptile SummaryThis PR fixes two error-reporting bugs in Jinja template rendering: empty renders and
|
| Filename | Overview |
|---|---|
| packages/data-designer-engine/src/data_designer/engine/processing/ginja/exceptions.py | Adds EmptyTemplateRenderError as a UserTemplateError subclass; clean, minimal change to the exception hierarchy. |
| packages/data-designer-engine/src/data_designer/engine/processing/ginja/ast.py | Adds ast_extract_access_chains, resolve_access_chain, and _build_access_chain; AST traversal logic correctly handles top-level chains, dynamic subscripts, and partial resolution. |
| packages/data-designer-engine/src/data_designer/engine/processing/ginja/environment.py | Adds UndefinedError → EmptyTemplateRenderError conversion in both sandbox environments, threads user_template/record through validate_rendered_text, and adds _build_empty_render_message with correct gate-expression logic for all resolution cases. |
| packages/data-designer-engine/tests/engine/processing/ginja/test_environment.py | Comprehensive regression tests covering both bugs, all culprit classification branches, sanitizer bypass, native engine conversion, safe remediation templates, and loop-variable scoping. |
| packages/data-designer-engine/tests/engine/processing/ginja/test_ast.py | Parametrized tests for ast_extract_access_chains and resolve_access_chain covering plain text, nested attrs, subscripts, dynamic subscripts, and type-mismatch cases. |
| packages/data-designer-engine/tests/engine/column_generators/generators/test_image.py | Updates match string from "invalid" to "empty" to reflect the more informative EmptyTemplateRenderError message; correct and minimal change. |
Sequence Diagram
sequenceDiagram
participant Caller
participant WithJinja2 as WithJinja2UserTemplateRendering
participant Env as UserTemplateSandboxEnvironment
participant Msg as _build_empty_render_message
participant Sanitizer as sanitize_user_exceptions
Caller->>WithJinja2: render_template(record)
Note over WithJinja2: @sanitize_user_exceptions
WithJinja2->>Env: safe_render(user_template, record)
alt Empty render (field is None/empty/missing)
Env->>Env: template.render(record) → ""
Env->>Env: validate_rendered_text("", user_template, record)
Env->>Msg: _build_empty_render_message(template, record, parse)
Msg-->>Env: actionable message with culprit chains
Env-->>WithJinja2: raise EmptyTemplateRenderError
else UndefinedError (nested missing attr)
Env->>Env: template.render(record) → UndefinedError
Env->>Msg: _build_empty_render_message(template, record, parse)
Msg-->>Env: actionable message with culprit chains
Env-->>WithJinja2: raise EmptyTemplateRenderError
end
Note over WithJinja2,Sanitizer: EmptyTemplateRenderError passes through (not swallowed)
WithJinja2-->>Sanitizer: EmptyTemplateRenderError propagates
Sanitizer-->>Caller: re-raises EmptyTemplateRenderError as-is
Reviews (4): Last reviewed commit: "Merge branch 'main' into fix/template-re..." | Re-trigger Greptile
| gate_accessors = sample_accessors[: len(sample_prefix) + 1] | ||
| gate_chain = _format_access_chain(sample_name, gate_accessors) |
There was a problem hiding this comment.
Gate expression is one step too deep when the root variable is absent from the record
resolve_access_chain returns (False, None, []) both when the root name is missing from record (name not in record) and when the first accessor fails — both yield prefix = []. The gate computation sample_accessors[:len(sample_prefix)+1] therefore produces sample_accessors[:1] in either scenario. For {{ person.address.street }} with record = {}, that gives gate_chain = "person.address", so the suggestion becomes {{ person.address.street if person.address else 'N/A' }}. But in Jinja2, person is Undefined when it is absent from the context, and Undefined.__getattr__ raises UndefinedError immediately — the suggested template itself fails with the same kind of error the user is trying to fix.
The gate should fall back to the root name alone (gate_accessors = []) when sample_name is not present in record.
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/data-designer-engine/src/data_designer/engine/processing/ginja/environment.py
Line: 736-737
Comment:
**Gate expression is one step too deep when the root variable is absent from the record**
`resolve_access_chain` returns `(False, None, [])` both when the root name is missing from `record` (`name not in record`) and when the first accessor fails — both yield `prefix = []`. The gate computation `sample_accessors[:len(sample_prefix)+1]` therefore produces `sample_accessors[:1]` in either scenario. For `{{ person.address.street }}` with `record = {}`, that gives `gate_chain = "person.address"`, so the suggestion becomes `{{ person.address.street if person.address else 'N/A' }}`. But in Jinja2, `person` is `Undefined` when it is absent from the context, and `Undefined.__getattr__` raises `UndefinedError` immediately — the suggested template itself fails with the same kind of error the user is trying to fix.
The gate should fall back to the root name alone (`gate_accessors = []`) when `sample_name` is not present in `record`.
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
Fixed in 6d258f1. When sample_name is not in record, the gate now falls back to the root name alone (gate_accessors = []) instead of slicing one accessor past an empty prefix. The resulting suggestion ({{ person.address.street if person else 'N/A' }} / SkipConfig(when="{{ not person }}")) is safe to evaluate because Jinja's default Undefined is falsy and stringifies to "", whereas Undefined.__getattr__ raises.
Added two regression tests covering this exact scenario:
test_undefined_root_variable_produces_safe_remediation_template— asserts the suggested template uses the root-name-only gate (and does not include the broken.addressaccessor).test_undefined_root_variable_remediation_template_is_renderable— actually renders the suggested template against{}and verifies it returns'N/A'instead of raising, which would have caught the original bug.
Addresses the open review comments on #633: 1. (Greptile P1) Gate expression in the suggested remediation template was one accessor too deep when the root variable was entirely absent from the record, causing the suggested fix to itself raise UndefinedError. Fall back to gating on the root name alone when sample_name is not in record. 2. (andreatgretel) The AST walker reported loop-local names as missing culprits (e.g. ``person`` in ``{% for person in people %}...{% endfor %}``). Filter extracted chains through ``meta.find_undeclared_variables`` to defer to Jinja's canonical scope tracking. 3. (andreatgretel follow-up) Empty collections used as loop iterables (``items=[]``) fell through to the no-culprit fallback. Add a new ``_CULPRIT_EMPTY_COLLECTION`` classification so they're surfaced. 4. Minor: add ``from exception`` to ``safe_render``'s UndefinedError re-raise for traceback consistency with the native engine path, and add a note on the load-bearing exception ordering in ``sanitize_user_exceptions``.
Summary
Closes #629.
Empty-render and missing-attribute Jinja failures used to surface as the same unhelpful message:
Two distinct failure modes were collapsing into that generic string:
{{ person.preferred_english_name }}where the key is missing):_assert_rendered_text_not_emptyraised a descriptiveUserTemplateError, butsanitize_user_exceptionsthen replaced the message with the generic one.UndefinedError(e.g.{{ person.address.street }}whereaddressis missing): ajinja2.exceptions.UndefinedErrorleaked all the way up because the sanitizer only caughtUserTemplateError/TemplateSyntaxError.Both paths now raise a new
EmptyTemplateRenderError(UserTemplateError)subclass that the sanitizer is explicitly told to pass through. The exception carries an actionable, row-level message naming the offending chain plus copy-pasteable Jinja conditional and SkipConfig remediation patterns:The "gate" expression in the suggestions stops one accessor past the resolvable prefix so it stays safe to evaluate (avoiding a fresh
UndefinedErroron the suggested fix itself).What changed
packages/data-designer-engine/src/data_designer/engine/processing/ginja/:exceptions.py— newEmptyTemplateRenderErrorsubclass.ast.py— new public helpersast_extract_access_chainsandresolve_access_chain(plus anAccessChaintype alias).environment.py:_assert_rendered_text_not_empty/validate_rendered_textthread the template + record through and raise the new error with the per-row diagnostic.UserTemplateSandboxEnvironment.safe_rendercatchesUndefinedErrorand converts it to the sameEmptyTemplateRenderError.NativeJinjaSandboxEnvironment.render_templatemirrors the conversion for consistency.sanitize_user_exceptionsbypassesEmptyTemplateRenderError(same treatment asUserTemplateUnsupportedFiltersError).End-to-end propagation already prepends the column name via
_run_batch, so no other layers needed changes.Test plan
test_empty_render_raises_empty_template_render_error_with_culprit_chain,test_undefined_nested_attr_raises_empty_template_render_error_with_safe_gate).NativeJinjaSandboxEnvironmentalso convertsUndefinedError.ast_extract_access_chains(plain text, single name, nested attr, subscript, mixed, dynamic subscript skipped, loop/conditional context) andresolve_access_chain(resolved, partially-resolved, type-mismatch cases).match="invalid"tomatch="empty"to reflect the new (more informative) message.uv run pytest packages/data-designer-engine/tests/engine/processing/ginja/— 95 passed.uv run ruff format+uv run ruff check— clean.