This is an automated email from the ASF dual-hosted git repository.
bbovenzi 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 7c014e1b61a Fix plugin applies_to scoping defects found in review
(#74425)
7c014e1b61a is described below
commit 7c014e1b61a6fe9a77f7ecb0a6600f5c1e9b26e7
Author: Brent Bovenzi <[email protected]>
AuthorDate: Thu Oct 8 13:27:19 2026 -0400
Fix plugin applies_to scoping defects found in review (#74425)
Several things shipped with the field-path scoping in #74278 that review
caught after it merged, plus two fixes that depend on work landing since.
The troubleshooting steps name `airflow plugins list`, which does not exist
--
`plugins` takes no subcommand -- so anyone following them to find out why a
view is not scoped as expected gets an argparse error instead.
The operator section recommends leaving `operator` and
`class_ref.class_name`
unqualified so each page reads its own record, which is right, but the
load-time check then reports the one the page lacks as a path that will make
the view "appear in more places than intended". That is untrue for this
block:
the other path is what scopes this destination. Following the documentation
should not produce a warning saying the documentation is wrong. The check
now
tells a path that names no field anywhere -- a misspelling -- apart from one
that names a real field on a record this destination does not have, and says
only what is true of each. The section leads with one view per destination,
which needs no such reasoning, and keeps the single block as the shorter
form.
A path that matches no field at all now withholds the view instead of
loading
it. The browser cannot tell a misspelling from a field this page's record
simply does not have -- a block shared across destinations relies on the
latter being skipped -- so it skips both, and skipping widens the scope. A
typo therefore showed the view everywhere, the opposite of what its author
asked for. The response models are in hand when plugins load, so the two are
distinguishable there: a field no record has cannot be anything but a
mistake,
while one a sibling record has is the shared-block pattern and still loads.
A
view that disappears gets noticed; one on every page looks deliberate.
Validation also edited the plugin author's own `applies_to` dictionary to
drop
null values. Harmless in isolation, but a plugin may pass one dictionary to
several views, and rewriting it is not this code's to do.
Which record an unqualified path is rooted at is also decided twice: once
when
plugins load, to choose what to validate, and again in the browser, to
choose
what to match. Nothing at runtime notices when the two tables disagree -- a
destination only one of them knows about makes every path on it unevaluable,
which shows the view everywhere instead of scoping it, and would surface
later
as someone else's bug. A prek check compares them, so that drift fails
before
it merges.
Scoping a task-level view on `task.*` judged historical task instances by
the
Dag's definition today, so a view scoped to an operator stopped appearing on
the runs that actually used it as soon as the task changed. #74365 added a
version to the task endpoint, so the task is now read at the version the
instance ran. That costs a round trip on task instance routes, since the
version is not known until the instance arrives, and asking earlier would
fetch a task that has to be discarded.
The newsfragment was a documentation section rather than a release note, so
it
is cut to what the release notes need and points at the docs for the rest.
Editing it trips `check-newsfragment-pr-number`, which expects any
newsfragment
a PR touches to be named for that PR; renaming this one would credit the
wrong
PR for the feature, so the check is skipped by label instead.
---
.pre-commit-config.yaml | 11 ++
.../docs/administration-and-deployment/plugins.rst | 105 ++++++++------
airflow-core/newsfragments/74278.feature.rst | 2 +-
airflow-core/src/airflow/plugins_manager.py | 93 +++++++++---
.../ui/src/hooks/usePluginAppliesToContext.test.ts | 32 ++++-
.../ui/src/hooks/usePluginAppliesToContext.ts | 49 ++++---
.../airflow/ui/src/utils/pluginAppliesTo.test.ts | 7 +-
.../tests/unit/plugins/test_plugins_manager.py | 158 +++++++++++++++++++--
scripts/ci/prek/check_applies_to_roots_in_sync.py | 117 +++++++++++++++
.../ci/prek/test_check_applies_to_roots_in_sync.py | 84 +++++++++++
10 files changed, 562 insertions(+), 96 deletions(-)
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index bfb428ed116..8392915d926 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -248,6 +248,17 @@ repos:
^pyproject\.toml$
pass_filenames: false
require_serial: true
+ - id: check-applies-to-roots-in-sync
+ name: Check plugin applies_to destination tables stay in sync
+ description: "The UI mirrors plugins_manager's destination -> record
map; drift silently widens plugin scope"
+ entry: ./scripts/ci/prek/check_applies_to_roots_in_sync.py
+ language: python
+ files: >
+ (?x)
+ ^airflow-core/src/airflow/plugins_manager\.py$|
+ ^airflow-core/src/airflow/ui/src/utils/pluginAppliesTo\.ts$
+ pass_filenames: false
+ require_serial: true
- id: check-ci-workflows-in-sync
name: Check ci-arm.yml and ci-amd.yml stay in sync
entry: ./scripts/ci/prek/check_ci_workflows_in_sync.py
diff --git a/airflow-core/docs/administration-and-deployment/plugins.rst
b/airflow-core/docs/administration-and-deployment/plugins.rst
index 57a0722c78e..d5861d0bb17 100644
--- a/airflow-core/docs/administration-and-deployment/plugins.rst
+++ b/airflow-core/docs/administration-and-deployment/plugins.rst
@@ -411,10 +411,11 @@ relevant instead of appearing on every Dag:
"dag.dag_id": ["train_pipeline"],
}
-Keys are **dotted field paths** into the records the page has, not a fixed set
of criteria, so
-anything the REST API returns for an entity is addressable — ``state``,
``operator``, ``pool``,
-``queue``, ``try_number``, and so on. Values are matched for equality, and are
compared as
-strings, so numeric and boolean fields work without quoting rules of their own
+Keys are **dotted field paths** into the records the page has, so any field
the REST API
+returns for an entity is addressable. Use the names the API returns, which are
not always the
+Python attribute names: a task instance's run is ``dag_run_id``, not
``run_id``, and computed
+fields such as a Dag's ``is_backfillable`` work like any other. Values are
matched for equality
+and compared as strings, so numbers and booleans need no quoting rules of
their own
(``"try_number": ["2"]``, ``"is_paused": ["false"]``).
An **unqualified path is rooted at the entity the destination is about** — on
``dag_run``,
@@ -423,14 +424,11 @@ name a related record as its first segment: ``dag``,
``dag_run``, ``task`` or
``task_instance``. Traversing a list fans out across it, so ``dag.tags.name``
collects every
tag name and matches if any of them is listed.
-Paths combine like Kubernetes label selectors — **OR within a path, AND across
paths**. A Dag
-matching any listed tag satisfies ``dag.tags.name``, and a view configured
with both
-``dag.tags.name`` and ``state`` requires both to match.
-
-Crucially, the AND applies **only across paths the current page can
evaluate**. A
-``task_instance.*`` path cannot be judged on a Dag-level page, so it is
skipped there rather
-than failing the match. This lets one ``applies_to`` block be shared by a
plugin's Dag- and
-task-level destinations. Which records each destination resolves:
+Paths combine like Kubernetes label selectors — **OR within a path, AND across
paths** — but
+the AND applies only across paths the page can evaluate. A ``task_instance.*``
path cannot be
+judged on a Dag-level page, so it is skipped there rather than failing the
match, which is what
+lets one block be shared across a plugin's destinations. Which records each
destination
+resolves:
.. list-table::
:header-rows: 1
@@ -457,19 +455,29 @@ task-level destinations. Which records each destination
resolves:
If none of the configured paths can be evaluated on a given page, the view is
shown. On task
group pages the task-level records are absent, since a group is not a task.
-A path is also skipped when the record exists but has no such field. That
means a **bad path
-widens the scope rather than narrowing it**, so paths are checked against the
API response
-models when plugins load and a path naming no field is logged with a suggested
correction.
-The check stops at a field whose contents the models do not describe — the
dict behind
-``class_ref``, or a Dag Run's ``conf`` — so a path below one of those is still
accepted and
-still widens silently. If a view appears in more places than you expect, run
-``airflow plugins list --verbose`` first, then check the path against the REST
API response for
-that entity.
+A path is also skipped when the record exists but has no such field, which on
its own would mean
+a **typo widens the scope rather than narrowing it**. So paths are checked
against the API
+response models when plugins load, and one that names a field *no* record has
is treated as a
+misconfiguration: the view is **not loaded at all**, and the reason is logged
as an error with a
+suggested correction.
+
+.. code-block:: text
+
+ Plugin 'acme' has an external view 'Incidents' with an 'applies_to' path
that matches no
+ field: 'dag.tags.nme' names no field 'nme' on DagTagResponse (did you mean
'name'?). It
+ could never scope the way it asks to, so the view will not be loaded.
-Segments are the field names **the REST API returns**, which are not always
the Python
-attribute names: a task instance's run is ``dag_run_id``, not ``run_id``, and
computed fields
-such as a Dag's ``is_backfillable`` are addressable like any other. If a field
appears in the
-API response for an entity, it can be named here.
+Withholding it is deliberate: the author asked to narrow by something that
cannot exist, so the
+view can never appear for the reason they intended, and a view that disappears
gets noticed
+while one on every page looks deliberate.
+
+A path naming a field some *other* record has is a different matter — that is
the shared-block
+pattern, so it is reported as skipped here and the view loads. Neither check
can see past a
+field the models do not describe — the dict behind ``class_ref``, or a Dag
Run's ``conf`` — so a
+typo below one of those still widens silently.
+
+If a view appears in more places than you expect, run ``airflow plugins
--verbose``, then check
+the path against the REST API response for that entity.
An empty list is different from a missing field: a Dag with no tags has
definitively answered
``dag.tags.name``, so the view is not shown. A path that stops short of a leaf
is likewise a
@@ -487,14 +495,30 @@ unqualified path targets an operator on either page**:
"applies_to": {"operator_name": ["KubernetesPodOperator"]}
-``operator_name`` is the display name shown in the UI (an operator's
-``custom_operator_name``). For a plain operator it is the class name, but the
two differ for
-decorator-based tasks: a ``@task.bash`` task has the display name
``@task.bash`` and the
-private class name ``_BashDecoratedOperator``, so ``operator_name`` is the one
to match on.
+``operator_name`` is the display name the UI shows (an operator's
``custom_operator_name``).
+It equals the class name for a plain operator, but not for a decorated one: a
``@task.bash``
+task is named ``@task.bash`` and classed ``_BashDecoratedOperator``.
+
+It is also the only operator field both records carry. The *class* name is
spelled differently
+on each — a task instance has ``operator``, a task has
``class_ref.class_name`` — so targeting
+it takes one view per destination, each scoped on its own record:
-If you specifically need the operator *class* name, the two records spell it
differently — a
-task instance has ``operator``, while a task carries it through
``class_ref.class_name``.
-Leave both paths **unqualified** so that each page reads its own record:
+.. code-block:: python
+
+ external_views = [
+ {
+ # ... name, href, and a url_route of its own
+ "destination": "task_instance",
+ "applies_to": {"operator": ["KubernetesPodOperator"]},
+ },
+ {
+ # ...
+ "destination": "task",
+ "applies_to": {"class_ref.class_name": ["KubernetesPodOperator"]},
+ },
+ ]
+
+One block naming both also works, since whichever field this page's record
lacks is skipped:
.. code-block:: python
@@ -505,17 +529,14 @@ Leave both paths **unqualified** so that each page reads
its own record:
"class_ref.class_name": ["KubernetesPodOperator"],
}
-.. warning::
- Do not write these as ``task_instance.operator`` and
``task.class_ref.class_name``. A task
- instance page resolves **both** records, so both paths would be evaluated
and AND-ed — and
- the task record always carries the Dag's *current* definition. An instance
that ran under
- ``KubernetesPodOperator`` would stop matching the moment the task was
changed to a
- different operator, hiding the view on historical runs.
+Qualifying them — ``task_instance.operator`` and ``task.class_ref.class_name``
— works too, but
+says the same thing twice: a task instance page resolves both records, so both
are evaluated and
+AND-ed. The task record is read at the version that instance ran, so the two
agree; naming one
+of them is enough.
-This is the general rule where records overlap: **prefer an unqualified
path**, which always
-reads the entity the page is about. Qualify a path only when you genuinely
mean the related
-record — ``dag.tags.name`` from a task instance, say — and not as a way of
naming the same
-concept twice.
+The general rule where records overlap: **prefer an unqualified path**, which
always reads the
+entity the page is about, and qualify one only when you genuinely mean a
different record —
+``dag.tags.name`` from a task instance, say.
A malformed ``applies_to`` — one that is not a dictionary, has a non-string or
empty path, or
gives a path something other than a list of strings — is reported as a warning
when plugins
@@ -530,7 +551,7 @@ server log, run:
.. code-block:: bash
- airflow plugins list --verbose
+ airflow plugins --verbose
Each invocation is a fresh process, so every warning for every plugin is
reported. The
``--verbose`` flag is required: without it the command suppresses log output.
diff --git a/airflow-core/newsfragments/74278.feature.rst
b/airflow-core/newsfragments/74278.feature.rst
index 69f483b4d0f..b6fba929773 100644
--- a/airflow-core/newsfragments/74278.feature.rst
+++ b/airflow-core/newsfragments/74278.feature.rst
@@ -1 +1 @@
-Plugin external views and React apps can be scoped with an optional
``applies_to`` block: a map of dotted field path to the values that path may
take, such as ``{"state": ["failed"], "dag.tags.name": ["ml"]}``. Any field the
REST API returns for the entity is addressable, so a tab or panel appears only
where it is relevant instead of on every Dag. An unqualified path is rooted at
the entity the ``destination`` is about; a path may instead name a related
record (``dag``, ``dag_run``, ``ta [...]
+Plugin external views and React apps can be scoped with an optional
``applies_to`` block, a map of dotted field path to the values that path may
take, such as ``{"state": ["failed"], "dag.tags.name": ["ml"]}``. This replaces
the five fixed criteria added earlier in this development cycle and never
released (``dag_tags``, ``dag_ids``, ``task_ids``, ``operators``,
``operator_names``). A path naming a field no record has is treated as a
misconfiguration rather than a filter that never match [...]
diff --git a/airflow-core/src/airflow/plugins_manager.py
b/airflow-core/src/airflow/plugins_manager.py
index 599b22fc201..67021ef58ae 100644
--- a/airflow-core/src/airflow/plugins_manager.py
+++ b/airflow-core/src/airflow/plugins_manager.py
@@ -334,21 +334,46 @@ def _describe_applies_to_error(applies_to: Any) -> str |
None:
return None
-def _validate_applies_to(plugin_name: str | None, view: ExternalViewDict |
ReactAppDict, kind: str) -> None:
+def _is_applies_to_path_known_elsewhere(path: str) -> bool:
"""
- Warn about scoping a UI plugin cannot honour, and strip it if it is
malformed.
+ Whether an unqualified ``path`` names a real field on some record other
than this one.
- A malformed block is removed so the view still loads unscoped, matching
the default for
- a view that omits ``applies_to`` entirely. Criteria the destination cannot
evaluate are
- only warned about — they are skipped at match time by design, so that one
block can be
- shared across a plugin's Dag- and task-level destinations.
+ ``class_ref.class_name`` means nothing on a task instance but everything
on a task, and an
+ author targeting both pages from one block writes exactly that. Such a
path is skipped at
+ match time by design, so it must not be reported as a misspelling.
+
+ Only asked about unqualified paths: one that names its record explicitly
has no other
+ reading, so a bad field there is simply wrong.
+ """
+ return any(_describe_applies_to_path_error(path, root) is None for root in
_applies_to_root_models())
+
+
+def _validate_applies_to(plugin_name: str | None, view: ExternalViewDict |
ReactAppDict, kind: str) -> bool:
+ """
+ Check a UI plugin's scoping, returning whether the view should be loaded
at all.
+
+ Three failures, wanting three different things:
+
+ * **Malformed** block -- not a dictionary, a non-string path, a value that
is not a list of
+ strings. Dropped, and the view loads unscoped, as it would without
``applies_to`` at all.
+ Nothing of the author's intent survives to act on.
+ * **Unevaluable** path -- its root record is one this destination does not
have, or it names
+ a field only a sibling record has. Warned about and kept: both are
skipped at match time
+ by design, which is what lets one block be shared across a plugin's
destinations.
+ * **A path naming a field no record has.** The author asked to narrow by
something that
+ cannot exist, so the view can never appear for the reason they intended.
Returning
+ ``False`` withholds it rather than letting the browser skip the path and
show it
+ everywhere. A view that disappears gets noticed; one on every page looks
deliberate.
+
+ The view gets a new dict rather than an edited one, because a plugin may
pass the same
+ ``applies_to`` object to several views and it is not this function's to
rewrite.
"""
if "applies_to" not in view:
- return
+ return True
applies_to = view["applies_to"]
if applies_to is None:
- return
+ return True
if error := _describe_applies_to_error(applies_to):
log.warning(
@@ -359,20 +384,23 @@ def _validate_applies_to(plugin_name: str | None, view:
ExternalViewDict | React
error,
)
del view["applies_to"]
- return
+ return True
# A null value means "no values configured", which the matcher already
ignores the same way
- # it ignores an empty list. Leaving it in place would fail
`PluginAppliesToResponse`
+ # it ignores an empty list. Carrying it through would fail
`PluginAppliesToResponse`
# serialization and drop the whole plugin -- including its other, valid
views -- from the
- # plugins API. Dropping the path keeps the rest of the block working.
- for path in [path for path, values in applies_to.items() if values is
None]:
- del applies_to[path]
+ # plugins API.
+ #
+ # The view gets a new dict rather than an edited one, because a plugin may
pass the same
+ # `applies_to` object to several views and it is not this function's to
rewrite.
+ applies_to = {path: list(values) for path, values in applies_to.items() if
values is not None}
+ view["applies_to"] = applies_to
destination = view.get("destination", "nav")
if destination not in _APPLIES_TO_ROOTS:
# An unrecognised destination already fails serialization; warning
here too would
# only add noise pointing at the wrong problem.
- return
+ return True
available = _APPLIES_TO_ROOTS[destination]
unevaluable = sorted(
@@ -398,16 +426,35 @@ def _validate_applies_to(plugin_name: str | None, view:
ExternalViewDict | React
root = _applies_to_path_root(path, destination)
if not values or root is None or root not in available:
continue
- if error := _describe_applies_to_path_error(path, root):
+ error = _describe_applies_to_path_error(path, root)
+ if error is None:
+ continue
+ head, _, rest = path.partition(".")
+ if not (head in _APPLIES_TO_ROOT_NAMES and rest) and
_is_applies_to_path_known_elsewhere(path):
+ # A real field, just not on this destination's record. Saying the
scope widened
+ # would be untrue: the block's other paths are what scope this
destination.
log.warning(
- "Plugin '%s' has %s '%s' with an 'applies_to' path that
matches no field: %s. "
- "That path will be ignored, so the %s will appear in more
places than intended.",
+ "Plugin '%s' has %s '%s' with an 'applies_to' path '%s' that
no %s has. It is "
+ "skipped here; it only scopes the destinations whose record
does have it.",
plugin_name,
kind,
view.get("name"),
- error,
- kind.split()[-1],
+ path,
+ root,
)
+ continue
+ log.error(
+ "Plugin '%s' has %s '%s' with an 'applies_to' path that matches no
field: %s. "
+ "It could never scope the way it asks to, so the %s will not be
loaded.",
+ plugin_name,
+ kind,
+ view.get("name"),
+ error,
+ kind.split()[-1],
+ )
+ return False
+
+ return True
@cache
@@ -430,7 +477,9 @@ def _get_ui_plugins() -> tuple[list[ExternalViewDict],
list[ReactAppDict]]:
)
external_views_to_remove.append(external_view)
continue
- _validate_applies_to(plugin.name, external_view, "an external
view")
+ if not _validate_applies_to(plugin.name, external_view, "an
external view"):
+ external_views_to_remove.append(external_view)
+ continue
url_route = external_view.get("url_route")
if url_route is None:
continue
@@ -455,7 +504,9 @@ def _get_ui_plugins() -> tuple[list[ExternalViewDict],
list[ReactAppDict]]:
)
react_apps_to_remove.append(react_app)
continue
- _validate_applies_to(plugin.name, react_app, "a React App")
+ if not _validate_applies_to(plugin.name, react_app, "a React App"):
+ react_apps_to_remove.append(react_app)
+ continue
url_route = react_app.get("url_route")
if url_route is None:
continue
diff --git
a/airflow-core/src/airflow/ui/src/hooks/usePluginAppliesToContext.test.ts
b/airflow-core/src/airflow/ui/src/hooks/usePluginAppliesToContext.test.ts
index aaf515ea077..8fb52712973 100644
--- a/airflow-core/src/airflow/ui/src/hooks/usePluginAppliesToContext.test.ts
+++ b/airflow-core/src/airflow/ui/src/hooks/usePluginAppliesToContext.test.ts
@@ -41,15 +41,17 @@ vi.mock("react-router-dom", async (importOriginal) => ({
useParams: () => mockParams,
}));
-const { calls, record } = vi.hoisted(() => {
+const { calls, data, record } = vi.hoisted(() => {
const captured: Record<string, { key: unknown; options?: { enabled?: boolean
}; params: unknown }> = {};
+ const resolved: Record<string, unknown> = { taskInstance: undefined };
return {
calls: captured,
+ data: resolved,
record: (name: string) => (params: unknown, key: unknown, options?: {
enabled?: boolean }) => {
captured[name] = { key, options, params };
- return { data: undefined, isLoading: false };
+ return { data: resolved[name], isLoading: false };
},
};
});
@@ -65,6 +67,7 @@ vi.mock("openapi/queries", async (importOriginal) => ({
describe("usePluginAppliesToContext", () => {
beforeEach(() => {
mockParams = { dagId, mapIndex: "-1", runId, taskId };
+ data.taskInstance = undefined;
});
it("issues no query when no view needs scoping", () => {
@@ -76,7 +79,32 @@ describe("usePluginAppliesToContext", () => {
expect(calls.taskInstance?.options?.enabled).toBe(false);
});
+ it("holds the task query until the instance names the version it ran", () =>
{
+ renderHook(() => usePluginAppliesToContext(true));
+
+ // Asking now would fetch the Dag's current task and have to discard it.
+ expect(calls.taskInstance?.options?.enabled).toBe(true);
+ expect(calls.task?.options?.enabled).toBe(false);
+
+ data.taskInstance = { dag_version: { version_number: 3 } };
+ renderHook(() => usePluginAppliesToContext(true));
+
+ expect(calls.task?.options?.enabled).toBe(true);
+ expect(calls.task?.params).toStrictEqual({ dagId, taskId, versionNumber: 3
});
+ });
+
+ it("asks for the latest task where there is no instance to pin it to", () =>
{
+ mockParams = { dagId, taskId };
+
+ renderHook(() => usePluginAppliesToContext(true));
+
+ expect(calls.task?.options?.enabled).toBe(true);
+ expect(calls.task?.params).toStrictEqual({ dagId, taskId, versionNumber:
undefined });
+ });
+
it("enables every query a full task instance route can resolve", () => {
+ data.taskInstance = { dag_version: { version_number: 3 } };
+
renderHook(() => usePluginAppliesToContext(true));
expect(calls.dag?.options?.enabled).toBe(true);
diff --git a/airflow-core/src/airflow/ui/src/hooks/usePluginAppliesToContext.ts
b/airflow-core/src/airflow/ui/src/hooks/usePluginAppliesToContext.ts
index 829247235f6..a67aa187be3 100644
--- a/airflow-core/src/airflow/ui/src/hooks/usePluginAppliesToContext.ts
+++ b/airflow-core/src/airflow/ui/src/hooks/usePluginAppliesToContext.ts
@@ -32,11 +32,14 @@ import type { AppliesToContext } from
"src/utils/pluginAppliesTo";
*
* Pass `enabled: false` where no view configures scoping, so none of these
run at all.
* Otherwise each query is gated on the route params it needs, so it stays
disabled where
- * those params are absent. Passing no explicit `queryKey` is what keeps these
on the same
- * cache entry the surrounding page already populated — `DetailsLayout` for
the Dag, the Task
- * and TaskInstance pages for their own records — since the generated hook
builds the key from
- * the params via the same `Use*KeyFn` the pages go through.
`usePluginAppliesToContext.test.ts`
- * asserts that equality so a param change on either side cannot silently
split the cache.
+ * those params are absent. Passing no explicit `queryKey` builds the key from
the params via
+ * the same `Use*KeyFn` the pages go through, so these share the surrounding
page's cache entry
+ * wherever that page passes the same params — `DetailsLayout` for the Dag,
the Task and
+ * TaskInstance pages for their own records. The task query adds the
instance's version number,
+ * so it shares with a page that asks for the same version and pays for its
own fetch otherwise,
+ * rather than going wrong.
+ * `usePluginAppliesToContext.test.ts` asserts the equality that holds today,
so a param change
+ * on either side surfaces here rather than silently splitting the cache.
* Task groups are skipped for the task query, since `groupId` is not a
task_id and would 404.
*/
export const usePluginAppliesToContext = (enabled: boolean): AppliesToContext
=> {
@@ -53,25 +56,37 @@ export const usePluginAppliesToContext = (enabled:
boolean): AppliesToContext =>
{ enabled: enabled && Boolean(dagId) && Boolean(runId) },
);
- const { data: task, isLoading: isTaskLoading } = useTaskServiceGetTask({
dagId, taskId }, undefined, {
- enabled: enabled && Boolean(dagId) && Boolean(taskId) && groupId ===
undefined,
- });
+ const hasTaskInstance =
+ enabled &&
+ Boolean(dagId) &&
+ Boolean(runId) &&
+ Boolean(taskId) &&
+ groupId === undefined &&
+ !isNaN(parsedMapIndex);
const { data: taskInstance, isLoading: isTaskInstanceLoading } =
useTaskInstanceServiceGetMappedTaskInstance(
{ dagId, dagRunId: runId, mapIndex: parsedMapIndex, taskId },
undefined,
- {
- enabled:
- enabled &&
- Boolean(dagId) &&
- Boolean(runId) &&
- Boolean(taskId) &&
- groupId === undefined &&
- !isNaN(parsedMapIndex),
- },
+ { enabled: hasTaskInstance },
);
+ // Pinned to the version this instance ran, so `task.*` paths judge it by
the definition it
+ // used rather than the Dag's current one. Waiting for the instance costs a
round trip on task
+ // instance routes, but asking before then would fetch the latest task and
have to discard it.
+ const { data: task, isLoading: isTaskLoading } = useTaskServiceGetTask(
+ { dagId, taskId, versionNumber: taskInstance?.dag_version?.version_number
},
+ undefined,
+ {
+ enabled:
+ enabled &&
+ Boolean(dagId) &&
+ Boolean(taskId) &&
+ groupId === undefined &&
+ (!hasTaskInstance || taskInstance !== undefined),
+ },
+ );
+
return {
dag,
dagRun,
diff --git a/airflow-core/src/airflow/ui/src/utils/pluginAppliesTo.test.ts
b/airflow-core/src/airflow/ui/src/utils/pluginAppliesTo.test.ts
index 3db8e67ef77..136c84d1166 100644
--- a/airflow-core/src/airflow/ui/src/utils/pluginAppliesTo.test.ts
+++ b/airflow-core/src/airflow/ui/src/utils/pluginAppliesTo.test.ts
@@ -242,9 +242,10 @@ describe("matchesAppliesTo — combining paths", () => {
expect(matchesAppliesTo({ ...view, destination: "task_instance" },
taskInstanceContext)).toBe(true);
});
- // A task instance page resolves the task record too, and that record always
carries the Dag's
- // current definition -- which may differ from what this instance actually
ran.
- it("reads the page's own operator when the task has since been changed", ()
=> {
+ // A task instance page resolves the task record too.
`usePluginAppliesToContext` fetches it at
+ // the version the instance ran, so the two agree; this pins the matcher's
half of that, that
+ // each unqualified path reads its own record.
+ it("reads the page's own operator when the records disagree", () => {
const staleContext: AppliesToContext = {
dag,
dagRun: makeDagRun("failed"),
diff --git a/airflow-core/tests/unit/plugins/test_plugins_manager.py
b/airflow-core/tests/unit/plugins/test_plugins_manager.py
index 56dc6095e97..686caacd55d 100644
--- a/airflow-core/tests/unit/plugins/test_plugins_manager.py
+++ b/airflow-core/tests/unit/plugins/test_plugins_manager.py
@@ -446,26 +446,37 @@ class TestPluginsManager:
]
@pytest.mark.parametrize(
- ("path", "error"),
+ ("destination", "path", "error"),
[
pytest.param(
+ "dag_run",
"dag.tags.nme",
"'dag.tags.nme' names no field 'nme' on DagTagResponse (did
you mean 'name'?)",
id="misspelled-nested-leaf",
),
pytest.param(
+ "dag_run",
"stat",
"'stat' names no field 'stat' on DAGRunResponse (did you mean
'state'?)",
id="misspelled-own-field",
),
pytest.param(
+ "dag_run",
"state.length",
"'state.length' reads 'length' from DagRunState, which has no
fields",
id="path-past-a-scalar",
),
+ # Naming the record explicitly leaves no second reading, so a bad
field there is
+ # simply wrong -- even though `class_ref` is real on the record it
names.
+ pytest.param(
+ "task_instance",
+ "task.class_rf",
+ "'task.class_rf' names no field 'class_rf' on TaskResponse
(did you mean 'class_ref'?)",
+ id="misspelled-behind-an-explicit-record",
+ ),
],
)
- def test_warns_about_a_path_matching_no_field(self, path, error, caplog):
+ def test_withholds_a_view_whose_path_matches_no_field(self, destination,
path, error, caplog):
class TestPlugin(AirflowPlugin):
name = "test_plugin"
@@ -474,9 +485,16 @@ class TestPluginsManager:
"name": "Scoped",
"href": "/scoped",
"url_route": "/scoped",
- "destination": "dag_run",
+ "destination": destination,
"applies_to": {path: ["x"]},
- }
+ },
+ {
+ "name": "Fine",
+ "href": "/fine",
+ "url_route": "/fine",
+ "destination": destination,
+ "applies_to": {"state": ["failed"]},
+ },
]
with (
@@ -487,17 +505,16 @@ class TestPluginsManager:
external_views, _ = plugins_manager._get_ui_plugins()
- # Warned about, not stripped: the path is inert either way, and
dropping it would
- # change the block the UI receives.
- assert external_views[0]["applies_to"] == {path: ["x"]}
+ # Withheld rather than shown everywhere; its sibling is untouched.
+ assert [view["name"] for view in external_views] == ["Fine"]
assert caplog.record_tuples == [
(
"airflow.plugins_manager",
- logging.WARNING,
+ logging.ERROR,
f"Plugin 'test_plugin' has an external view 'Scoped' with an
'applies_to' path that "
- f"matches no field: {error}. That path will be ignored, so the
view will appear in "
- f"more places than intended.",
+ f"matches no field: {error}. It could never scope the way it
asks to, so the view "
+ f"will not be loaded.",
),
]
@@ -621,6 +638,127 @@ class TestPluginsManager:
assert caplog.record_tuples == []
+ def test_documented_operator_recipes_load_without_warnings(self, caplog):
+ """The per-destination recipe in plugins.rst must not trip the path
validator."""
+
+ class TestPlugin(AirflowPlugin):
+ name = "test_plugin"
+
+ external_views = [
+ {
+ "name": "Pod logs",
+ "href": "https://example.com/pods",
+ "url_route": "pod-logs",
+ "destination": "task_instance",
+ "applies_to": {"operator": ["KubernetesPodOperator"]},
+ },
+ {
+ "name": "Pod logs",
+ "href": "https://example.com/pods",
+ "url_route": "pod-logs-task",
+ "destination": "task",
+ "applies_to": {"class_ref.class_name":
["KubernetesPodOperator"]},
+ },
+ # The portable single-path form the docs lead with, on both
destinations.
+ {
+ "name": "By display name",
+ "href": "https://example.com/op",
+ "url_route": "op",
+ "destination": "task_instance",
+ "applies_to": {"operator_name": ["KubernetesPodOperator"]},
+ },
+ {
+ "name": "By display name",
+ "href": "https://example.com/op",
+ "url_route": "op-task",
+ "destination": "task",
+ "applies_to": {"operator_name": ["KubernetesPodOperator"]},
+ },
+ ]
+
+ with (
+ mock_plugin_manager(plugins=[TestPlugin()]),
+ caplog.at_level(logging.WARNING, logger="airflow.plugins_manager"),
+ ):
+ from airflow import plugins_manager
+
+ plugins_manager._get_ui_plugins()
+
+ assert caplog.record_tuples == []
+
+ def test_reports_a_shared_block_path_as_skipped_not_as_widening(self,
caplog):
+ """`class_ref.class_name` is meaningless on a task instance but real
on a task.
+
+ An author targeting both pages from one block writes exactly that, so
saying the view
+ will appear in more places than intended would be untrue -- `operator`
is scoping it.
+ """
+
+ class TestPlugin(AirflowPlugin):
+ name = "test_plugin"
+
+ external_views = [
+ {
+ "name": "Pod logs",
+ "href": "/pods",
+ "url_route": "/pods",
+ "destination": "task_instance",
+ "applies_to": {
+ "operator": ["KubernetesPodOperator"],
+ "class_ref.class_name": ["KubernetesPodOperator"],
+ },
+ }
+ ]
+
+ with (
+ mock_plugin_manager(plugins=[TestPlugin()]),
+ caplog.at_level(logging.WARNING, logger="airflow.plugins_manager"),
+ ):
+ from airflow import plugins_manager
+
+ external_views, _ = plugins_manager._get_ui_plugins()
+
+ assert [view["name"] for view in external_views] == ["Pod logs"]
+
+ assert len(caplog.record_tuples) == 1
+ assert "'class_ref.class_name' that no task_instance has" in
caplog.record_tuples[0][2]
+ assert "more places than intended" not in caplog.record_tuples[0][2]
+
+ def test_does_not_mutate_the_plugin_author_s_applies_to(self):
+ """A plugin may hand the same dict to several views; validation must
not rewrite it."""
+ shared = {"operator_name": ["@task.bash"], "task_id": None}
+
+ class TestPlugin(AirflowPlugin):
+ name = "test_plugin"
+
+ external_views = [
+ {
+ "name": "On task",
+ "href": "/a",
+ "url_route": "/a",
+ "destination": "task",
+ "applies_to": shared,
+ },
+ {
+ "name": "On task instance",
+ "href": "/b",
+ "url_route": "/b",
+ "destination": "task_instance",
+ "applies_to": shared,
+ },
+ ]
+
+ with mock_plugin_manager(plugins=[TestPlugin()]):
+ from airflow import plugins_manager
+
+ external_views, _ = plugins_manager._get_ui_plugins()
+
+ # The null path is dropped from what each view serializes...
+ for view in external_views:
+ assert view["applies_to"] == {"operator_name": ["@task.bash"]}
+
+ # ...but the author's own object still has it.
+ assert shared == {"operator_name": ["@task.bash"], "task_id": None}
+
def test_does_not_warn_about_valid_applies_to(self, caplog):
class TestPlugin(AirflowPlugin):
name = "test_plugin"
diff --git a/scripts/ci/prek/check_applies_to_roots_in_sync.py
b/scripts/ci/prek/check_applies_to_roots_in_sync.py
new file mode 100755
index 00000000000..45fbb085258
--- /dev/null
+++ b/scripts/ci/prek/check_applies_to_roots_in_sync.py
@@ -0,0 +1,117 @@
+#!/usr/bin/env python3
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""
+Verify the plugin ``applies_to`` destination tables stay in sync.
+
+Which record an unqualified ``applies_to`` path is rooted at is decided twice:
by
+``_APPLIES_TO_ENTITY_ROOT`` when plugins load, to decide what to validate and
warn about, and
+again by ``ENTITY_ROOT_BY_DESTINATION`` in the browser, to decide what to
match. The two have to
+agree, and nothing at runtime notices when they do not -- a destination added
to one side only
+makes every path on it unevaluable, which shows the view everywhere instead of
narrowing it.
+
+The tables are not textually identical: Python keys records by their wire name
(``dag_run``)
+while the UI keys them by context field (``dagRun``), so this compares them
through
+``RECORD_FIELD_BY_WIRE_NAME`` below.
+"""
+
+from __future__ import annotations
+
+import ast
+import re
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).parent.resolve()))
+
+from common_prek_utils import AIRFLOW_ROOT_PATH
+
+PYTHON_SOURCE = Path("airflow-core/src/airflow/plugins_manager.py")
+PYTHON_TABLE = "_APPLIES_TO_ENTITY_ROOT"
+
+UI_SOURCE = Path("airflow-core/src/airflow/ui/src/utils/pluginAppliesTo.ts")
+UI_TABLE = "ENTITY_ROOT_BY_DESTINATION"
+
+# The UI holds each record on a camelCase field of `AppliesToContext`; the
wire names the Python
+# side uses are the `applies_to` path prefixes a plugin author writes.
+RECORD_FIELD_BY_WIRE_NAME = {
+ "dag": "dag",
+ "dag_run": "dagRun",
+ "task": "task",
+ "task_instance": "taskInstance",
+}
+
+
+def extract_python_table(source: str) -> dict[str, str]:
+ """Read the destination -> record mapping out of the module without
importing airflow."""
+ for node in ast.parse(source).body:
+ if isinstance(node, ast.AnnAssign):
+ targets: list[ast.expr] = [node.target]
+ elif isinstance(node, ast.Assign):
+ targets = list(node.targets)
+ else:
+ continue
+ if node.value is None:
+ continue
+ if any(isinstance(target, ast.Name) and target.id == PYTHON_TABLE for
target in targets):
+ return ast.literal_eval(node.value)
+ raise SystemExit(f"Could not find `{PYTHON_TABLE}` in {PYTHON_SOURCE}")
+
+
+def extract_ui_table(source: str) -> dict[str, str]:
+ """Read the destination -> record mapping out of the TypeScript object
literal."""
+ match = re.search(
+ rf"const {UI_TABLE}: Record<string, RootName> = \{{(.*?)\n\}};",
+ source,
+ re.DOTALL,
+ )
+ if match is None:
+ raise SystemExit(f"Could not find `{UI_TABLE}` in {UI_SOURCE}")
+ return dict(re.findall(r'^\s*([A-Za-z_]+):\s*"([A-Za-z]+)",',
match.group(1), re.MULTILINE))
+
+
+def main() -> int:
+ python_table = extract_python_table((AIRFLOW_ROOT_PATH /
PYTHON_SOURCE).read_text())
+ ui_table = extract_ui_table((AIRFLOW_ROOT_PATH / UI_SOURCE).read_text())
+
+ expected = {
+ destination: RECORD_FIELD_BY_WIRE_NAME.get(record, record)
+ for destination, record in python_table.items()
+ }
+ if expected == ui_table:
+ return 0
+
+ print(f"`{PYTHON_TABLE}` in {PYTHON_SOURCE} and `{UI_TABLE}` in
{UI_SOURCE} disagree.\n")
+ for destination in sorted(set(expected) | set(ui_table)):
+ in_python, in_ui = expected.get(destination), ui_table.get(destination)
+ if in_python == in_ui:
+ continue
+ print(
+ f" {destination}: {PYTHON_TABLE} says {in_python or '(missing)'},
"
+ f"{UI_TABLE} says {in_ui or '(missing)'}"
+ )
+ print(
+ "\nA destination only one side knows about makes every unqualified
path on it "
+ "unevaluable, which shows the view everywhere rather than scoping it.
Update both "
+ "tables, and the destination table in "
+ "docs/administration-and-deployment/plugins.rst."
+ )
+ return 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/tests/ci/prek/test_check_applies_to_roots_in_sync.py
b/scripts/tests/ci/prek/test_check_applies_to_roots_in_sync.py
new file mode 100644
index 00000000000..a630b4de39c
--- /dev/null
+++ b/scripts/tests/ci/prek/test_check_applies_to_roots_in_sync.py
@@ -0,0 +1,84 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import textwrap
+
+import pytest
+from check_applies_to_roots_in_sync import extract_python_table,
extract_ui_table
+
+
+class TestExtractPythonTable:
+ def test_reads_the_mapping_without_importing_airflow(self):
+ source = textwrap.dedent(
+ '''
+ """Module docstring."""
+ _APPLIES_TO_ROOTS: dict[str, frozenset[str]] = {"dag":
frozenset({"dag"})}
+
+ _APPLIES_TO_ENTITY_ROOT: dict[str, str] = {
+ "dag": "dag",
+ "dag_run": "dag_run",
+ "task_instance": "task_instance",
+ }
+ '''
+ )
+
+ assert extract_python_table(source) == {
+ "dag": "dag",
+ "dag_run": "dag_run",
+ "task_instance": "task_instance",
+ }
+
+ def test_fails_loudly_when_the_table_is_renamed(self):
+ with pytest.raises(SystemExit, match="_APPLIES_TO_ENTITY_ROOT"):
+ extract_python_table("SOMETHING_ELSE = {}\n")
+
+
+class TestExtractUiTable:
+ def test_reads_the_object_literal(self):
+ source = textwrap.dedent(
+ """
+ type RootName = "dag" | "dagRun";
+
+ const ROOT_BY_PREFIX: Record<string, RootName> = {
+ dag: "dag",
+ };
+
+ const ENTITY_ROOT_BY_DESTINATION: Record<string, RootName> = {
+ dag: "dag",
+ dag_run: "dagRun",
+ task_instance: "taskInstance",
+ };
+ """
+ )
+
+ assert extract_ui_table(source) == {
+ "dag": "dag",
+ "dag_run": "dagRun",
+ "task_instance": "taskInstance",
+ }
+
+ def test_fails_loudly_when_the_table_is_renamed(self):
+ with pytest.raises(SystemExit, match="ENTITY_ROOT_BY_DESTINATION"):
+ extract_ui_table("const SOMETHING_ELSE: Record<string, RootName> =
{\n};\n")
+
+
+def test_the_checked_in_tables_agree():
+ """The check this hook exists for, run against the real files."""
+ from check_applies_to_roots_in_sync import main
+
+ assert main() == 0