kaxil commented on code in PR #66160:
URL: https://github.com/apache/airflow/pull/66160#discussion_r3171192441


##########
task-sdk/src/airflow/sdk/execution_time/task_runner.py:
##########
@@ -248,6 +250,14 @@ def get_template_context(self) -> Context:
                     "value": VariableAccessor(deserialize_json=False),
                 },
                 "conn": ConnectionAccessor(),
+                "task_state": TaskStateAccessor(ti_id=self.id),
+                # TODO: right now, asset_state is only provided for 
single-inlet tasks.
+                # Multi-inlet support is deferred — see AssetStateAccessor 
docstring.
+                **(
+                    {"asset_state": 
AssetStateAccessor(name=self.task.inlets[0].name)}

Review Comment:
   `self.task.inlets[0].name` crashes when the single inlet is 
`Asset.ref(uri="...")`. `AssetUriRef` only has `.uri` (no `.name`), so any task 
with an inlet like `Asset.ref(uri="s3://x")` raises `AttributeError: 
'AssetUriRef' object has no attribute 'name'` at template-context construction, 
before user code runs. For `AssetAlias(name=...)` it doesn't crash, but the 
alias name then gets sent to `/state/asset/{name}` and the route's 
`_resolve_asset_id` looks for a real asset by that name and 404s, which is 
silently confusing.
   
   Worth gating on `isinstance(self.task.inlets[0], Asset)` (and probably 
`AssetNameRef`) so non-Asset / non-NameRef inlets fall through to "no 
`asset_state` in context", matching the multi-inlet behavior. Existing tests 
only cover the `Asset(name=..., uri=...)` shape, so this isn't caught.



##########
task-sdk/src/airflow/sdk/execution_time/context.py:
##########
@@ -406,6 +405,137 @@ def get(self, key, default: Any = NOTSET) -> Any:
             raise
 
 
+class TaskStateAccessor:
+    """Accessor for task state scoped to the current task instance. Available 
as ``context['task_state']`` at task execution time."""
+
+    def __init__(self, ti_id: UUID) -> None:
+        self._ti_id = ti_id
+
+    def __eq__(self, other: object) -> bool:
+        if not isinstance(other, TaskStateAccessor):
+            return False
+        return self._ti_id == other._ti_id
+
+    def __hash__(self) -> int:
+        return hash(self._ti_id)
+
+    def __repr__(self) -> str:
+        return f"<TaskStateAccessor ti_id={self._ti_id}>"
+
+    # TODO: ``__getattr__`` for jinja template access like ``{{ 
task_state.job_id }}``
+    # is not implemented yet cos it's unclear whether task state values will be
+    # used in templates.
+
+    def get(self, key: str) -> str | None:

Review Comment:
   `get(key)` returns `None` on missing but takes no `default` parameter. 
`VariableAccessor.get(key, default=NOTSET)` lets users distinguish "not set" 
from "set to None". Same shape would help here, especially since the stored 
value is `str` and `None` is a meaningful sentinel users may want to override. 
Same applies to `AssetStateAccessor.get` at line 500.



##########
task-sdk/src/airflow/sdk/execution_time/context.py:
##########
@@ -406,6 +405,137 @@ def get(self, key, default: Any = NOTSET) -> Any:
             raise
 
 
+class TaskStateAccessor:
+    """Accessor for task state scoped to the current task instance. Available 
as ``context['task_state']`` at task execution time."""
+
+    def __init__(self, ti_id: UUID) -> None:
+        self._ti_id = ti_id
+
+    def __eq__(self, other: object) -> bool:
+        if not isinstance(other, TaskStateAccessor):
+            return False
+        return self._ti_id == other._ti_id
+
+    def __hash__(self) -> int:
+        return hash(self._ti_id)
+
+    def __repr__(self) -> str:
+        return f"<TaskStateAccessor ti_id={self._ti_id}>"
+
+    # TODO: ``__getattr__`` for jinja template access like ``{{ 
task_state.job_id }}``
+    # is not implemented yet cos it's unclear whether task state values will be
+    # used in templates.
+
+    def get(self, key: str) -> str | None:
+        """Return the stored value, or ``None`` if the key does not exist."""
+        from airflow.sdk.exceptions import AirflowRuntimeError, ErrorType
+        from airflow.sdk.execution_time.comms import GetTaskState, 
TaskStateResult
+        from airflow.sdk.execution_time.task_runner import SUPERVISOR_COMMS
+
+        try:
+            resp = SUPERVISOR_COMMS.send(GetTaskState(ti_id=self._ti_id, 
key=key))
+        except AirflowRuntimeError as e:
+            if e.error.error == ErrorType.API_SERVER_ERROR and 
isinstance(e.error.detail, dict):
+                if e.error.detail.get("status_code") == 404:

Review Comment:
   Detecting "not found" via `e.error.detail.get("status_code") == 404` couples 
the accessor to the supervisor's error-wrapping shape (the magic `status_code` 
key inside `detail`). Every other accessor in this module 
(`ConnectionAccessor.get`, `VariableAccessor.get`) translates 404 into a typed 
`ErrorType.X_NOT_FOUND` at the client layer (see `client.py:425`, 
`client.py:448`) and matches on that.
   
   Worth adding `TASK_STATE_NOT_FOUND` / `ASSET_STATE_NOT_FOUND` to 
`ErrorType`, doing the translation in `client.task_state.get` / 
`client.asset_state.get`, and matching on the typed error here. Same change 
applies to the duplicated branch at line 510 in `AssetStateAccessor.get`.



##########
task-sdk/src/airflow/sdk/execution_time/context.py:
##########
@@ -406,6 +405,137 @@ def get(self, key, default: Any = NOTSET) -> Any:
             raise
 
 
+class TaskStateAccessor:
+    """Accessor for task state scoped to the current task instance. Available 
as ``context['task_state']`` at task execution time."""
+
+    def __init__(self, ti_id: UUID) -> None:
+        self._ti_id = ti_id
+
+    def __eq__(self, other: object) -> bool:
+        if not isinstance(other, TaskStateAccessor):
+            return False
+        return self._ti_id == other._ti_id
+
+    def __hash__(self) -> int:
+        return hash(self._ti_id)
+
+    def __repr__(self) -> str:
+        return f"<TaskStateAccessor ti_id={self._ti_id}>"
+
+    # TODO: ``__getattr__`` for jinja template access like ``{{ 
task_state.job_id }}``
+    # is not implemented yet cos it's unclear whether task state values will be
+    # used in templates.
+
+    def get(self, key: str) -> str | None:
+        """Return the stored value, or ``None`` if the key does not exist."""
+        from airflow.sdk.exceptions import AirflowRuntimeError, ErrorType

Review Comment:
   `AirflowRuntimeError` and `ErrorType` are already imported at module top 
(line 45), so this inline re-import is redundant. Same in 
`AssetStateAccessor.get` at line 502. The `comms` and `task_runner` imports do 
need to stay inline (circular), but the exceptions ones don't.



##########
task-sdk/src/airflow/sdk/definitions/context.py:
##########
@@ -72,6 +72,8 @@ class Context(TypedDict, total=False):
     task_reschedule_count: int
     task_instance: RuntimeTaskInstanceProtocol
     task_instance_key_str: str
+    task_state: TaskStateAccessor

Review Comment:
   `task_state: TaskStateAccessor` is unannotated, but `asset_state: 
NotRequired[AssetStateAccessor]` on the next line is. With `Context(TypedDict, 
total=False)` the two are functionally equivalent, but the asymmetry reads as 
if `task_state` is required and `asset_state` isn't. The PR description says 
`task_state` is "always available", which matches your intent. Either drop 
`NotRequired` from `asset_state` for symmetry, or wrap `task_state` in 
`Required[...]` if you really want to enforce it (and switch the dict to 
`total=True`).



##########
task-sdk/src/airflow/sdk/api/client.py:
##########
@@ -627,6 +631,63 @@ def get_sequence_slice(
         return XComSequenceSliceResponse.model_validate_json(resp.read())
 
 
+class TaskStateOperations:
+    __slots__ = ("client",)
+
+    def __init__(self, client: Client):
+        self.client = client
+
+    def get(self, ti_id: uuid.UUID, key: str) -> TaskStateResponse:

Review Comment:
   Other client `get` methods translate 404 into a typed 
`ErrorResponse(error=ErrorType.X_NOT_FOUND, ...)`: `connections.get` at line 
417/425, `variables.get` at 440/448, `xcoms.get` at 585/598, `assets.get` at 
710/718. `task_state.get` and `asset_state.get` (line 669) skip that step and 
let `ServerResponseError` propagate, which is what forces the brittle 
`e.error.detail.get("status_code") == 404` check in `TaskStateAccessor.get` / 
`AssetStateAccessor.get`.
   
   If you adopt the typed-error approach here (wrap in `try/except 
ServerResponseError`, return 
`ErrorResponse(error=ErrorType.TASK_STATE_NOT_FOUND, detail={...})` on 404), 
the accessor's "is this a 404?" logic becomes a clean enum match against 
`resp.error`.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to