kaxil commented on code in PR #70298:
URL: https://github.com/apache/airflow/pull/70298#discussion_r4025168956
##########
dev/registry/extract_parameters.py:
##########
@@ -391,28 +394,159 @@ def load_resumable_job_mixin() -> type | None:
return None
+# Matches an actual self.defer() call or self.deferrable attribute read, but
not
+# self.defer_for_approval(). TaskDeferred catches operators that raise it
directly instead of
+# calling self.defer() (e.g. VespaIngestOperator).
+_DEFERRAL_TOKEN_RE =
re.compile(r"self\.defer\(|self\.deferrable\b|TaskDeferred\b")
+_SELF_CALL_RE = re.compile(r"self\.([A-Za-z_][A-Za-z0-9_]*)\(")
+_SUPER_EXECUTE_RE = re.compile(r"super\(\)\.execute\(")
+# Matches the @task.* decorator idiom of naming the parent class directly
instead of using
+# super() (e.g. `AgentOperator.execute(self, context)` in common.ai's
@task.agent).
+_EXPLICIT_EXECUTE_RE = re.compile(r"\b([A-Za-z_][A-Za-z0-9_]*)\.execute\(")
+
+# To prevent infinite looping, most cases in the repo are 1-2 hops away.
+_MAX_DEFERRAL_WALK_DEPTH = 6
+
+
+def _get_method_source(cls: type, name: str) -> str | None:
+ method = getattr(cls, name, None)
+ if method is None:
+ return None
+ try:
+ return inspect.getsource(method)
+ except (OSError, TypeError):
+ return None
+
+
+def _find_owner_of_execute(mro: tuple[type, ...], start_idx: int) -> type |
None:
+ """Return the first class in `mro[start_idx:]` whose own `__dict__`
defines `execute`."""
+ for cls in mro[start_idx:]:
+ if "execute" in cls.__dict__:
+ return cls
+ return None
+
+
+def _next_execute_via_super(origin: type, current: type) -> type | None:
+ """Find what `super().execute()` resolves to from `current`, per
`origin`'s MRO.
+
+ Must use `origin`'s MRO, not `current`'s own: with multiple inheritance
(e.g. a
+ `@task.kubernetes`-built class) they diverge, and a mixin's own MRO may
have no
+ relationship to the class the chain actually needs to reach.
+ """
+ try:
+ idx = origin.__mro__.index(current)
+ except ValueError:
+ return None
+ return _find_owner_of_execute(origin.__mro__, idx + 1)
+
+
+def _strip_comment_lines(source: str) -> str:
+ """Drop whole-line comments so they can't be mistaken for real delegation
code.
+
+ A comment can say the opposite of what the code does (e.g. "overrides
execute rather
+ than calling super().execute()"), and a raw-text search can't tell the two
apart.
+ """
+ return "\n".join(line for line in source.splitlines() if not
line.strip().startswith("#"))
+
+
+def _next_execute_hop(origin: type, current: type, source: str) -> type | None:
+ """Find the next class in `source`'s delegation chain, via
`super().execute()` or an
+ explicit `ParentClass.execute(...)` call.
+
+ Every explicit match is tried in order, since an unrelated earlier call
(e.g.
+ `cursor.execute(...)`) can otherwise shadow the real delegation. The
matched class is
+ resolved to whoever actually owns `execute` (it may only inherit one, e.g.
+ `GKEStartPodOperator.execute(self, context)`), the same way a `super()`
hop is.
+ """
+ source = _strip_comment_lines(source)
+ if _SUPER_EXECUTE_RE.search(source):
+ return _next_execute_via_super(origin, current)
+
+ mro_by_name = {base.__name__: base for base in origin.__mro__}
+ for match in _EXPLICIT_EXECUTE_RE.finditer(source):
+ named_cls = mro_by_name.get(match.group(1))
+ if named_cls is None:
+ continue
+ owner = _find_owner_of_execute(origin.__mro__,
origin.__mro__.index(named_cls))
+ if owner is not None:
+ return owner
+ return None
+
+
+def _find_marker_declaring_class(cls: type) -> type | None:
+ """Return the class in `cls`'s MRO whose own body sets
`__supports_durable_execution = True`.
+
+ The lookup is per-class (`_{base.__name__}__supports_durable_execution`),
not a fixed
+ string, and only matches a class whose own `__dict__` carries the
(mangled) name;
+ inheriting the attribute value from a base doesn't count, only writing it
yourself does.
+ Leading underscores in the class name are stripped first, matching
Python's own name
+ mangling rule (`_Foo` mangles to `_Foo__x`, not `__Foo__x`).
+ """
+ for base in cls.__mro__:
+ mangled = f"_{base.__name__.lstrip('_')}__supports_durable_execution"
+ if base.__dict__.get(mangled) is True:
+ return base
+ return None
+
+
+def _delegates_execute_to(cls: type, target: type, depth: int) -> bool:
+ """Return True if `cls`'s resolved `execute()` chain reaches
`target.execute`.
+
+ Covers a class that never overrides `execute` (inherits `target.execute`
directly, e.g.
+ GKEStartPodOperator), one whose override ends in
`super().execute(context)` (e.g.
+ EksPodOperator, SparkKubernetesOperator), and one that names the parent
class directly
Review Comment:
The docstring note promised in the round-4 reply is not here, and the
description points readers at it twice.
Reply 4012645933 said "Added a docstring note explaining that". At this
commit `grep -niE "coincid|get_or_create_pod|backstop"` over `dev/registry/`
returns nothing, and 44bd0106 did not touch this docstring. The description
meanwhile says "for a reason the detector doesn't actually check (see the
docstring on `_delegates_execute_to`)", and its table row reads "coincidental
reasoning per the `_delegates_execute_to` docstring note". Both send a reader
here for an explanation that does not exist.
The line above is the part worth changing. `SparkKubernetesOperator` is
listed as an example of "one whose override ends in
`super().execute(context)`", which is the reading round 4 questioned. At HEAD
its `execute` (`spark_kubernetes.py:352-361`) is:
```python
if self.deferrable:
self.execute_async(context)
return
return super().execute(context)
```
On the deferrable path it returns at :359 and never reaches
`super().execute(context)` at :361. `_delegates_execute_to` matches it
regardless, because `_SUPER_EXECUTE_RE.search` at :462 is a text search with no
view of the early return. The verdict is still right, for the reason you gave
in the reply: `execute_sync` and `execute_async` reach the pod reattach
independently.
Adding the note you described would close it, naming the failure mode it
guards against: a subclass whose `execute` neither reaches the marker-declaring
class nor goes through the pod reattach, yet inherits the durable claim. The
example list above also wants `SparkKubernetesOperator` either dropped or
marked as the case that matches for a reason the check does not test, since as
written it teaches the opposite.
##########
registry/src/css/main.css:
##########
@@ -3297,6 +3298,27 @@ main {
max-width: 16rem;
}
+.capability-filter-toggles {
Review Comment:
The `flex-wrap` landed here and it does fix this group, but the overflow
moved to the sibling rather than going away.
Measured by rendering the built bundle at the widest real label counts the
extractor produces (amazon, 115 and 115), with the pre-PR bundle as the
baseline. This group's own `scrollWidth - clientWidth` is 0 at every width
tested, and at 320, 360 and 390 the toggles wrap to two rows with no page
overflow, so the round-4 ask is satisfied.
What changed is the search input beside it. `.module-search-wrapper` at
:3297-3299 sets `max-width: 16rem` and no `min-width`, so it becomes the flex
item that absorbs the squeeze once the toggles stop shrinking, while
`.capability-filter-toggle` children are `white-space: nowrap` at :3316.
```
pre-PR 320-900px : no page overflow, search input 195-256px
HEAD 44bd0106 435-470px : page overflow up to 34px, search input 54px
530-560px : page overflow up to 32px, search input 54px
```
At 54px the placeholder "Search modules..." no longer fits. These are
large-phone-landscape and small-tablet widths rather than phone widths, which
is why a check at 390px now reads clean.
Adding `min-width: 12rem` to `.module-search-wrapper` takes the narrowest
search input to 193px and leaves no width between 320 and 900 with page scroll,
measured the same way. One line if you want it.
##########
dev/registry/extract_parameters.py:
##########
@@ -530,6 +770,7 @@ def make_entry(
"provider_id": provider_id,
"provider_name": provider_name,
"supports_durable_execution": is_durable_capable(cls_or_obj,
resumable_mixin),
+ "supports_deferrable": supports_deferrable(cls_or_obj),
Review Comment:
Decorator entries never receive either capability field, because this is the
only path that goes through `make_entry`.
The decorator loop builds its own dict literal (:904-931 at this commit)
instead of calling `make_entry`, so every `@task.*` entry ships 11 keys rather
than 13 and falls back to the contract defaults. That is all 24 decorators the
extractor discovers. Two matter for this feature: `@task.agent` wraps
`AgentOperator` and `@task.kubernetes` wraps `KubernetesPodOperator`, both of
which the detector reports durable, so those cards can never show a badge or
match either filter. The docstrings added this round name `@task.agent` and
`@task.kubernetes` as the motivating cases for `_EXPLICIT_EXECUTE_RE` and
`_delegates_execute_to`, so the walk was extended for exactly this delegation
shape and the result is then dropped.
`test_all_module_fields_present` (`test_extract_parameters.py:1002-1013`)
cannot catch it. It derives the required set from `fields(Module)`, which is
right, but its fixture sets `"task-decorators": []` at :821, so the decorator
path is never exercised. Giving that fixture one decorator entry makes the test
fail until the entry either goes through `make_entry` or gains the two fields.
On the description. "This is the single durable-only case across the entire
registry" does not hold: your own verification table lists `AgentOperator` at
durable True and deferrable False two sections below, which is a second
durable-only case, and `SparkSubmitOperator` is not in the table at all. The
per-class rows themselves check out, so it is this sentence and the aggregates
that are off. For the aggregates I would regenerate rather than patch the
numbers, and it is worth checking the run for import warnings while you do: the
claimed durable figure is lower than what the pre-merge baseline produces,
which points at a run that was dropping classes on import rather than one that
is merely stale.
Noticed rather than requested, since it predates this PR and sits outside
the diff: the durable badge tooltip says "Requires durable=True (default)".
That holds for twelve of the thirteen badged classes, because
`ResumableJobMixin` defaults `durable=True` (`resumablejobmixin.py:99`) and
`KubernetesPodOperator` resolves `None` to `True` on 3.3+ (`pod.py:417-420`).
`AgentOperator` declares `durable: bool = False` (`agent.py:267`), so for that
one the tooltip tells a reader the opposite of what they need to do.
##########
dev/registry/extract_parameters.py:
##########
@@ -421,15 +555,121 @@ def is_durable_capable(cls: type, resumable_mixin: type
| None) -> bool:
if inspect.isabstract(cls):
return False
- execute = getattr(cls, "execute", None)
- if execute is None:
- return False
+ return _execute_chain_calls_resumable(cls, _MAX_DEFERRAL_WALK_DEPTH)
+
+
+def _is_terminal_block(body: list[ast.stmt]) -> bool:
+ """Return True if `body`'s last statement always exits the function (raise
or return)."""
+ return bool(body) and isinstance(body[-1], (ast.Raise, ast.Return))
+
+
+def _strip_dead_version_guard_branches(source: str, resolve_global:
typing.Callable[[str], object]) -> str:
+ """Blank out code after a terminal `if <flag>: raise ...` whose flag
resolves True here.
+
+ Some operators write `if AIRFLOW_V_3_3_PLUS: raise ...` with no `else`,
followed by a
+ pre-3.3 `self.defer(...)` fallback that never runs on the core being
imported.
+ `resolve_global` looks up the flag by name rather than a fixed list.
+ """
+ try:
+ tree = ast.parse(textwrap.dedent(source))
+ except SyntaxError:
+ return source
+
+ if not tree.body or not isinstance(tree.body[0], (ast.FunctionDef,
ast.AsyncFunctionDef)):
+ return source
+
+ dead_from: int | None = None
+ for stmt in tree.body[0].body:
+ if (
+ isinstance(stmt, ast.If)
+ and not stmt.orelse
+ and isinstance(stmt.test, ast.Name)
+ and _is_terminal_block(stmt.body)
+ and resolve_global(stmt.test.id) is True
+ ):
+ dead_from = stmt.end_lineno
+ break
+
+ if dead_from is None:
+ return source
+ return "".join(source.splitlines(keepends=True)[:dead_from])
+
+
+def _get_reachable_method_source(cls: type, name: str) -> str | None:
+ """Like `_get_method_source`, but with dead version-guard branches
stripped first."""
+ method = getattr(cls, name, None)
+ if method is None:
+ return None
try:
- source = inspect.getsource(execute)
+ source = inspect.getsource(method)
except (OSError, TypeError):
+ return None
+
+ # unwrap() undoes a functools.wraps() decorator, whose __globals__ would
otherwise
+ # point at the decorator's own module instead of the method's.
+ func = inspect.unwrap(getattr(method, "__func__", method))
Review Comment:
Half of the round-4 fix depends on this `unwrap`, and nothing tests it.
Neutering `inspect.unwrap` here leaves the whole suite green at 88 passed,
yet it is the only reason four of the nine classes come back False.
`BaseOperatorMeta._apply_defaults` wraps `HITLOperator.execute`, and the
wrapper's `__globals__` does not carry `AIRFLOW_V_3_3_PLUS` while the unwrapped
function's does. Both report the same `__module__`, which is why reading
`__module__` would not have surfaced it. Without this line the guard flag
resolves to nothing, the dead branch is not stripped, and `HITLOperator`,
`ApprovalOperator`, `HITLBranchOperator` and `HITLEntryOperator` all go back to
True. The five common.ai classes are unaffected, since `defer_for_approval` is
not wrapped.
The reason no test covers it is structural rather than an oversight: the
file contains no `functools`, no `wraps`, no `__globals__` and no `unwrap`
anywhere, so every fixture is a plain method and none can reach this line. The
two fixtures added this round do genuinely constrain the stripper, and
reverting that half turns exactly
`test_defer_for_approval_helper_with_dead_fallback_does_not_false_match` and
`test_hitl_shaped_dead_fallback_does_not_false_match` red, so that half is
protected. This half is not.
A fixture with a `functools.wraps` wrapper whose `__globals__` belong to a
different module than the wrapped function would pin it, and it is worth
having, because a silent regression here puts back the exact nine-class false
positive this round was about.
Worth knowing that this is not specific to the 3.3 flag. `BaseOperatorMeta`
wraps `execute` on nearly every registered class, so the `__globals__` you get
back is usually not the method's own, and dozens of classes carry a version
flag that only becomes visible after unwrapping, across several
`AIRFLOW_V_3_*_PLUS` names rather than just this one. `_is_terminal_block` has
the same shape of gap: forcing it to return True also leaves the suite green,
though unlike this line it changes no verdict in the tree today.
##########
dev/registry/extract_parameters.py:
##########
@@ -421,15 +555,121 @@ def is_durable_capable(cls: type, resumable_mixin: type
| None) -> bool:
if inspect.isabstract(cls):
return False
- execute = getattr(cls, "execute", None)
- if execute is None:
- return False
+ return _execute_chain_calls_resumable(cls, _MAX_DEFERRAL_WALK_DEPTH)
+
+
+def _is_terminal_block(body: list[ast.stmt]) -> bool:
+ """Return True if `body`'s last statement always exits the function (raise
or return)."""
+ return bool(body) and isinstance(body[-1], (ast.Raise, ast.Return))
+
+
+def _strip_dead_version_guard_branches(source: str, resolve_global:
typing.Callable[[str], object]) -> str:
+ """Blank out code after a terminal `if <flag>: raise ...` whose flag
resolves True here.
+
+ Some operators write `if AIRFLOW_V_3_3_PLUS: raise ...` with no `else`,
followed by a
+ pre-3.3 `self.defer(...)` fallback that never runs on the core being
imported.
+ `resolve_global` looks up the flag by name rather than a fixed list.
+ """
+ try:
+ tree = ast.parse(textwrap.dedent(source))
+ except SyntaxError:
+ return source
+
+ if not tree.body or not isinstance(tree.body[0], (ast.FunctionDef,
ast.AsyncFunctionDef)):
+ return source
+
+ dead_from: int | None = None
+ for stmt in tree.body[0].body:
+ if (
+ isinstance(stmt, ast.If)
+ and not stmt.orelse
+ and isinstance(stmt.test, ast.Name)
+ and _is_terminal_block(stmt.body)
+ and resolve_global(stmt.test.id) is True
+ ):
+ dead_from = stmt.end_lineno
+ break
+
+ if dead_from is None:
+ return source
+ return "".join(source.splitlines(keepends=True)[:dead_from])
+
+
+def _get_reachable_method_source(cls: type, name: str) -> str | None:
+ """Like `_get_method_source`, but with dead version-guard branches
stripped first."""
+ method = getattr(cls, name, None)
+ if method is None:
+ return None
try:
- source = inspect.getsource(execute)
+ source = inspect.getsource(method)
except (OSError, TypeError):
+ return None
+
+ # unwrap() undoes a functools.wraps() decorator, whose __globals__ would
otherwise
+ # point at the decorator's own module instead of the method's.
+ func = inspect.unwrap(getattr(method, "__func__", method))
+ module_globals = getattr(func, "__globals__", None)
+ if module_globals is None:
+ return source
+ return _strip_dead_version_guard_branches(source, module_globals.get)
+
+
+def _references_deferral(
+ origin: type, current: type, source: str, visited: set[tuple[int, str]],
depth: int
+) -> bool:
+ """Return True if `source` (the resolved `execute()` of `current`, called
on `origin`) references deferral.
+
+ `origin` stays fixed across recursion so `super().execute()` hops resolve
against its
+ real MRO, while `current` walks forward through the chain.
+ """
+ if _DEFERRAL_TOKEN_RE.search(source):
Review Comment:
Grouped into one comment, since none of these changes a badge today.
This search runs on source with comments and docstrings intact:
`_get_reachable_method_source` strips dead version branches but not comments,
while the delegation path strips them at :461 via `_strip_comment_lines`, whose
docstring names this exact hazard. So a comment mentioning `self.defer()`, a
docstring `:raises TaskDeferred:`, an `except TaskDeferred`, or a plain
`self.deferrable = False` write all match. Instrumenting the regex to record
whether the deciding hit sat inside a comment or string gives zero across the
registered set, so nothing is misbadged. The shapes are in the tree, in
`MSGraphAsyncOperator.execute_complete` (`msgraph.py:238`) and at
`pod.py:1165`, but both sit in resume-side methods on classes that defer for
real.
At :646 the helper is resolved against `current`, while `self.<name>()`
dispatches on the instance's real class, which is `origin`. `current` advances
as `super().execute()` hops are followed, so past the first hop the walk reads
an ancestor's copy and cannot see a more derived override. The seeding also
differs from the siblings: this walk starts at `_references_deferral(cls, cls,
...)` (:669) while `_delegates_execute_to` (:500) and
`_execute_chain_calls_resumable` (:521) start at
`_find_owner_of_execute(cls.__mro__, 0)`. Resolving helpers against `origin`
across the dataset flips zero verdicts, and the obvious carriers miss it:
`EksPodOperator` and `GKEStartPodOperator` both override `invoke_defer_method`,
but `KubernetesPodOperator.execute` contains `if not self.deferrable:`
(`pod.py:803`), so the match at :625 returns True on that frame before the
helper loop runs.
`visited` is marked at :634 and :645 without recording the depth it was
searched at, so a node first reached shallow is skipped when re-reached with
more budget. That precondition fires 22 times on real data and still flips zero
verdicts, because the walk never bottoms out at `depth <= 0`:
`_MAX_DEFERRAL_WALK_DEPTH = 6` does not bind on any real chain. That is also
why reproducing it requires forcing a chain deeper than 6, and why it becomes
live if that constant is raised or a provider grows a deeper chain.
Fixes are one-liners each: call `_strip_comment_lines` inside
`_get_reachable_method_source`, tighten `TaskDeferred\b` to `raise
TaskDeferred` (which keeps the `VespaIngestOperator` case your comment at
:396-398 cites, `vespa_ingest.py:85`), resolve helpers against `origin`, and
key `visited` on depth. Worth one test either way: `TestIsDurableCapable` has
`test_comment_mentioning_delegation_is_not_mistaken_for_delegation` at
`test_extract_parameters.py:369`, and `TestSupportsDeferrable` (:584-651) has
fourteen tests with no comment or docstring case.
##########
dev/registry/tests/test_extract_parameters.py:
##########
@@ -266,12 +322,335 @@ def
test_manual_durable_marker_qualifies_without_mixin(self):
def test_subclass_not_redeclaring_marker_disqualifies(self):
assert is_durable_capable(ManuallyDurableSubclass,
FakeResumableJobMixin) is False
+ def test_subclass_with_no_execute_override_inherits_marker(self):
+ assert is_durable_capable(ManuallyDurableSubclassNoOverride,
FakeResumableJobMixin) is True
+
+ def test_subclass_delegating_via_super_execute_qualifies(self):
+ assert is_durable_capable(ManuallyDurableSubclassDelegating,
FakeResumableJobMixin) is True
+
+ def test_subclass_delegating_via_super_execute_multi_hop_qualifies(self):
+ assert is_durable_capable(ManuallyDurableSubclassDelegatingMultiHop,
FakeResumableJobMixin) is True
+
+ def test_multiple_inheritance_decorator_mixin_qualifies(self):
+ assert is_durable_capable(DecoratedDurableSubclass,
FakeResumableJobMixin) is True
+
+ def test_multiple_inheritance_no_own_execute_qualifies(self):
+ assert is_durable_capable(DecoratedDurableSubclassNoOwnExecute,
FakeResumableJobMixin) is True
+
+ def test_mixin_subclass_delegating_via_super_execute_qualifies(self):
+ class MixinSubclassDelegating(FullyImplementedResumableOperator):
+ def execute(self, context):
+ return super().execute(context)
+
+ assert is_durable_capable(MixinSubclassDelegating,
FakeResumableJobMixin) is True
+
+ def test_explicit_parent_class_delegation_qualifies(self):
+ assert is_durable_capable(ExplicitParentDelegatingSubclass,
FakeResumableJobMixin) is True
+
+ def
test_mixin_subclass_delegating_via_explicit_parent_call_qualifies(self):
+ class
MixinSubclassExplicitDelegating(FullyImplementedResumableOperator):
+ def execute(self, context):
+ return FullyImplementedResumableOperator.execute(self, context)
+
+ assert is_durable_capable(MixinSubclassExplicitDelegating,
FakeResumableJobMixin) is True
+
+ def test_declaring_class_with_leading_underscore_is_found(self):
+ """Python strips leading underscores from the class name when
mangling, so the
+ lookup must too, or a declaring class like `_FooOperator` is never
found."""
+
+ class _UnderscoreOperator:
+ __supports_durable_execution = True
+
+ def execute(self, context):
+ return None
+
+ assert is_durable_capable(_UnderscoreOperator, FakeResumableJobMixin)
is True
+
+ def
test_comment_mentioning_delegation_is_not_mistaken_for_delegation(self):
+ """A comment can say the opposite of what the code does; a raw-text
search must not
+ be fooled by it into reporting capable."""
+
+ class CommentedNonDelegatingSubclass(ManuallyDurableOperator):
+ def execute(self, context):
+ # overrides execute rather than calling super().execute(),
+ return None
+
+ assert is_durable_capable(CommentedNonDelegatingSubclass,
FakeResumableJobMixin) is False
+
+ def test_decoy_execute_call_does_not_shadow_real_delegation(self):
Review Comment:
This passes for a different reason than its name, so it does not currently
bind the behaviour it describes.
The decoy is `cursor.execute("select 1")`, and `cursor` is a local variable
rather than a class in the MRO. `_next_execute_hop` builds `mro_by_name` from
`origin.__mro__` class names, so `mro_by_name.get("cursor")` returns None and
the loop takes the `continue` at :468. It never reaches the
try-every-match-in-order behaviour that the docstring at :456-458 describes,
which is the thing the test is named for. Changing that loop to return on the
first resolved match leaves the whole suite green.
A decoy that names a class which is in the MRO but owns no `execute`, placed
before the real delegation, would make it bind.
--
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]