This is an automated email from the ASF dual-hosted git repository.
o-nikolas 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 836a5565c58 Scope plugin scheduling classes to their team in
multi-team mode (#73569)
836a5565c58 is described below
commit 836a5565c58afd0a9267827b6692c955bf1ccb7e
Author: Niko Oliveira <[email protected]>
AuthorDate: Tue Sep 29 11:22:03 2026 -0700
Scope plugin scheduling classes to their team in multi-team mode (#73569)
A team-scoped plugin's timetables, partition mappers, windows, deadline
references and priority weight strategies were usable by any Dag. Dags are
now checked when their parsing results are stored: a Dag that uses another
team's class gets an import error instead of being stored.
---
.../src/airflow/dag_processing/collection.py | 104 ++++++
airflow-core/src/airflow/plugins_manager.py | 42 +++
.../tests/unit/dag_processing/test_collection.py | 347 ++++++++++++++++++++-
.../tests/unit/plugins/test_plugins_manager.py | 61 ++++
.../src/tests_common/test_utils/mock_plugins.py | 42 ++-
5 files changed, 578 insertions(+), 18 deletions(-)
diff --git a/airflow-core/src/airflow/dag_processing/collection.py
b/airflow-core/src/airflow/dag_processing/collection.py
index 70c95f8eedf..33b1b443931 100644
--- a/airflow-core/src/airflow/dag_processing/collection.py
+++ b/airflow-core/src/airflow/dag_processing/collection.py
@@ -50,11 +50,13 @@ from airflow.models.asset import (
TaskOutletAssetReference,
)
from airflow.models.dag import DagModel, DagOwnerAttributes, DagTag
+from airflow.models.dagbundle import DagBundleModel
from airflow.models.dagrun import DagRun
from airflow.models.dagwarning import DagWarning, DagWarningType
from airflow.models.errors import ParseImportError
from airflow.models.serialized_dag import SerializedDagModel
from airflow.models.trigger import Trigger
+from airflow.plugins_manager import get_scheduling_class_teams
from airflow.serialization.definitions.assets import (
SerializedAsset,
SerializedAssetAlias,
@@ -475,6 +477,101 @@ def _update_import_errors(
)
+def _iter_serialized_class_names(data: Any) -> Iterator[str]:
+ """
+ Yield every string in a serialized Dag, which includes every class name it
records.
+
+ Timetables, partition mappers and windows are encoded as ``{"__type":
<qualname>, ...}``,
+ a custom deadline reference carries ``"__class_path"``, and a task's
``weight_rule`` is
+ stored as the strategy's qualname. Yielding every string rather than
reading known keys
+ means it does not matter where the encoders put a name; ordinary strings
simply match
+ nothing in the plugin map.
+
+ This is what ``DagSerialization.to_dict`` returns, before it is dumped to
JSON, so it
+ holds tuples as well as lists (a partition mapper config is a list of
pairs), and names
+ can sit in keys as well as values. None of it is cyclic.
+ """
+ stack = [data]
+ while stack:
+ item = stack.pop()
+ if isinstance(item, str):
+ yield item
+ elif isinstance(item, dict):
+ stack.extend(item.keys())
+ stack.extend(item.values())
+ elif isinstance(item, (list, tuple, set, frozenset)):
+ stack.extend(item)
+
+
+def _reject_other_teams_plugin_classes(
+ bundle_name: str,
+ dags: Collection[LazyDeserializedDAG],
+ import_errors: dict[tuple[str, str], str],
+ *,
+ session: Session,
+) -> list[LazyDeserializedDAG]:
+ """
+ Drop Dags that name a scheduling class belonging to another team's plugin.
+
+ Timetables, partition mappers, windows, deadline references and priority
weight
+ strategies are named by the Dag itself, with no team-aware lookup in
between, so this is
+ the only thing keeping a team-scoped plugin's scheduling classes to that
team.
+
+ It runs here, on the data about to be stored, because that is exactly what
the scheduler
+ will resolve, and because on the Dag processor path this runs in the
manager, which never
+ executes Dag code. Configuration read in the process that imported the Dag
file cannot be
+ trusted: the file could have changed it.
+
+ A partition mapper a timetable picks inside ``get_partition_mapper()`` is
not covered,
+ because nothing decides it until the timetable runs.
+ """
+ if not conf.getboolean("core", "multi_team"):
+ return list(dags)
+
+ # A class registered by any global plugin is available to every Dag, so
only classes
+ # that every registering plugin scoped to a team can be refused.
+ restricted: dict[str, frozenset[str]] = {
+ name: frozenset(team for team in teams if team is not None)
+ for name, teams in get_scheduling_class_teams().items()
+ if None not in teams
+ }
+ if not restricted:
+ return list(dags)
+
+ accepted: list[LazyDeserializedDAG] = []
+ dag_team_name: str | None = None
+ team_looked_up = False
+ for dag in dags:
+ used = {
+ name: restricted[name] for name in
_iter_serialized_class_names(dag.data) if name in restricted
+ }
+ if used and not team_looked_up:
+ dag_team_name = DagBundleModel.get_team_name(bundle_name,
session=session)
+ team_looked_up = True
+ refused = {name: teams for name, teams in used.items() if
dag_team_name not in teams}
+ if not refused:
+ accepted.append(dag)
+ continue
+
+ name, owning_teams = min(refused.items())
+ owners = ", ".join(sorted(owning_teams))
+ belongs_to = f"team '{dag_team_name}'" if dag_team_name else "no team"
+ log.warning(
+ "Refusing Dag that uses another team's plugin class",
+ dag_id=dag.dag_id,
+ class_name=name,
+ owning_teams=sorted(owning_teams),
+ dag_team=dag_team_name,
+ )
+ import_errors[(bundle_name, dag.relative_fileloc)] = (
+ f"Dag '{dag.dag_id}' uses {name}, which is provided by a plugin
belonging to {owners}. "
+ f"This Dag belongs to {belongs_to}, so it cannot use it. Move the
Dag into a bundle "
+ f"owned by {owners}, or have the plugin provide the class globally
instead of for a "
+ "single team."
+ )
+ return accepted
+
+
def update_dag_parsing_results_in_db(
bundle_name: str,
bundle_version: str | None,
@@ -513,6 +610,13 @@ def update_dag_parsing_results_in_db(
If None, will be inferred from dags and import_errors. Passing this
explicitly ensures that
import errors are cleared for files that were parsed but no longer
contain DAGs.
"""
+ accepted = _reject_other_teams_plugin_classes(bundle_name, dags,
import_errors, session=session)
+ if len(accepted) != len(dags):
+ # A rejected Dag may have no ``dag`` row yet, and dag_warning has a
foreign key to it.
+ rejected_ids = {dag.dag_id for dag in dags} - {dag.dag_id for dag in
accepted}
+ warnings = {warning for warning in warnings if warning.dag_id not in
rejected_ids}
+ dags = accepted
+
# Retry 'DAG.bulk_write_to_db' & 'SerializedDagModel.bulk_sync_to_db' in
case
# of any Operational Errors
# In case of failures, provide_session handles rollback
diff --git a/airflow-core/src/airflow/plugins_manager.py
b/airflow-core/src/airflow/plugins_manager.py
index edf8db34d5b..8b2ea644e0b 100644
--- a/airflow-core/src/airflow/plugins_manager.py
+++ b/airflow-core/src/airflow/plugins_manager.py
@@ -44,6 +44,10 @@ from airflow._shared.plugins_manager import (
is_valid_plugin,
)
from airflow.configuration import conf
+from airflow.serialization.helpers import (
+ is_core_partition_mapper_import_path,
+ is_core_timetable_import_path,
+)
if TYPE_CHECKING:
from airflow.listeners.listener import ListenerManager
@@ -519,6 +523,44 @@ def is_extra_link_visible_to_team(link: Any, team_name:
str | None) -> bool:
return team_name in link_teams
+@cache
+def get_scheduling_class_teams() -> dict[str, frozenset[str | None]]:
+ """
+ Map the qualname of every plugin-registered scheduling class to the teams
that registered it.
+
+ Covers timetables, partition mappers, windows, deadline references and
priority weight
+ strategies: the registries a Dag names directly, with no team-aware lookup
in between.
+
+ Keyed by qualname because that is what a serialized Dag records and what
the scheduler
+ resolves through ``get_timetables_plugins()`` and its siblings. Class
identity is not
+ stable enough to key on: the plugin loader executes a plugin file again
under its own
+ module entry, so a Dag importing a class from that file can hold a
different class
+ object with the same qualname as the one that was registered.
+
+ A qualname registered by several plugins maps to all of their teams, and
is then
+ resolved least restrictively.
+
+ Airflow's own timetables, partition mappers and windows are left out even
if a plugin
+ lists them: the decoder imports anything under those core paths directly
and never
+ consults plugins, so a plugin cannot own them.
+ """
+ teams: dict[str, set[str | None]] = {}
+ for plugin in _get_plugins()[0]:
+ for scheduling_class in (
+ *plugin.timetables,
+ *plugin.partition_mappers,
+ *plugin.windows,
+ *plugin.deadline_references,
+ *plugin.priority_weight_strategies,
+ ):
+ name = qualname(scheduling_class)
+ # The partition mapper prefix also covers core windows.
+ if is_core_timetable_import_path(name) or
is_core_partition_mapper_import_path(name):
+ continue
+ teams.setdefault(name, set()).add(plugin.team_name)
+ return {name: frozenset(team_names) for name, team_names in teams.items()}
+
+
@cache
def get_timetables_plugins() -> dict[str, type[Timetable]]:
"""Collect and get timetable classes registered by plugins."""
diff --git a/airflow-core/tests/unit/dag_processing/test_collection.py
b/airflow-core/tests/unit/dag_processing/test_collection.py
index be36290abce..cc331423e3f 100644
--- a/airflow-core/tests/unit/dag_processing/test_collection.py
+++ b/airflow-core/tests/unit/dag_processing/test_collection.py
@@ -18,7 +18,11 @@
from __future__ import annotations
+import importlib
import logging
+import os
+import sys
+import textwrap
import warnings
from collections.abc import Generator
from datetime import timedelta
@@ -31,6 +35,8 @@ from sqlalchemy import delete, event, func, inspect as
sa_inspect, select
from sqlalchemy.exc import OperationalError, SAWarning
import airflow.dag_processing.collection
+from airflow import plugins_manager
+from airflow._shared.module_loading import qualname
from airflow._shared.timezones import timezone as tz
from airflow.configuration import conf
from airflow.dag_processing.collection import (
@@ -42,6 +48,9 @@ from airflow.dag_processing.collection import (
_update_import_errors,
update_dag_parsing_results_in_db,
)
+from airflow.example_dags.plugins.business_day_window import BusinessDayWindow
+from airflow.example_dags.plugins.custom_partition_mapper import
PrefixStripMapper
+from airflow.example_dags.plugins.workday import AfterWorkdayTimetable
from airflow.exceptions import SerializationError
from airflow.models import DagModel, DagRun
from airflow.models.asset import (
@@ -57,27 +66,36 @@ from airflow.models.errors import ParseImportError
from airflow.models.serialized_dag import SerializedDagModel
from airflow.models.trigger import Trigger
from airflow.partition_mappers.base import RollupMapper
-from airflow.partition_mappers.temporal import StartOfDayMapper
+from airflow.partition_mappers.chain import ChainMapper
+from airflow.partition_mappers.identity import IdentityMapper
+from airflow.partition_mappers.temporal import StartOfDayMapper,
StartOfMonthMapper
from airflow.partition_mappers.window import DayWindow
+from airflow.plugins_manager import AirflowPlugin
from airflow.providers.standard.operators.empty import EmptyOperator
from airflow.providers.standard.triggers.file import FileDeleteTrigger
from airflow.sdk import DAG, Asset, AssetAlias, AssetAll, AssetWatcher
-from airflow.sdk.definitions.timetables.assets import PartitionedAssetTimetable
+from airflow.sdk.definitions.deadline import AsyncCallback,
BaseDeadlineReference, DeadlineAlert
+from airflow.sdk.definitions.timetables.assets import AssetOrTimeSchedule,
PartitionedAssetTimetable
from airflow.serialization.definitions.assets import SerializedAsset
from airflow.serialization.encoders import encode_trigger,
ensure_serialized_asset
from airflow.serialization.serialized_objects import LazyDeserializedDAG
from airflow.timetables.simple import PartitionedAtRuntime
+from airflow.timetables.trigger import CronTriggerTimetable
from airflow.triggers.base import BaseEventTrigger
from airflow.utils.types import DagRunType
from tests_common.test_utils.config import conf_vars
from tests_common.test_utils.db import (
clear_db_assets,
+ clear_db_dag_bundles,
clear_db_dags,
clear_db_import_errors,
clear_db_serialized_dags,
+ clear_db_teams,
clear_db_triggers,
)
+from tests_common.test_utils.mock_plugins import mock_plugin_manager
+from unit.plugins.priority_weight_strategy import
StaticTestPriorityWeightStrategy
if TYPE_CHECKING:
from kgb import SpyAgency
@@ -1680,3 +1698,328 @@ class TestPartitionMapperInfoSync:
dag_model = session.get(DagModel, "non_partitioned_dag")
assert dag_model.partition_mapper_info == []
assert dag_model.has_rollup_mappers is False
+
+
+class TeamDeadlineReference(BaseDeadlineReference):
+ """A deadline reference a team-scoped plugin ships; Airflow has no example
one to reuse."""
+
+ def _evaluate_with(self, *, session, **kwargs):
+ raise NotImplementedError
+
+
+async def _deadline_callback():
+ raise NotImplementedError
+
+
+def _nested_chain_mapper(depth):
+ mapper = PrefixStripMapper("eu")
+ for _ in range(depth):
+ mapper = ChainMapper(mapper, IdentityMapper())
+ return mapper
+
+
+def _dag_kwargs_using(case):
+ """Return the Dag keyword arguments that make it use the plugin class, as
a Dag author would."""
+ if case == "timetable":
+ return {"schedule": AfterWorkdayTimetable()}
+ if case == "timetable-in-asset-or-time":
+ return {"schedule":
AssetOrTimeSchedule(timetable=AfterWorkdayTimetable(), assets=[Asset("a")])}
+ if case == "default-partition-mapper":
+ return {
+ "schedule": PartitionedAssetTimetable(
+ assets=Asset("a"),
default_partition_mapper=PrefixStripMapper("eu")
+ )
+ }
+ if case == "partition-mapper-deep-in-chain":
+ return {
+ "schedule": PartitionedAssetTimetable(
+ assets=Asset("a"), partition_mapper_config={Asset("a"):
_nested_chain_mapper(6)}
+ )
+ }
+ if case == "window-in-rollup-mapper":
+ return {
+ "schedule": PartitionedAssetTimetable(
+ assets=Asset("a"),
+ partition_mapper_config={
+ Asset("a"): RollupMapper(window=BusinessDayWindow(),
upstream_mapper=StartOfMonthMapper())
+ },
+ )
+ }
+ if case == "deadline-reference":
+ return {
+ "deadline": DeadlineAlert(
+ reference=TeamDeadlineReference(),
+ interval=timedelta(hours=1),
+ callback=AsyncCallback(_deadline_callback),
+ )
+ }
+ raise ValueError(case)
+
+
+# Each case: the registry the plugin fills, the class it registers, and how
the Dag uses it.
+SCHEDULING_CLASS_USES = [
+ pytest.param("timetables", AfterWorkdayTimetable, "timetable",
id="timetable"),
+ pytest.param(
+ "timetables", AfterWorkdayTimetable, "timetable-in-asset-or-time",
id="timetable-in-asset-or-time"
+ ),
+ pytest.param(
+ "partition_mappers", PrefixStripMapper, "default-partition-mapper",
id="default-partition-mapper"
+ ),
+ pytest.param(
+ "partition_mappers",
+ PrefixStripMapper,
+ "partition-mapper-deep-in-chain",
+ id="partition-mapper-deep-in-chain",
+ ),
+ pytest.param("windows", BusinessDayWindow, "window-in-rollup-mapper",
id="window-in-rollup-mapper"),
+ pytest.param("deadline_references", TeamDeadlineReference,
"deadline-reference", id="deadline-reference"),
+]
+
+
[email protected]_test
+class TestRejectOtherTeamsPluginClasses:
+ """A team-scoped plugin's scheduling classes may only be stored for that
team's Dags."""
+
+ @pytest.fixture(autouse=True)
+ def _clean(self):
+ yield
+ clear_db_serialized_dags()
+ clear_db_dags()
+ clear_db_import_errors()
+ clear_db_dag_bundles()
+ clear_db_teams()
+
+ @pytest.fixture
+ def bundle(self, testing_team, session):
+ """Return a factory creating the "team_bundle" bundle, owned by the
given team or none."""
+
+ def create(owned_by_team: bool) -> str:
+ bundle = DagBundleModel(name="team_bundle")
+ if owned_by_team:
+ bundle.teams.append(testing_team)
+ session.add(bundle)
+ session.flush()
+ return bundle.name
+
+ return create
+
+ @staticmethod
+ def _plugin(team_name, registry, scheduling_class,
name="scheduling_plugin"):
+ plugin = AirflowPlugin()
+ plugin.name = name
+ plugin.team_name = team_name
+ setattr(plugin, registry, [scheduling_class])
+ return plugin
+
+ @staticmethod
+ def _serialized(dag_id="team_dag", **dag_kwargs):
+ with DAG(dag_id, **{"schedule": None, **dag_kwargs}) as dag:
+ EmptyOperator(task_id="t")
+ dag.relative_fileloc = f"{dag_id}.py"
+ return LazyDeserializedDAG.from_dag(dag)
+
+ @staticmethod
+ def _store(bundle_name, dags, session, warnings=frozenset()):
+ import_errors: dict[tuple[str, str], str] = {}
+ update_dag_parsing_results_in_db(
+ bundle_name=bundle_name,
+ bundle_version=None,
+ dags=dags,
+ import_errors=import_errors,
+ parse_duration=None,
+ warnings=set(warnings),
+ session=session,
+ )
+ stored = set(session.scalars(select(SerializedDagModel.dag_id)))
+ errors = {
+ (e.bundle_name, e.filename): e.stacktrace for e in
session.scalars(select(ParseImportError))
+ }
+ return stored, errors
+
+ @conf_vars({("core", "multi_team"): "True"})
+ @pytest.mark.parametrize(("registry", "scheduling_class", "usage"),
SCHEDULING_CLASS_USES)
+ @pytest.mark.parametrize(
+ ("plugin_team", "dag_owned_by_team", "allowed"),
+ [
+ pytest.param("testing", True, True, id="owning-team"),
+ pytest.param("other_team", True, False, id="other-team"),
+ pytest.param("testing", False, False, id="teamless"),
+ ],
+ )
+ def test_team_class_is_only_stored_for_its_team(
+ self, bundle, session, plugin_team, dag_owned_by_team, allowed,
registry, scheduling_class, usage
+ ):
+ bundle_name = bundle(dag_owned_by_team)
+ with mock_plugin_manager(plugins=[self._plugin(plugin_team, registry,
scheduling_class)]):
+ dag = self._serialized(**_dag_kwargs_using(usage))
+ stored, errors = self._store(bundle_name, [dag], session)
+
+ if allowed:
+ assert stored == {"team_dag"}
+ assert errors == {}
+ else:
+ assert stored == set()
+ assert list(errors) == [(bundle_name, "team_dag.py")]
+ assert f"belonging to {plugin_team}" in errors[(bundle_name,
"team_dag.py")]
+
+ @conf_vars({("core", "multi_team"): "True"})
+ @pytest.mark.parametrize(
+ "weight_rule",
+ [StaticTestPriorityWeightStrategy(),
qualname(StaticTestPriorityWeightStrategy)],
+ ids=["instance", "dotted-path"],
+ )
+ def test_weight_rule_is_checked_in_both_spellings(self, bundle, session,
weight_rule):
+ bundle_name = bundle(True)
+ plugin = self._plugin("other_team", "priority_weight_strategies",
StaticTestPriorityWeightStrategy)
+ with mock_plugin_manager(plugins=[plugin]):
+ with DAG("team_dag", schedule=None) as dag:
+ EmptyOperator(task_id="t", weight_rule=weight_rule)
+ dag.relative_fileloc = "team_dag.py"
+ stored, errors = self._store(bundle_name,
[LazyDeserializedDAG.from_dag(dag)], session)
+
+ assert stored == set()
+ assert "belonging to other_team" in errors[(bundle_name,
"team_dag.py")]
+
+ @conf_vars({("core", "multi_team"): "True"})
+ def test_only_the_offending_dag_is_dropped(self, bundle, session):
+ bundle_name = bundle(True)
+ with mock_plugin_manager(plugins=[self._plugin("other_team",
"timetables", AfterWorkdayTimetable)]):
+ dags = [
+ self._serialized("rejected", schedule=AfterWorkdayTimetable()),
+ self._serialized("accepted"),
+ ]
+ stored, errors = self._store(bundle_name, dags, session)
+
+ assert stored == {"accepted"}
+ assert list(errors) == [(bundle_name, "rejected.py")]
+
+ @conf_vars({("core", "multi_team"): "True"})
+ def test_warning_for_a_rejected_new_dag_is_dropped(self, bundle, session):
+ """
+ The stability check warns about every Dag in a file, including one
being rejected.
+
+ A new Dag has no ``dag`` row to hang that warning on, so storing it
would break the
+ foreign key and fail the whole write.
+ """
+ bundle_name = bundle(True)
+ warnings = {
+ DagWarning("rejected", DagWarningType.RUNTIME_VARYING_VALUE.value,
"datetime.now() in args"),
+ DagWarning("accepted", DagWarningType.RUNTIME_VARYING_VALUE.value,
"datetime.now() in args"),
+ }
+ with mock_plugin_manager(plugins=[self._plugin("other_team",
"timetables", AfterWorkdayTimetable)]):
+ dags = [
+ self._serialized("rejected", schedule=AfterWorkdayTimetable()),
+ self._serialized("accepted"),
+ ]
+ stored, errors = self._store(bundle_name, dags, session,
warnings=warnings)
+
+ assert stored == {"accepted"}
+ assert list(errors) == [(bundle_name, "rejected.py")]
+ assert set(session.scalars(select(DagWarning.dag_id))) == {"accepted"}
+
+ @conf_vars({("core", "multi_team"): "True"})
+ def test_error_is_a_plain_message(self, bundle, session):
+ """The UI shows this as-is, so it must read as an explanation, not a
traceback."""
+ bundle_name = bundle(True)
+ with mock_plugin_manager(plugins=[self._plugin("other_team",
"timetables", AfterWorkdayTimetable)]):
+ stored, errors = self._store(
+ bundle_name,
[self._serialized(schedule=AfterWorkdayTimetable())], session
+ )
+
+ assert errors[(bundle_name, "team_dag.py")] == (
+ f"Dag 'team_dag' uses {qualname(AfterWorkdayTimetable)}, which is
provided by a plugin "
+ "belonging to other_team. This Dag belongs to team 'testing', so
it cannot use it. "
+ "Move the Dag into a bundle owned by other_team, or have the
plugin provide the class "
+ "globally instead of for a single team."
+ )
+
+ @conf_vars({("core", "multi_team"): "True"})
+ def test_class_also_registered_globally_is_available_to_every_dag(self,
bundle, session):
+ bundle_name = bundle(True)
+ plugins = [
+ self._plugin(team, "timetables", AfterWorkdayTimetable,
name=f"plugin_{i}")
+ for i, team in enumerate(["other_team", None])
+ ]
+ with mock_plugin_manager(plugins=plugins):
+ stored, errors = self._store(
+ bundle_name,
[self._serialized(schedule=AfterWorkdayTimetable())], session
+ )
+
+ assert stored == {"team_dag"}
+ assert errors == {}
+
+ @conf_vars({("core", "multi_team"): "True"})
+ def test_airflow_class_listed_by_a_team_plugin_stays_available(self,
bundle, session):
+ """
+ Every team's cron Dags keep working even if one team's plugin lists
the cron timetable.
+
+ The timetable is explicit: a cron string can serialize to a different
class, depending on
+ ``create_cron_data_intervals``, which would leave the plugin's class
out of the Dag.
+ """
+ bundle_name = bundle(True)
+ with mock_plugin_manager(plugins=[self._plugin("other_team",
"timetables", CronTriggerTimetable)]):
+ stored, errors = self._store(
+ bundle_name,
+ [self._serialized(schedule=CronTriggerTimetable("0 0 * * *",
timezone="UTC"))],
+ session,
+ )
+
+ assert stored == {"team_dag"}
+ assert errors == {}
+
+ def test_nothing_is_rejected_when_multi_team_is_off(self, bundle, session):
+ bundle_name = bundle(True)
+ with mock_plugin_manager(plugins=[self._plugin("other_team",
"timetables", AfterWorkdayTimetable)]):
+ stored, errors = self._store(
+ bundle_name,
[self._serialized(schedule=AfterWorkdayTimetable())], session
+ )
+
+ assert stored == {"team_dag"}
+ assert errors == {}
+
+ @conf_vars({("core", "multi_team"): "True"})
+ def test_class_reloaded_by_the_plugin_loader_is_still_recognised(
+ self, bundle, session, tmp_path, monkeypatch, request
+ ):
+ """
+ A Dag importing from a plugin file holds a different class from the
one registered.
+
+ The plugin loader executes the file again under its own module entry,
so the two
+ classes share a qualname but not an identity.
+ """
+ (tmp_path / "workday.py").write_text(
+ textwrap.dedent(
+ """\
+ from airflow.plugins_manager import AirflowPlugin
+ from airflow.timetables.simple import NullTimetable
+
+
+ class WorkdayTimetable(NullTimetable):
+ pass
+
+
+ class WorkdayPlugin(AirflowPlugin):
+ name = "workday"
+ team_name = "other_team"
+ timetables = [WorkdayTimetable]
+ """
+ )
+ )
+ monkeypatch.syspath_prepend(os.fspath(tmp_path))
+ # Both the import below and the plugin loader put a "workday" module
in sys.modules, and
+ # monkeypatch would restore the loader's at teardown, so remove it
outright instead.
+ sys.modules.pop("workday", None)
+ request.addfinalizer(lambda: sys.modules.pop("workday", None))
+ dag_side_class = importlib.import_module("workday").WorkdayTimetable
+ plugins, import_errors =
plugins_manager._load_plugins_from_plugin_directory(
+ plugins_folder=os.fspath(tmp_path)
+ )
+ assert not import_errors
+ assert plugins[0].timetables[0] is not dag_side_class
+
+ bundle_name = bundle(True)
+ with mock_plugin_manager(plugins=plugins):
+ stored, errors = self._store(bundle_name,
[self._serialized(schedule=dag_side_class())], session)
+
+ assert stored == set()
+ assert "belonging to other_team" in errors[(bundle_name,
"team_dag.py")]
diff --git a/airflow-core/tests/unit/plugins/test_plugins_manager.py
b/airflow-core/tests/unit/plugins/test_plugins_manager.py
index bf6d4f00f4c..b1a883e9282 100644
--- a/airflow-core/tests/unit/plugins/test_plugins_manager.py
+++ b/airflow-core/tests/unit/plugins/test_plugins_manager.py
@@ -645,6 +645,67 @@ class TestPluginTeamName:
assert info_by_name["team_plugin"]["team_name"] == "team_a"
+class TestGetSchedulingClassTeams:
+ @staticmethod
+ def _plugin(team_name, **registries):
+ plugin = AirflowPlugin()
+ plugin.name = f"plugin_{team_name}"
+ plugin.team_name = team_name
+ for registry, classes in registries.items():
+ setattr(plugin, registry, classes)
+ return plugin
+
+ def test_maps_each_registry_by_qualname(self):
+ from airflow.example_dags.plugins.business_day_window import
BusinessDayWindow
+ from airflow.example_dags.plugins.custom_partition_mapper import
PrefixStripMapper
+ from airflow.example_dags.plugins.decreasing_priority_weight_strategy
import (
+ DecreasingPriorityStrategy,
+ )
+ from airflow.example_dags.plugins.workday import AfterWorkdayTimetable
+
+ plugin = self._plugin(
+ "team_a",
+ timetables=[AfterWorkdayTimetable],
+ partition_mappers=[PrefixStripMapper],
+ windows=[BusinessDayWindow],
+ priority_weight_strategies=[DecreasingPriorityStrategy],
+ )
+ with mock_plugin_manager(plugins=[plugin]):
+ assert plugins_manager.get_scheduling_class_teams() == {
+ qualname(cls): frozenset({"team_a"})
+ for cls in (
+ AfterWorkdayTimetable,
+ PrefixStripMapper,
+ BusinessDayWindow,
+ DecreasingPriorityStrategy,
+ )
+ }
+
+ def test_class_registered_by_several_plugins_maps_to_all_their_teams(self):
+ from airflow.example_dags.plugins.workday import AfterWorkdayTimetable
+
+ plugins = [self._plugin(team, timetables=[AfterWorkdayTimetable]) for
team in ("team_a", None)]
+ with mock_plugin_manager(plugins=plugins):
+ assert plugins_manager.get_scheduling_class_teams() == {
+ qualname(AfterWorkdayTimetable): frozenset({"team_a", None})
+ }
+
+ def test_airflow_classes_are_left_out(self):
+ """The decoder imports these directly, so no plugin can own them."""
+ from airflow.partition_mappers.temporal import StartOfDayMapper
+ from airflow.partition_mappers.window import DayWindow
+ from airflow.timetables.trigger import CronTriggerTimetable
+
+ plugin = self._plugin(
+ "team_a",
+ timetables=[CronTriggerTimetable],
+ partition_mappers=[StartOfDayMapper],
+ windows=[DayWindow],
+ )
+ with mock_plugin_manager(plugins=[plugin]):
+ assert plugins_manager.get_scheduling_class_teams() == {}
+
+
class TestValidatePluginTeams:
"""``validate_plugin_teams`` startup validation."""
diff --git a/devel-common/src/tests_common/test_utils/mock_plugins.py
b/devel-common/src/tests_common/test_utils/mock_plugins.py
index cc100747dd0..b33831bfbce 100644
--- a/devel-common/src/tests_common/test_utils/mock_plugins.py
+++ b/devel-common/src/tests_common/test_utils/mock_plugins.py
@@ -19,7 +19,7 @@ from __future__ import annotations
from contextlib import ExitStack, contextmanager
from unittest import mock
-from tests_common.test_utils.version_compat import AIRFLOW_V_3_0_PLUS,
AIRFLOW_V_3_2_PLUS
+from tests_common.test_utils.version_compat import AIRFLOW_V_3_0_PLUS,
AIRFLOW_V_3_2_PLUS, AIRFLOW_V_3_3_PLUS
PLUGINS_MANAGER_NULLABLE_ATTRIBUTES_V3_0 = [
"plugins",
@@ -83,24 +83,34 @@ def mock_plugin_manager(plugins=None, **kwargs):
# Handle plugins specially
with ExitStack() as exit_stack:
if AIRFLOW_V_3_2_PLUS:
- # Always start the block with an non-initialized plugins, so
ensure_plugins_loaded runs.
from airflow import plugins_manager
from airflow.sdk import plugins_manager as sdk_plugins_manager
- plugins_manager._get_plugins.cache_clear()
- plugins_manager._get_ui_plugins.cache_clear()
- plugins_manager.get_ui_translations.cache_clear()
- plugins_manager.get_flask_plugins.cache_clear()
- plugins_manager.get_fastapi_plugins.cache_clear()
- plugins_manager._get_extra_operators_links_plugins.cache_clear()
- plugins_manager._get_extra_link_class_teams.cache_clear()
- plugins_manager.get_timetables_plugins.cache_clear()
- plugins_manager.integrate_macros_plugins.cache_clear()
- plugins_manager.get_priority_weight_strategy_plugins.cache_clear()
-
- sdk_plugins_manager.integrate_macros_plugins.cache_clear()
- sdk_plugins_manager.get_macro_plugin_teams.cache_clear()
- sdk_plugins_manager.get_hook_lineage_readers_plugins.cache_clear()
+ def clear_plugin_caches():
+ plugins_manager._get_plugins.cache_clear()
+ plugins_manager._get_ui_plugins.cache_clear()
+ plugins_manager.get_ui_translations.cache_clear()
+ plugins_manager.get_flask_plugins.cache_clear()
+ plugins_manager.get_fastapi_plugins.cache_clear()
+
plugins_manager._get_extra_operators_links_plugins.cache_clear()
+ plugins_manager._get_extra_link_class_teams.cache_clear()
+ plugins_manager.get_scheduling_class_teams.cache_clear()
+ plugins_manager.get_timetables_plugins.cache_clear()
+ plugins_manager.get_partition_mapper_plugins.cache_clear()
+ if AIRFLOW_V_3_3_PLUS:
+ plugins_manager.get_windows_plugins.cache_clear()
+
plugins_manager.get_deadline_references_plugins.cache_clear()
+ plugins_manager.integrate_macros_plugins.cache_clear()
+
plugins_manager.get_priority_weight_strategy_plugins.cache_clear()
+
+ sdk_plugins_manager.integrate_macros_plugins.cache_clear()
+ sdk_plugins_manager.get_macro_plugin_teams.cache_clear()
+
sdk_plugins_manager.get_hook_lineage_readers_plugins.cache_clear()
+
+ # Start the block with no plugins loaded, so ensure_plugins_loaded
runs, and clear again
+ # on the way out so nothing computed from the mocked plugins
outlives the block.
+ clear_plugin_caches()
+ exit_stack.callback(clear_plugin_caches)
if plugins is not None or "import_errors" in kwargs:
exit_stack.enter_context(