This is an automated email from the ASF dual-hosted git repository.
potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new 11bf87404c2 Restrict the second render pass in
SqlToSlackWebhookOperator (#71402)
11bf87404c2 is described below
commit 11bf87404c24f11821617992afe3e12c9457efa4
Author: JelyFishhhhhh <[email protected]>
AuthorDate: Tue Oct 6 00:09:20 2026 +0800
Restrict the second render pass in SqlToSlackWebhookOperator (#71402)
* Restrict the second render pass in SqlToSlackWebhookOperator
The operator renders in two passes because slack_message references
results_df, which does not exist until the query has run. The first pass
renders every templated field except slack_message; the second is meant to
fill in that one deferred field.
The second pass rendered self.template_fields instead, so sql - already
rendered by the first pass - was compiled again with its own rendered output
as the template source. Jinja syntax arriving inside a context value was
therefore evaluated on the second pass rather than staying literal.
Restrict the second pass to slack_message. The first pass is unchanged and
slack_message still renders exactly as before.
Co-Authored-By: Claude Opus 5 <[email protected]>
* Render only slack_message when sending, also for mapped tasks
A mapped task's first render bypasses render_template_fields and leaves
times_rendered at 0, so sending re-rendered the already-rendered sql.
Render the deferred slack_message field directly instead of relying on
the counter.
Generated-by: Claude Opus 5
* Render slack_message once from its original template
A mapped task's first render also renders slack_message, so rendering
the attribute again at send time evaluated Jinja that arrived in a
context value. Keep the template given to the constructor and render
that, once, when sending.
Generated-by: Claude Opus 5
---------
Co-authored-by: JelyF1shhhhhh <[email protected]>
Co-authored-by: Claude Opus 5 <[email protected]>
Co-authored-by: Jarek Potiuk <[email protected]>
---
.../slack/transfers/sql_to_slack_webhook.py | 28 +++++++---
.../slack/transfers/test_sql_to_slack_webhook.py | 61 ++++++++++++++++++++++
2 files changed, 82 insertions(+), 7 deletions(-)
diff --git
a/providers/slack/src/airflow/providers/slack/transfers/sql_to_slack_webhook.py
b/providers/slack/src/airflow/providers/slack/transfers/sql_to_slack_webhook.py
index 1892a9293fe..04b6bb7c40b 100644
---
a/providers/slack/src/airflow/providers/slack/transfers/sql_to_slack_webhook.py
+++
b/providers/slack/src/airflow/providers/slack/transfers/sql_to_slack_webhook.py
@@ -97,13 +97,21 @@ class SqlToSlackWebhookOperator(BaseSqlToSlackOperator):
self.slack_webhook_conn_id = slack_webhook_conn_id
self.slack_channel = slack_channel
self.slack_message = slack_message
+ # Rendered once, at send time, from this original text. A mapped
task's first render also
+ # renders slack_message, and rendering that output again would
evaluate any Jinja that arrived
+ # in a context value (``dag_run.conf``, a param).
+ self._slack_message_template = slack_message
self.results_df_name = results_df_name
self.kwargs = kwargs
def _render_and_send_slack_message(self, context, df) -> None:
- # Put the dataframe into the context and render the JINJA template
fields
+ # Render only slack_message, without going through
render_template_fields: a mapped task's
+ # first render never increments times_rendered, so relying on that
counter would render the
+ # already-rendered fields a second time.
context[self.results_df_name] = df
- self.render_template_fields(context)
+ self.slack_message = self.render_template(
+ self._slack_message_template, context, self._get_jinja_env()
+ )
slack_hook = self._get_slack_hook()
self.log.info("Sending slack message: %s", self.slack_message)
@@ -123,16 +131,22 @@ class SqlToSlackWebhookOperator(BaseSqlToSlackOperator):
if self.times_rendered == 0:
fields_to_render: Iterable[str] = (x for x in self.template_fields
if x != "slack_message")
else:
- fields_to_render = self.template_fields
+ # Only the deferred field. Re-rendering a field that the first
pass already rendered
+ # feeds its own output back in as template source, so any Jinja
syntax that arrived in
+ # a context value (``dag_run.conf``, a param) would be evaluated
on this second pass.
+ fields_to_render = ("slack_message",)
+
+ self._do_render_template_fields(
+ self, fields_to_render, context, self._get_jinja_env(jinja_env),
set()
+ )
+ self.times_rendered += 1
+ def _get_jinja_env(self, jinja_env=None):
if not jinja_env:
jinja_env = self.get_template_env()
-
# Add the tabulate library into the JINJA environment
jinja_env.filters["tabulate"] = tabulate
-
- self._do_render_template_fields(self, fields_to_render, context,
jinja_env, set())
- self.times_rendered += 1
+ return jinja_env
def execute(self, context: Context) -> None:
if not isinstance(self.sql, str):
diff --git
a/providers/slack/tests/unit/slack/transfers/test_sql_to_slack_webhook.py
b/providers/slack/tests/unit/slack/transfers/test_sql_to_slack_webhook.py
index beb91bf255d..49eef22bfd9 100644
--- a/providers/slack/tests/unit/slack/transfers/test_sql_to_slack_webhook.py
+++ b/providers/slack/tests/unit/slack/transfers/test_sql_to_slack_webhook.py
@@ -133,6 +133,67 @@ class TestSqlToSlackWebhookOperator:
channel="#test",
)
+ def test_second_render_leaves_already_rendered_fields_alone(self,
mocked_hook):
+ """The second render pass must touch only ``slack_message``.
+
+ The first pass renders every other templated field; re-rendering them
afterwards would feed
+ each field's own output back in as template source, so Jinja syntax
that arrived inside a
+ context value would be evaluated on the second pass instead of staying
literal.
+ """
+ mock_dbapi_hook = mock.Mock()
+ mock_dbapi_hook.return_value.get_df.return_value = pd.DataFrame({"a":
"1"}, index=[0])
+
+ sql_to_slack_operator = self._construct_operator(
+ sql_conn_id="snowflake_connection",
+ slack_webhook_conn_id="slack_connection",
+ slack_message="message: {{ results_df }}",
+ slack_channel="#test",
+ sql="SELECT '{{ ds }}'",
+ )
+ sql_to_slack_operator._get_hook = mock_dbapi_hook
+
+ # ``ds`` resolves to a value that itself contains Jinja - the shape a
triggering user can
+ # produce through ``dag_run.conf``. It must survive as literal text.
+ context = {"ds": "{{ leaked }}", "leaked": "SECRET"}
+
+ sql_to_slack_operator.render_template_fields(context)
+ assert sql_to_slack_operator.sql == "SELECT '{{ leaked }}'"
+
+ sql_to_slack_operator.execute(context)
+ assert sql_to_slack_operator.sql == "SELECT '{{ leaked }}'"
+
+ def test_send_renders_each_field_once_after_mapped_first_render(self,
mocked_hook):
+ """A mapped task's first render bypasses ``render_template_fields``:
it renders every template
+ field, ``slack_message`` included, and leaves ``times_rendered`` at 0.
Sending must not render
+ any field a second time, or Jinja that arrived in a context value
would be evaluated."""
+ mock_dbapi_hook = mock.Mock()
+ mock_dbapi_hook.return_value.get_df.return_value = pd.DataFrame({"a":
"1"}, index=[0])
+
+ sql_to_slack_operator = self._construct_operator(
+ sql_conn_id="snowflake_connection",
+ slack_webhook_conn_id="slack_connection",
+ slack_message="message: {{ ds }}",
+ slack_channel="#test",
+ sql="SELECT '{{ ds }}'",
+ )
+ sql_to_slack_operator._get_hook = mock_dbapi_hook
+ context = {"ds": "{{ leaked }}", "leaked": "SECRET"}
+
+ # What MappedOperator.render_template_fields does for the unmapped
task.
+ sql_to_slack_operator._do_render_template_fields(
+ sql_to_slack_operator,
+ sql_to_slack_operator.template_fields,
+ context,
+ sql_to_slack_operator.get_template_env(),
+ set(),
+ )
+ assert sql_to_slack_operator.times_rendered == 0
+
+ sql_to_slack_operator.execute(context)
+
+ assert sql_to_slack_operator.sql == "SELECT '{{ leaked }}'"
+ assert sql_to_slack_operator.slack_message == "message: {{ leaked }}"
+
@pytest.mark.parametrize(
("slack_webhook_conn_id", "warning_expected", "expected_conn_id"),
[