fix(loader): preserve non-env {{...}} placeholders in ConfigLoader (#81) - #132
owen-pengtao wants to merge 3 commits into
Conversation
…ontextforge-org#81) ConfigLoader.load_config renders the whole plugin config.yaml through a single jinja pass with the default (empty) Undefined, so any {{...}} that is not an env reference is silently blanked to "". A plugin that stores a runtime template in its config (e.g. WebhookNotification's default_template) is therefore emptied at load time and later emits an all-empty, invalid body. Render with jinja2.DebugUndefined so undefined names (everything except the env.* values passed to render()) survive verbatim as {{ name }} instead of being blanked, while env.* references are still substituted. Adds regression tests covering env substitution, non-env placeholder preservation, and use_jinja=False. Fixes contextforge-org#81. Signed-off-by: Owen Peng <tao.peng@tibco.com>
|
can anyone review this pr? |
@owen-pengtao I was busy with something else, but marking myself to review it this week. |
|
@araujof do you able to review this pr week? |
araujof
left a comment
There was a problem hiding this comment.
@owen-pengtao Thanks for this PR. DebugUndefined does not preserve arbitrary non-env Jinja.** The entire YAML file is still evaluated as a Jinja template. For example, {{ event.name }} raises UndefinedError, filters can transform placeholders, and conditional blocks can disappear. An unset {{ env.MISSING }} also becomes Jinja diagnostic text rather than remaining {{ env.MISSING }} as described in the PR. Use genuinely env-only interpolation and add regression coverage for nested expressions, filters, conditionals, and missing environment variables.
Below is a suggested alternative that addresses this problem. Would you like to try it and update your PR?
Suggested implementation
Replace only the explicitly supported {{ env.NAME }} syntax instead of rendering the entire configuration as Jinja. For example:
import re
_ENV_REFERENCE = re.compile(r"{{\s*env\.([A-Za-z_][A-Za-z0-9_]*)\s*}}")
def _interpolate_env(template: str) -> str:
def replace(match: re.Match[str]) -> str:
name = match.group(1)
# Preserve an unset reference. This policy could instead raise a clear
# configuration error, but it should be explicit and tested.
return os.environ.get(name, match.group(0))
return _ENV_REFERENCE.sub(replace, template)Then use _interpolate_env(template) in the use_jinja branch. Given:
endpoint: "{{ env.WEBHOOK_URL }}"
default_template: "{{ event.name | upper }}"only WEBHOOK_URL would be substituted; the runtime template would remain byte-for-byte unchanged. If | default(...) must remain supported, add it as an explicitly parsed part of this restricted syntax rather than evaluating the whole YAML document with Jinja.
Add tests confirming that nested expressions, filters, and {% ... %} blocks remain unchanged, plus a test defining the behavior of an unset environment variable.
…eholders (contextforge-org#81) Rendering the whole plugin config.yaml as a Jinja template corrupts any {{...}} that a plugin stores in order to render it itself at runtime. The first attempt at this fix switched to jinja2.DebugUndefined, which is not sufficient: the document is still evaluated as a template, so - {{ event.name }} raises UndefinedError, - {{ event | upper }} is rewritten to {{ EVENT }}, - {% if ... %} blocks are evaluated away, - an unset {{ env.MISSING }} becomes Jinja diagnostic text ({{ no such element: os._Environ object['MISSING'] }}) rather than staying literal as the previous change claimed, and - autoescape=True HTML-escapes every substituted value, turning a valid query-string & into &. DebugUndefined also normalises {{event}} to {{ event }}, and that alone defeats the motivating consumer: WebhookNotification._render_template substitutes the exact literal "{{event}}" via str.replace, so the canonicalised form never matches and the webhook body stays broken. Preservation has to be byte-for-byte. Replace the Jinja pass with a narrow regular expression that resolves only the documented {{ env.NAME }} syntax and leaves every other byte of the configuration untouched. An unset variable keeps its original {{ env.NAME }} text instead of collapsing to "". Dropping autoescape means an env value containing a double quote now breaks the surrounding YAML quoting and raises, where before it was silently loaded as a mangled &contextforge-org#34; value; a characterisation test pins that boundary. Fixes contextforge-org#81. Signed-off-by: Owen Peng <tao.peng@tibco.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@araujof Thank you — you were right on every point, and the PR is updated to your suggested approach (pushed as 8698ace). I verified each of your claims against
All confirmed. The unset-variable row is also a regression the PR itself introduced, and it contradicted my own PR description — thank you for catching that. One more piece of evidence for your "byte-for-byte" requirement, which I think strengthens the case beyond style. I went and read the motivating consumer, placeholder = f"{{{{{key}}}}}" # -> "{{event}}", no inner spaces
result = result.replace(placeholder, ...)It is an exact What the new commit does Replaces the Jinja pass with your narrow Tests — the regression coverage you asked for, 9 cases: env substitution; byte-for-byte placeholder preservation; nested expressions; filters; I also ran the new suite against both previous implementations (the Two things worth flagging for your call:
Full unit suite: 1702 passed. The 10 failures plus the |
interrogate is configured with fail-under = 100 and checks nested functions, so the new _replace callback needs its own docstring. Signed-off-by: Owen Peng <tao.peng@tibco.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Summary
Fixes #81 —
ConfigLoader.load_configcorrupts non-env{{...}}placeholders in the plugin config.load_configrenders the entireconfig.yamlthrough one Jinja pass:Because the render only defines
env, any{{...}}that is not anenvreference is silently rendered to""at load time — including placeholders a plugin legitimately stores in its config to render itself later, at runtime. The reported symptom:WebhookNotification'sdefault_templateis blanked to
{ "event": "", "timestamp": "", "violation": , ... }at load, so the plugin posts an all-empty, invalid-JSON body.Fix
Stop treating the configuration as a template at all. A narrow regular expression resolves only the documented
{{ env.NAME }}syntax and leaves every other byte of the file untouched:Thanks to @araujof for catching that the original
DebugUndefinedapproach was not sufficient. It does not make the document stop being a Jinja template, so it left five holes — all of them verified againstjinja23.1.6:DebugUndefinedresult{{ env.URL }}where the value contains&...?b=1&c=2— HTML-escaped byautoescape=True{{ env.MISSING }}(unset){{ no such element: os._Environ object['MISSING'] }}{{event}}{{ event }}— whitespace normalised{{ event.name }}UndefinedError{{ event | upper }}{{ EVENT }}— the filter rewrote the placeholder{% if flag %}kept{% endif %}The whitespace row is the decisive one:
WebhookNotification._render_templatesubstitutes the exact literal{{event}}withstr.replace, so the canonicalised{{ event }}never matches.DebugUndefinedwould only have traded "blanked to empty" for "literal{{ event }}left in the JSON body" — issue #81 would not actually have been fixed. Preservation has to be byte-for-byte.Behaviour notes
{{ env.MISSING }}is now preserved verbatim rather than collapsing to"", so a missing variable stays visible instead of silently emptying a value.autoescape=Truewas turning a valid query-string&into&inside loaded values. That is fixed as a side effect and covered by a test.{{ env['NAME'] }}and{{ env.NAME | default('x') }}are no longer interpolated. No configuration in this repository uses either form, but it is a behaviour change worth a release note; thedefaultfilter can be added to the restricted syntax later if it turns out to be needed.use_jinjaparameter name kept —cpex/framework/external/mcp/server/server.pypasses it, so renaming would break the public signature. Its docstring now describes what it actually does.autoescapepreviously hid it by loading a mangled"value. Failing loudly is the better of the two; a characterisation test pins the behaviour so any future escaping/quoting change is deliberate.Tests
tests/unit/cpex/framework/loader/test_config_loader.py— 9 tests covering exactly the cases requested in review:{{ env.X }}is substituted;envplaceholder survives byte-for-byte (the [BUG]: ConfigLoader.load_config Jinja pass blanks non-env {{...}} placeholders in plugin config #81 regression);{{ event.name }}) are preserved, not raised on;{{ event | upper }}) are preserved, not applied;{% ... %}blocks are preserved, not evaluated;use_jinja=Falseleaves everything literal.Verification:
0.1.xbaseline and theDebugUndefinedcommit) — 7 of 8 applicable tests fail on each, so these are genuine regression tests rather than tests written to fit the new code.tests/unit/cpex/tools/test_cli.pycollection error are pre-existing and unrelated — the identical set fails on the unmodified0.1.xbaseline (Windows path / venv / catalog environment issues).Signed-off-by: Owen Peng tao.peng@tibco.com
🤖 Generated with Claude Code