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 1c3252e5805 Add `callback` and `source` to context to differentiate 
between Dag and task callbacks (#76110)
1c3252e5805 is described below

commit 1c3252e5805028ee94efb497c7ff53ec8dbdea5b
Author: IndiTheo <[email protected]>
AuthorDate: Mon Nov 2 01:52:21 2026 +0200

    Add `callback` and `source` to context to differentiate between Dag and 
task callbacks (#76110)
    
    * Add callback_source to context to differentiate DAG vs task callbacks
    
    Currently a callback defined at the DAG level receives a context that
    is nearly identical to a task-level callback context, making it
    impossible to tell which level triggered the call.
    
    This adds a `CallbackSource` enum ("dag" / "task") and a `CallbackMeta`
    named tuple, and injects `context["callback"] = CallbackMeta(source=...)`
    in every code path that invokes callbacks:
    
    - task_runner._run_task_state_change_callbacks  (in-worker task path)
    - processor._execute_dag_callbacks              (DAG processor path)
    - processor._execute_task_callbacks             (DAG processor path)
    - DagRun.handle_dag_callback                    (dag.test() path)
    
    Both `CallbackSource` and `CallbackMeta` are exported from `airflow.sdk`.
    
    Closes: #61119
    Made-with: Cursor
    
    * Document CallbackSource and CallbackMeta in task-sdk API reference
    
    Adds the new public callback metadata types to api.rst so
    test_docs_inventory_matches_public_api passes in CI.
    
    * Add callback meta structs to context
    
    Fix test to use RuntimeError instead of AirflowException.
    Add docs for callback source in context.
    Add noqa SDK001 to processor.py imports.
    Update known_sdk_imports_in_core.txt for dagrun.py.
    
    * Fixed dag spelling
    
    * Fixed imports
    
    * Converted newsfragment to significant
    
    * Drop the newsfragment for the additive callback context key
    
    Significant newsfragments are only for changes users must act on before 
upgrading; this adds a new context key, which the release notes pick up from 
the PR.
    
    Generated-by: Claude Opus 5
    
    ---------
    
    Co-authored-by: vasanthrpjan1-boop <[email protected]>
    Co-authored-by: IndiTheo <[email protected]>
    Co-authored-by: Jarek Potiuk <[email protected]>
---
 .../logging-monitoring/callbacks.rst               |  2 ++
 .../src/airflow/dag_processing/processor.py        |  6 ++++
 airflow-core/src/airflow/models/dagrun.py          |  2 ++
 .../tests/unit/dag_processing/test_processor.py    |  9 ++++++
 .../check_template_context_variable_in_sync.py     |  4 +--
 task-sdk/docs/api.rst                              |  4 +++
 task-sdk/src/airflow/sdk/__init__.py               | 12 +++++++-
 task-sdk/src/airflow/sdk/__init__.pyi              |  2 ++
 task-sdk/src/airflow/sdk/definitions/context.py    | 26 +++++++++++++++++
 .../src/airflow/sdk/execution_time/task_runner.py  |  3 ++
 .../task_sdk/execution_time/test_task_runner.py    | 34 ++++++++++++++++++++++
 11 files changed, 101 insertions(+), 3 deletions(-)

diff --git 
a/airflow-core/docs/administration-and-deployment/logging-monitoring/callbacks.rst
 
b/airflow-core/docs/administration-and-deployment/logging-monitoring/callbacks.rst
index 9b15c8f6c09..3e6372cf455 100644
--- 
a/airflow-core/docs/administration-and-deployment/logging-monitoring/callbacks.rst
+++ 
b/airflow-core/docs/administration-and-deployment/logging-monitoring/callbacks.rst
@@ -76,6 +76,8 @@ Context Mapping
 A context mapping that contains runtime information about a task instance is 
passed to every callback.
 Full list of variables available in ``context`` are in :doc:`docs 
<../../templates-ref>` and `code 
<https://github.com/apache/airflow/blob/main/task-sdk/src/airflow/sdk/definitions/context.py>`_.
 
+Every callback context also includes a ``callback`` key whose ``source`` field 
tells you whether the callback ran at the Dag level or the task level.
+
 
 Dag Callbacks
 ^^^^^^^^^^^^^
diff --git a/airflow-core/src/airflow/dag_processing/processor.py 
b/airflow-core/src/airflow/dag_processing/processor.py
index 4be5623d71d..a44e43623d3 100644
--- a/airflow-core/src/airflow/dag_processing/processor.py
+++ b/airflow-core/src/airflow/dag_processing/processor.py
@@ -376,6 +376,7 @@ def _execute_callbacks(
 
 def _execute_dag_callbacks(dagbag: DagBag, request: DagCallbackRequest, log: 
FilteringBoundLogger) -> None:
     from airflow.sdk.api.datamodels._generated import TIRunContext
+    from airflow.sdk.definitions.context import CallbackMeta, CallbackSource  
# noqa: SDK001
 
     dag, _ = _get_dag_with_task(dagbag, request.dag_id)
     callbacks = dag.on_failure_callback if request.is_failure_callback else 
dag.on_success_callback
@@ -415,6 +416,8 @@ def _execute_dag_callbacks(dagbag: DagBag, request: 
DagCallbackRequest, log: Fil
             context = runtime_ti.get_template_context()
             context["reason"] = request.msg
 
+    context["callback"] = CallbackMeta(source=CallbackSource.DAG)
+
     for callback in callbacks:
         log.info(
             "Executing on_%s dag callback",
@@ -441,6 +444,8 @@ def _execute_dag_callbacks(dagbag: DagBag, request: 
DagCallbackRequest, log: Fil
 
 
 def _execute_task_callbacks(dagbag: DagBag, request: TaskCallbackRequest, log: 
FilteringBoundLogger) -> None:
+    from airflow.sdk.definitions.context import CallbackMeta, CallbackSource  
# noqa: SDK001
+
     if not request.is_failure_callback:
         log.warning(
             "Task callback requested but is not a failure callback",
@@ -487,6 +492,7 @@ def _execute_task_callbacks(dagbag: DagBag, request: 
TaskCallbackRequest, log: F
             task=task,
         )
     context = runtime_ti.get_template_context()
+    context["callback"] = CallbackMeta(source=CallbackSource.TASK)
 
     def get_callback_representation(callback):
         with contextlib.suppress(AttributeError):
diff --git a/airflow-core/src/airflow/models/dagrun.py 
b/airflow-core/src/airflow/models/dagrun.py
index 6b8236805f1..71b25c9b4b8 100644
--- a/airflow-core/src/airflow/models/dagrun.py
+++ b/airflow-core/src/airflow/models/dagrun.py
@@ -99,6 +99,7 @@ from airflow.models.dynamic_region import (
 )
 from airflow.models.taskinstance import TaskInstance as TI, 
_add_and_prime_mapped_ti
 from airflow.models.tasklog import LogTemplate
+from airflow.sdk.definitions.context import CallbackMeta, CallbackSource  # 
noqa: SDK001
 from airflow.serialization.definitions.deadline import 
SerializedReferenceModels
 from airflow.serialization.definitions.notset import NOTSET, ArgNotSet, 
is_arg_set
 from airflow.ti_deps.dep_context import DepContext
@@ -1823,6 +1824,7 @@ class DagRun(Base, LoggingMixin):
             }
 
         context["reason"] = reason
+        context["callback"] = CallbackMeta(source=CallbackSource.DAG)
 
         callbacks = dag.on_success_callback if success else 
dag.on_failure_callback
         if not callbacks:
diff --git a/airflow-core/tests/unit/dag_processing/test_processor.py 
b/airflow-core/tests/unit/dag_processing/test_processor.py
index 154cb7640ce..c9c4ab19e08 100644
--- a/airflow-core/tests/unit/dag_processing/test_processor.py
+++ b/airflow-core/tests/unit/dag_processing/test_processor.py
@@ -1078,6 +1078,9 @@ class TestExecuteDagCallbacks:
         # Check that we have template context variables from 
RuntimeTaskInstance
         assert "ts" in context_received
         assert "params" in context_received
+        from airflow.sdk.definitions.context import CallbackSource
+
+        assert context_received["callback"].source is CallbackSource.DAG
 
     def 
test_execute_dag_callbacks_missing_last_ti_task_falls_back_to_minimal_context(self,
 spy_agency):
         """A removed last_ti task must not drop the callback; it runs with the 
minimal context."""
@@ -1189,6 +1192,9 @@ class TestExecuteDagCallbacks:
         # Should not have template context variables
         assert "ts" not in context_received
         assert "params" not in context_received
+        from airflow.sdk.definitions.context import CallbackSource
+
+        assert context_received["callback"].source is CallbackSource.DAG
 
     def test_execute_dag_callbacks_success_callback(self, spy_agency):
         """Test _execute_dag_callbacks executes success callback with 
context_from_server"""
@@ -1626,6 +1632,9 @@ class TestExecuteTaskCallbacks:
         assert context_received is not None
         assert context_received["dag"] == dag
         assert "ti" in context_received
+        from airflow.sdk.definitions.context import CallbackSource
+
+        assert context_received["callback"].source is CallbackSource.TASK
 
     def test_execute_task_callbacks_retry_callback(self, spy_agency):
         """Test _execute_task_callbacks executes retry callbacks"""
diff --git a/scripts/ci/prek/check_template_context_variable_in_sync.py 
b/scripts/ci/prek/check_template_context_variable_in_sync.py
index 88bc3cbed3a..86a5e8cccf7 100755
--- a/scripts/ci/prek/check_template_context_variable_in_sync.py
+++ b/scripts/ci/prek/check_template_context_variable_in_sync.py
@@ -163,8 +163,8 @@ def _compare_keys(retn_keys: set[str], hint_keys: set[str], 
docs_keys: set[str])
     retn_keys.add("test_mode")
 
     # Only present in callbacks. Not listed in templates-ref (that doc is for 
task execution).
-    retn_keys.update(("exception", "reason", "try_number"))
-    docs_keys.update(("exception", "reason", "try_number"))
+    retn_keys.update(("callback", "exception", "reason", "try_number"))
+    docs_keys.update(("callback", "exception", "reason", "try_number"))
 
     # Airflow 3 added:
     retn_keys.add("task_reschedule_count")
diff --git a/task-sdk/docs/api.rst b/task-sdk/docs/api.rst
index 824fde2638e..e7f854a7785 100644
--- a/task-sdk/docs/api.rst
+++ b/task-sdk/docs/api.rst
@@ -112,6 +112,10 @@ Callbacks
 
 .. autoclass:: airflow.sdk.SyncCallback
 
+.. autoclass:: airflow.sdk.CallbackSource
+
+.. autoclass:: airflow.sdk.CallbackMeta
+
 Deadline Alerts
 ---------------
 .. autoclass:: airflow.sdk.DeadlineAlert
diff --git a/task-sdk/src/airflow/sdk/__init__.py 
b/task-sdk/src/airflow/sdk/__init__.py
index 46418ceeaf1..1c4d7150201 100644
--- a/task-sdk/src/airflow/sdk/__init__.py
+++ b/task-sdk/src/airflow/sdk/__init__.py
@@ -41,6 +41,8 @@ __all__ = [
     "BaseSensorOperator",
     "BaseXCom",
     "BranchMixIn",
+    "CallbackMeta",
+    "CallbackSource",
     "ChainMapper",
     "ChainRetryPolicy",
     "Connection",
@@ -156,7 +158,13 @@ if TYPE_CHECKING:
     from airflow.sdk.definitions.asset.metadata import Metadata
     from airflow.sdk.definitions.callback import AsyncCallback, SyncCallback
     from airflow.sdk.definitions.connection import Connection
-    from airflow.sdk.definitions.context import Context, get_current_context, 
get_parsing_context
+    from airflow.sdk.definitions.context import (
+        CallbackMeta as CallbackMeta,
+        CallbackSource as CallbackSource,
+        Context,
+        get_current_context,
+        get_parsing_context,
+    )
     from airflow.sdk.definitions.dag import DAG, dag
     from airflow.sdk.definitions.deadline import (
         BaseDeadlineReference,
@@ -262,6 +270,8 @@ __lazy_imports: dict[str, tuple[str, str]] = {
     "BaseSensorOperator": (".bases.sensor", "BaseSensorOperator"),
     "BaseXCom": (".bases.xcom", "BaseXCom"),
     "BranchMixIn": (".bases.branch", "BranchMixIn"),
+    "CallbackMeta": (".definitions.context", "CallbackMeta"),
+    "CallbackSource": (".definitions.context", "CallbackSource"),
     "ChainMapper": (".definitions.partition_mappers.chain", "ChainMapper"),
     "ChainRetryPolicy": (".definitions.retry_policy", "ChainRetryPolicy"),
     "Connection": (".definitions.connection", "Connection"),
diff --git a/task-sdk/src/airflow/sdk/__init__.pyi 
b/task-sdk/src/airflow/sdk/__init__.pyi
index 221d3eb48a6..e35b2cb05a5 100644
--- a/task-sdk/src/airflow/sdk/__init__.pyi
+++ b/task-sdk/src/airflow/sdk/__init__.pyi
@@ -56,6 +56,8 @@ from airflow.sdk.definitions.asset.metadata import Metadata 
as Metadata
 from airflow.sdk.definitions.callback import AsyncCallback, SyncCallback
 from airflow.sdk.definitions.connection import Connection as Connection
 from airflow.sdk.definitions.context import (
+    CallbackMeta as CallbackMeta,
+    CallbackSource as CallbackSource,
     Context as Context,
     get_current_context as get_current_context,
     get_parsing_context as get_parsing_context,
diff --git a/task-sdk/src/airflow/sdk/definitions/context.py 
b/task-sdk/src/airflow/sdk/definitions/context.py
index 6f85470cc68..df305bcde0e 100644
--- a/task-sdk/src/airflow/sdk/definitions/context.py
+++ b/task-sdk/src/airflow/sdk/definitions/context.py
@@ -18,10 +18,35 @@
 from __future__ import annotations
 
 import copy
+import enum
 import os
 from collections.abc import MutableMapping
 from typing import TYPE_CHECKING, Any, NamedTuple, NotRequired, TypedDict, cast
 
+
+class CallbackSource(str, enum.Enum):
+    """Whether a callback was triggered at the Dag level or the task level."""
+
+    DAG = "dag"
+    TASK = "task"
+
+    def __str__(self) -> str:
+        return self.value
+
+
+class CallbackMeta(NamedTuple):
+    """
+    Metadata about the callback that is being executed.
+
+    Present in the callback context as ``context["callback"]``.
+
+    ``source`` tells whether the callback was defined on the Dag or on a task.
+    More fields may be added in the future without breaking existing callbacks.
+    """
+
+    source: CallbackSource
+
+
 if TYPE_CHECKING:
     import jinja2
     from pendulum import DateTime
@@ -45,6 +70,7 @@ if TYPE_CHECKING:
 class Context(TypedDict, total=False):
     """Jinja2 template context for task rendering."""
 
+    callback: NotRequired[CallbackMeta]
     conn: Any
     dag: DAG
     dag_run: DagRunProtocol
diff --git a/task-sdk/src/airflow/sdk/execution_time/task_runner.py 
b/task-sdk/src/airflow/sdk/execution_time/task_runner.py
index 3160f2d01db..caccb6d737c 100644
--- a/task-sdk/src/airflow/sdk/execution_time/task_runner.py
+++ b/task-sdk/src/airflow/sdk/execution_time/task_runner.py
@@ -2222,6 +2222,9 @@ def _run_task_state_change_callbacks(
     context: Context,
     log: Logger,
 ) -> None:
+    from airflow.sdk.definitions.context import CallbackMeta, CallbackSource
+
+    context["callback"] = CallbackMeta(source=CallbackSource.TASK)
     callback: Callable[[Context], None]
     for i, callback in enumerate(getattr(task, kind)):
         try:
diff --git a/task-sdk/tests/task_sdk/execution_time/test_task_runner.py 
b/task-sdk/tests/task_sdk/execution_time/test_task_runner.py
index 387543a0bff..ea1b4afd964 100644
--- a/task-sdk/tests/task_sdk/execution_time/test_task_runner.py
+++ b/task-sdk/tests/task_sdk/execution_time/test_task_runner.py
@@ -5980,6 +5980,40 @@ class TestTaskRunnerCallsCallbacks:
         )
         assert callback_data["duration"] >= 0, f"duration should be >= 0, got 
{callback_data['duration']}"
 
+    @pytest.mark.parametrize(
+        ("callback_kind", "expected_state"),
+        [
+            pytest.param("on_success_callback", TaskInstanceState.SUCCESS, 
id="success"),
+            pytest.param("on_failure_callback", TaskInstanceState.FAILED, 
id="failure"),
+        ],
+    )
+    def test_task_callback_context_has_callback_meta(self, create_runtime_ti, 
callback_kind, expected_state):
+        """Task-level callbacks should receive callback.source=TASK in the 
context."""
+        from airflow.sdk.definitions.context import CallbackSource
+
+        captured = {}
+
+        def capture_callback(context):
+            captured["callback"] = context.get("callback")
+
+        succeeds = callback_kind == "on_success_callback"
+
+        class CustomOperator(BaseOperator):
+            def execute(self, context):
+                if succeeds:
+                    return "ok"
+                raise RuntimeError("boom")
+
+        task = CustomOperator(task_id="cb_task", **{callback_kind: 
capture_callback})
+        runtime_ti = create_runtime_ti(dag_id="dag", task=task)
+        log = mock.MagicMock()
+        context = runtime_ti.get_template_context()
+        state, _, error = run(runtime_ti, context, log)
+        finalize(runtime_ti, state, context, log, error)
+
+        assert state == expected_state
+        assert captured["callback"].source is CallbackSource.TASK
+
     def test_task_runner_both_callbacks_have_timing_info(self, 
create_runtime_ti):
         """Test that both success and failure callbacks receive accurate 
timing information."""
         import time

Reply via email to