| # 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. | |
| """Unit tests for dev/registry/extract_parameters.py.""" | |
| from __future__ import annotations | |
| import abc | |
| import builtins | |
| import json | |
| import types | |
| from dataclasses import fields | |
| from unittest.mock import patch | |
| import pytest | |
| import yaml | |
| from extract_parameters import ( | |
| Module, | |
| _get_source_line, | |
| _parse_requested_providers, | |
| _resolve_dotted_path, | |
| _should_skip_class, | |
| compare_with_ast, | |
| discover_classes_from_provider, | |
| get_category, | |
| is_durable_capable, | |
| load_resumable_job_mixin, | |
| supports_deferrable, | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # get_category | |
| # --------------------------------------------------------------------------- | |
| class TestGetCategory: | |
| def test_simple_name(self): | |
| assert get_category("Amazon S3") == "amazon-s3" | |
| def test_parentheses_stripped(self): | |
| assert get_category("Google Cloud (GCS)") == "google-cloud-gcs" | |
| def test_special_chars_removed(self): | |
| assert get_category("Apache Kafkaâ„¢") == "apache-kafka" | |
| def test_empty_string(self): | |
| assert get_category("") == "" | |
| # --------------------------------------------------------------------------- | |
| # _should_skip_class | |
| # --------------------------------------------------------------------------- | |
| class TestClassFiltering: | |
| def test_skips_private_classes(self): | |
| assert _should_skip_class("_InternalHelper") is True | |
| def test_skips_dunder_prefix(self): | |
| assert _should_skip_class("__Meta") is True | |
| def test_skips_base_prefix(self): | |
| assert _should_skip_class("BaseOperator") is True | |
| assert _should_skip_class("BaseSQLOperator") is True | |
| def test_skips_abstract_in_name(self): | |
| assert _should_skip_class("AbstractHook") is True | |
| assert _should_skip_class("MyAbstractSensor") is True | |
| def test_skips_mixin_in_name(self): | |
| assert _should_skip_class("AwsMixin") is True | |
| assert _should_skip_class("MixinHelper") is True | |
| def test_allows_normal_class(self): | |
| assert _should_skip_class("S3CopyObjectOperator") is False | |
| assert _should_skip_class("RedshiftHook") is False | |
| def test_allows_class_containing_base_not_at_start(self): | |
| assert _should_skip_class("FirebaseHook") is False | |
| def test_allows_class_with_database_in_name(self): | |
| # "Database" contains "base" but not at position 0 | |
| assert _should_skip_class("DatabaseOperator") is False | |
| # --------------------------------------------------------------------------- | |
| # _get_source_line | |
| # --------------------------------------------------------------------------- | |
| class TestGetSourceLine: | |
| def test_returns_line_for_real_class(self): | |
| # _should_skip_class is a function in the same file, but any real class works | |
| line = _get_source_line(TestGetSourceLine) | |
| assert line is not None | |
| assert isinstance(line, int) | |
| assert line > 0 | |
| def test_returns_none_for_dynamic_class(self): | |
| DynamicClass = type("DynamicClass", (), {}) | |
| assert _get_source_line(DynamicClass) is None | |
| # --------------------------------------------------------------------------- | |
| # load_resumable_job_mixin | |
| # --------------------------------------------------------------------------- | |
| class TestLoadResumableJobMixin: | |
| def test_returns_none_when_airflow_sdk_unavailable(self): | |
| real_import = builtins.__import__ | |
| def fake_import(name, *args, **kwargs): | |
| if name == "airflow.sdk": | |
| raise ImportError("no airflow.sdk here") | |
| return real_import(name, *args, **kwargs) | |
| with patch("builtins.__import__", side_effect=fake_import): | |
| assert load_resumable_job_mixin() is None | |
| # --------------------------------------------------------------------------- | |
| # is_durable_capable | |
| # --------------------------------------------------------------------------- | |
| class FakeResumableJobMixin(abc.ABC): | |
| """Stand-in for airflow.sdk.ResumableJobMixin's abstract-method contract.""" | |
| @abc.abstractmethod | |
| def submit_job(self, context): | |
| raise NotImplementedError | |
| @abc.abstractmethod | |
| def get_job_status(self, external_id, context): | |
| raise NotImplementedError | |
| @abc.abstractmethod | |
| def is_job_active(self, status): | |
| raise NotImplementedError | |
| @abc.abstractmethod | |
| def is_job_succeeded(self, status): | |
| raise NotImplementedError | |
| @abc.abstractmethod | |
| def poll_until_complete(self, external_id, context): | |
| raise NotImplementedError | |
| @abc.abstractmethod | |
| def get_job_result(self, external_id, context): | |
| raise NotImplementedError | |
| def execute_resumable(self, context): | |
| raise NotImplementedError | |
| class FullyImplementedResumableOperator(FakeResumableJobMixin): | |
| def execute(self, context): | |
| return self.execute_resumable(context) | |
| def submit_job(self, context): | |
| return "job-1" | |
| def get_job_status(self, external_id, context): | |
| return "RUNNING" | |
| def is_job_active(self, status): | |
| return status == "RUNNING" | |
| def is_job_succeeded(self, status): | |
| return status == "SUCCEEDED" | |
| def poll_until_complete(self, external_id, context): | |
| return None | |
| def get_job_result(self, external_id, context): | |
| return None | |
| class PartiallyImplementedResumableOperator(FakeResumableJobMixin): | |
| """Retry-path methods left unoverridden -- would only blow up on an actual crash-recovery retry.""" | |
| def execute(self, context): | |
| return self.execute_resumable(context) | |
| def submit_job(self, context): | |
| return "job-1" | |
| def poll_until_complete(self, external_id, context): | |
| return None | |
| def get_job_result(self, external_id, context): | |
| return None | |
| class UnwiredResumableOperator(FakeResumableJobMixin): | |
| """execute() never calls execute_resumable -- dead capability, never exercised.""" | |
| def execute(self, context): | |
| return self.submit_job(context) | |
| def submit_job(self, context): | |
| return "job-1" | |
| def get_job_status(self, external_id, context): | |
| return "RUNNING" | |
| def is_job_active(self, status): | |
| return status == "RUNNING" | |
| def is_job_succeeded(self, status): | |
| return status == "SUCCEEDED" | |
| def poll_until_complete(self, external_id, context): | |
| return None | |
| def get_job_result(self, external_id, context): | |
| return None | |
| class PlainOperator: | |
| def execute(self, context): | |
| return None | |
| class ManuallyDurableOperator: | |
| """Implements durable execution directly (e.g. via task_state_store), without | |
| ResumableJobMixin -- mirrors KubernetesPodOperator/AgentOperator.""" | |
| __supports_durable_execution = True | |
| def execute(self, context): | |
| return None | |
| class ManuallyDurableSubclass(ManuallyDurableOperator): | |
| """Overrides execute() itself -- must NOT inherit the parent's declaration, | |
| since nothing here verifies it preserves the reconnect behavior.""" | |
| def execute(self, context): | |
| return "something else entirely" | |
| class TrailingNoExecuteMixin: | |
| """Sits last in a combined MRO and owns no `execute`, so resolving a hop to it finds no | |
| owner from that point on.""" | |
| class ManuallyDurableSubclassNoOverride(ManuallyDurableOperator): | |
| """Adds no execute() override at all. Mirrors GKEStartPodOperator, which relies | |
| entirely on KubernetesPodOperator.execute(). Must still qualify.""" | |
| def some_other_method(self): | |
| return None | |
| class ManuallyDurableSubclassDelegating(ManuallyDurableOperator): | |
| """Overrides execute() but delegates back via super().execute(). Mirrors | |
| EksPodOperator/SparkKubernetesOperator's non-deferrable path. Must still qualify.""" | |
| def execute(self, context): | |
| self.log_something() | |
| return super().execute(context) | |
| def log_something(self): | |
| return None | |
| class ManuallyDurableSubclassDelegatingMultiHop(ManuallyDurableSubclassDelegating): | |
| """A second layer of super().execute() delegation on top of ManuallyDurableSubclassDelegating. | |
| Must still qualify.""" | |
| def execute(self, context): | |
| return super().execute(context) | |
| class DecoratorMixin: | |
| """Mirrors DecoratedOperator: a mixin whose own MRO has no relationship to the marker class.""" | |
| def execute(self, context): | |
| return super().execute(context) | |
| class DecoratedDurableSubclass(DecoratorMixin, ManuallyDurableOperator): | |
| """Combines DecoratorMixin with the marker class via multiple inheritance (e.g. @task.kubernetes).""" | |
| def execute(self, context): | |
| return super().execute(context) | |
| class DecoratedDurableSubclassNoOwnExecute(DecoratorMixin, ManuallyDurableOperator): | |
| """Same combined MRO as above, but inherits DecoratorMixin's execute() unchanged.""" | |
| class ExplicitParentDelegatingSubclass(ManuallyDurableOperator): | |
| """Mirrors @task.agent's _AgentDecoratedOperator: names the parent class directly instead | |
| of using super(). Must still qualify.""" | |
| def execute(self, context): | |
| return ManuallyDurableOperator.execute(self, context) | |
| class TestIsDurableCapable: | |
| def test_fully_implemented_and_wired_qualifies(self): | |
| assert is_durable_capable(FullyImplementedResumableOperator, FakeResumableJobMixin) is True | |
| def test_missing_retry_path_overrides_disqualifies(self): | |
| assert is_durable_capable(PartiallyImplementedResumableOperator, FakeResumableJobMixin) is False | |
| def test_implemented_but_not_called_from_execute_disqualifies(self): | |
| assert is_durable_capable(UnwiredResumableOperator, FakeResumableJobMixin) is False | |
| def test_no_mixin_in_mro_disqualifies(self): | |
| assert is_durable_capable(PlainOperator, FakeResumableJobMixin) is False | |
| def test_mixin_unavailable_disqualifies(self): | |
| assert is_durable_capable(FullyImplementedResumableOperator, None) is False | |
| def test_manual_durable_marker_qualifies_without_mixin(self): | |
| assert is_durable_capable(ManuallyDurableOperator, None) is True | |
| 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_non_class_execute_call_does_not_shadow_real_delegation(self): | |
| """A local name that is not a class in the MRO (e.g. a DB cursor) is skipped rather | |
| than dead-ending the walk before the real delegation below it.""" | |
| class DecoyExecuteThenRealDelegation(ManuallyDurableOperator): | |
| def execute(self, context): | |
| cursor = object() | |
| cursor.execute("select 1") | |
| return ManuallyDurableOperator.execute(self, context) | |
| assert is_durable_capable(DecoyExecuteThenRealDelegation, FakeResumableJobMixin) is True | |
| def test_every_explicit_match_is_tried_not_just_the_first_resolvable_one(self): | |
| """The decoy here does resolve to a class in the MRO, but one that owns no `execute` | |
| and has nothing after it that does, so the hop yields no owner. The walk has to keep | |
| going to the real delegation rather than stopping at the first resolved match.""" | |
| class DecoyResolvesButOwnsNoExecute(ManuallyDurableOperator, TrailingNoExecuteMixin): | |
| def execute(self, context): | |
| TrailingNoExecuteMixin.execute(self, context) | |
| return ManuallyDurableOperator.execute(self, context) | |
| assert is_durable_capable(DecoyResolvesButOwnsNoExecute, FakeResumableJobMixin) is True | |
| def test_explicit_call_to_class_with_no_own_execute_resolves_to_actual_owner(self): | |
| """Mirrors `GKEStartPodOperator.execute(self, context)`: the named class inherits | |
| `execute` rather than defining it, so the hop must resolve up to whoever actually | |
| owns it, the same as a super() hop already does.""" | |
| class NoOwnExecuteIntermediate(ManuallyDurableOperator): | |
| pass | |
| class ExplicitDelegatesToIntermediate(NoOwnExecuteIntermediate): | |
| def execute(self, context): | |
| return NoOwnExecuteIntermediate.execute(self, context) | |
| assert is_durable_capable(ExplicitDelegatesToIntermediate, FakeResumableJobMixin) is True | |
| # --------------------------------------------------------------------------- | |
| # supports_deferrable | |
| # --------------------------------------------------------------------------- | |
| class DeferrableOperator: | |
| """Mirrors HttpOperator: reads self.deferrable directly in its own execute().""" | |
| def __init__(self, *, deferrable: bool = False): | |
| self.deferrable = deferrable | |
| def execute(self, context): | |
| if self.deferrable: | |
| return self.execute_async(context) | |
| return self.execute_sync(context) | |
| def execute_async(self, context): | |
| return None | |
| def execute_sync(self, context): | |
| return None | |
| class NonDeferrableOperator: | |
| def __init__(self, *, some_other_param: str = ""): | |
| self.some_other_param = some_other_param | |
| def execute(self, context): | |
| return None | |
| class OverridesExecuteWithoutDeferring(DeferrableOperator): | |
| """Mirrors DiscordWebhookOperator: inherits the deferrable param but overrides | |
| execute() with synchronous code that never reads it -- an inherited, dead knob.""" | |
| def execute(self, context): | |
| return self.hook_call(context) | |
| def hook_call(self, context): | |
| return None | |
| class InheritsDeferringExecute(DeferrableOperator): | |
| """Mirrors TimeDeltaSensorAsync: doesn't override execute() at all -- still | |
| defers because the inherited method is what actually runs.""" | |
| class AlwaysDeferringOperator: | |
| """Mirrors DateTimeSensorAsync: defers unconditionally, no toggle to find.""" | |
| def execute(self, context): | |
| self.defer() | |
| def defer(self, *args, **kwargs): | |
| return None | |
| class DefersThroughHelperMethod: | |
| """Mirrors TriggerDagRunOperator: execute() delegates to a same class helper | |
| that is the one actually calling self.defer().""" | |
| def execute(self, context): | |
| return self._trigger_dag_af_2(context) | |
| def _trigger_dag_af_2(self, context): | |
| return self.defer() | |
| def defer(self, *args, **kwargs): | |
| return None | |
| class DefersThroughUnrelatedHelperMethod: | |
| """The helper the walk follows doesn't defer at all.""" | |
| def execute(self, context): | |
| return self._do_the_thing(context) | |
| def _do_the_thing(self, context): | |
| return None | |
| class BaseWithDeferringExecute: | |
| """Mirrors KubernetesPodOperator: its own execute() calls self.defer().""" | |
| def execute(self, context): | |
| if self.pod is None: | |
| self.defer() | |
| return None | |
| def defer(self, *args, **kwargs): | |
| return None | |
| class SubclassDelegatingToSuperExecute(BaseWithDeferringExecute): | |
| """Mirrors EksPodOperator: overrides execute() to do setup, then calls | |
| super().execute(), which is where the actual self.defer() call lives.""" | |
| def execute(self, context): | |
| self.setup(context) | |
| return super().execute(context) | |
| def setup(self, context): | |
| return None | |
| class SubclassDelegatingToExplicitParentExecute(BaseWithDeferringExecute): | |
| """Mirrors @task.agent's _AgentDecoratedOperator: names the parent class directly | |
| instead of using super().""" | |
| def execute(self, context): | |
| return BaseWithDeferringExecute.execute(self, context) | |
| class DeferringDecoratorMixin: | |
| """Mirrors DecoratedOperator: a mixin whose own MRO has no relationship to BaseWithDeferringExecute.""" | |
| def execute(self, context): | |
| return super().execute(context) | |
| class DecoratedDeferringSubclass(DeferringDecoratorMixin, BaseWithDeferringExecute): | |
| """Combines DeferringDecoratorMixin with the deferring class via multiple inheritance.""" | |
| def execute(self, context): | |
| return super().execute(context) | |
| class RaisesTaskDeferredDirectly: | |
| """Mirrors VespaIngestOperator: raises TaskDeferred directly, with no | |
| self.defer()/self.deferrable reference anywhere in the call chain.""" | |
| def execute(self, context): | |
| raise TaskDeferred(trigger=None, method_name="execute_complete") | |
| class TaskDeferred(Exception): | |
| """Placeholder for actual TaskDeferred exception.""" | |
| def __init__(self, *, trigger, method_name): | |
| self.trigger = trigger | |
| self.method_name = method_name | |
| _FAKE_AIRFLOW_V_3_3_PLUS = True | |
| _FAKE_OLDER_CORE_FLAG = False | |
| class TaskAwaitingInput(Exception): | |
| """Placeholder for the real exception the AIRFLOW_V_3_3_PLUS branch raises.""" | |
| class DefersForApprovalOnly: | |
| """Mirrors the common.ai LLM operators: self.defer( is real but dead, reachable only | |
| through a helper, and only on a core older than this one.""" | |
| def execute(self, context): | |
| return self.defer_for_approval(context) | |
| def defer_for_approval(self, context): | |
| if _FAKE_AIRFLOW_V_3_3_PLUS: | |
| raise TaskAwaitingInput | |
| return self.defer() | |
| def defer(self, *args, **kwargs): | |
| return None | |
| class HITLShapedOperator: | |
| """Mirrors HITLOperator: same dead self.defer(, but directly in execute() itself.""" | |
| def execute(self, context): | |
| if _FAKE_AIRFLOW_V_3_3_PLUS: | |
| raise TaskAwaitingInput | |
| return self.defer() | |
| def defer(self, *args, **kwargs): | |
| return None | |
| def _wrap_in_foreign_module(func): | |
| """Wrap `func` the way BaseOperatorMeta._apply_defaults wraps operator `execute`. | |
| functools.wraps copies `__module__` from the wrapped function but leaves `__globals__` | |
| pointing at the wrapper's own module, which is why the version flag is invisible until | |
| the wrapper is unwrapped. Building the wrapper in a separate module namespace is what | |
| reproduces that here, since a wrapper defined in this file would share these globals. | |
| """ | |
| foreign = types.ModuleType("foreign_wrapper_module") | |
| foreign.__dict__["_inner"] = func | |
| exec( | |
| "import functools\n" | |
| "@functools.wraps(_inner)\n" | |
| "def wrapper(self, context):\n" | |
| " return _inner(self, context)\n", | |
| foreign.__dict__, | |
| ) | |
| return foreign.__dict__["wrapper"] | |
| class DeferralCommentOnlyOperator: | |
| """A comment naming self.defer() is not a deferral, the same way it is not delegation.""" | |
| def execute(self, context): | |
| # falls through to the sync path rather than calling self.defer() | |
| return None | |
| class TestSupportsDeferrable: | |
| def test_reads_deferrable_directly_qualifies(self): | |
| assert supports_deferrable(DeferrableOperator) is True | |
| def test_no_deferral_reference_disqualifies(self): | |
| assert supports_deferrable(NonDeferrableOperator) is False | |
| def test_inherited_param_but_overridden_execute_disqualifies(self): | |
| """The Discord regression case: an inherited deferrable knob that the | |
| subclass's own execute() never reads must not count as deferrable.""" | |
| assert supports_deferrable(OverridesExecuteWithoutDeferring) is False | |
| def test_inherited_unmodified_execute_qualifies(self): | |
| assert supports_deferrable(InheritsDeferringExecute) is True | |
| def test_unconditional_defer_with_no_param_qualifies(self): | |
| assert supports_deferrable(AlwaysDeferringOperator) is True | |
| def test_defers_through_helper_method_qualifies(self): | |
| """The TriggerDagRunOperator case: found by walking into the helper the | |
| exception list used to hardcode, without needing a class-name lookup.""" | |
| assert supports_deferrable(DefersThroughHelperMethod) is True | |
| def test_helper_method_without_deferral_disqualifies(self): | |
| assert supports_deferrable(DefersThroughUnrelatedHelperMethod) is False | |
| def test_defers_through_super_execute_qualifies(self): | |
| """The EksPodOperator case: the override calls super().execute(), and the | |
| actual self.defer() call lives in the parent's execute().""" | |
| assert supports_deferrable(SubclassDelegatingToSuperExecute) is True | |
| def test_defers_through_super_execute_with_multiple_inheritance_qualifies(self): | |
| """A @task.kubernetes-shaped case: the chain must resolve against the subclass's MRO.""" | |
| assert supports_deferrable(DecoratedDeferringSubclass) is True | |
| def test_defers_through_explicit_parent_class_call_qualifies(self): | |
| """A @task.agent-shaped case: delegation names the parent class instead of super().""" | |
| assert supports_deferrable(SubclassDelegatingToExplicitParentExecute) is True | |
| def test_raises_task_deferred_directly_qualifies(self): | |
| """The VespaIngestOperator case: no self.defer()/self.deferrable anywhere, | |
| just a direct TaskDeferred raise.""" | |
| assert supports_deferrable(RaisesTaskDeferredDirectly) is True | |
| def test_defer_for_approval_helper_with_dead_fallback_does_not_false_match(self): | |
| assert supports_deferrable(DefersForApprovalOnly) is False | |
| def test_hitl_shaped_dead_fallback_does_not_false_match(self): | |
| assert supports_deferrable(HITLShapedOperator) is False | |
| def test_version_guard_only_excludes_branch_true_for_this_core(self): | |
| """A guard evaluating False leaves the self.defer( below it genuinely reachable.""" | |
| class OlderCoreShapedOperator: | |
| def execute(self, context): | |
| if _FAKE_OLDER_CORE_FLAG: | |
| raise TaskAwaitingInput | |
| return self.defer() | |
| def defer(self, *args, **kwargs): | |
| return None | |
| assert supports_deferrable(OlderCoreShapedOperator) is True | |
| def test_wrapped_execute_still_resolves_the_version_guard(self): | |
| """Pins the `inspect.unwrap` in `_get_reachable_method_source`. Without it the flag | |
| resolves against the wrapper's module, the dead branch survives, and the four | |
| standard HITL operators go back to reporting deferrable.""" | |
| class WrappedHITLShapedOperator: | |
| execute = _wrap_in_foreign_module(HITLShapedOperator.execute) | |
| def defer(self, *args, **kwargs): | |
| return None | |
| assert supports_deferrable(WrappedHITLShapedOperator) is False | |
| def test_comment_mentioning_defer_is_not_mistaken_for_deferral(self): | |
| assert supports_deferrable(DeferralCommentOnlyOperator) is False | |
| # --------------------------------------------------------------------------- | |
| # Module dataclass | |
| # --------------------------------------------------------------------------- | |
| class TestModuleDataclass: | |
| def test_construction_with_all_fields(self): | |
| m = Module( | |
| id="amazon-s3-S3Hook", | |
| name="S3Hook", | |
| type="hook", | |
| import_path="airflow.providers.amazon.aws.hooks.s3.S3Hook", | |
| module_path="airflow.providers.amazon.aws.hooks.s3", | |
| short_description="Interact with Amazon S3.", | |
| docs_url="https://example.com/docs", | |
| source_url="https://github.com/apache/airflow/blob/main/providers/amazon/src/...", | |
| category="amazon-s3", | |
| provider_id="amazon", | |
| provider_name="Amazon", | |
| supports_durable_execution=False, | |
| supports_deferrable=False, | |
| ) | |
| assert m.id == "amazon-s3-S3Hook" | |
| assert m.provider_name == "Amazon" | |
| # --------------------------------------------------------------------------- | |
| # Helpers: fake classes and modules | |
| # --------------------------------------------------------------------------- | |
| class FakeBaseOperator: | |
| """Fake base operator.""" | |
| class FakeBaseSensorOperator(FakeBaseOperator): | |
| """Fake base sensor.""" | |
| class FakeBaseHook: | |
| """Fake base hook.""" | |
| class FakeOperator(FakeBaseOperator): | |
| """Copy objects in S3.""" | |
| __module__ = "airflow.providers.amazon.aws.operators.s3" | |
| class FakeSensor(FakeBaseSensorOperator): | |
| """Wait for S3 key.""" | |
| __module__ = "airflow.providers.amazon.aws.sensors.s3" | |
| class FakeHook(FakeBaseHook): | |
| """Interact with S3.""" | |
| __module__ = "airflow.providers.amazon.aws.hooks.s3" | |
| class FakeReexportedClass(FakeBaseOperator): | |
| """This class is defined in another module.""" | |
| __module__ = "airflow.providers.amazon.aws.operators._internal" | |
| class _FakePrivateClass(FakeBaseOperator): | |
| """Private helper class.""" | |
| __module__ = "airflow.providers.amazon.aws.operators.s3" | |
| class BaseCustomOperator(FakeBaseOperator): | |
| """A base class that should be skipped.""" | |
| __module__ = "airflow.providers.amazon.aws.operators.s3" | |
| class AbstractThing(FakeBaseOperator): | |
| """An abstract class.""" | |
| __module__ = "airflow.providers.amazon.aws.operators.s3" | |
| class OperatorMixin(FakeBaseOperator): | |
| """A mixin.""" | |
| __module__ = "airflow.providers.amazon.aws.operators.s3" | |
| class FakeNotifier: | |
| """Chime notifier.""" | |
| __module__ = "airflow.providers.amazon.aws.notifications.chime" | |
| class FakeExecutor: | |
| """ECS executor.""" | |
| __module__ = "airflow.providers.amazon.aws.executors.ecs" | |
| class FakeSecretBackend: | |
| """Secrets Manager backend.""" | |
| __module__ = "airflow.providers.amazon.aws.secrets.secrets_manager" | |
| class FakePlugin: | |
| """S3 plugin.""" | |
| __module__ = "airflow.providers.amazon.aws.plugins.s3" | |
| class FakeDialect: | |
| """Redshift dialect.""" | |
| __module__ = "airflow.providers.amazon.aws.dialects.redshift" | |
| def _make_module(name: str, members: dict) -> types.ModuleType: | |
| """Create a fake module with given members.""" | |
| mod = types.ModuleType(name) | |
| mod.__name__ = name | |
| for attr_name, attr_val in members.items(): | |
| setattr(mod, attr_name, attr_val) | |
| return mod | |
| class FakeDecoratedOperator(ManuallyDurableOperator): | |
| """Stands in for _AgentDecoratedOperator: private, so its only catalog surface is the | |
| decorator entry, and durable-capable via explicit-parent delegation.""" | |
| __module__ = "airflow.providers.amazon.aws.decorators.fake" | |
| def execute(self, context): | |
| return ManuallyDurableOperator.execute(self, context) | |
| def _fake_task_decorator_factory(**kwargs): | |
| return None | |
| def fake_decorator_task(python_callable=None, **kwargs): | |
| """Fake task decorator.""" | |
| return _fake_task_decorator_factory( | |
| python_callable=python_callable, | |
| decorated_operator_class=FakeDecoratedOperator, | |
| **kwargs, | |
| ) | |
| FAKE_PROVIDER_YAML = { | |
| "package-name": "apache-airflow-providers-amazon", | |
| "name": "Amazon", | |
| "operators": [ | |
| { | |
| "integration-name": "Amazon S3", | |
| "python-modules": ["airflow.providers.amazon.aws.operators.s3"], | |
| }, | |
| ], | |
| "sensors": [ | |
| { | |
| "integration-name": "Amazon S3", | |
| "python-modules": ["airflow.providers.amazon.aws.sensors.s3"], | |
| }, | |
| ], | |
| "hooks": [ | |
| { | |
| "integration-name": "Amazon S3", | |
| "python-modules": ["airflow.providers.amazon.aws.hooks.s3"], | |
| }, | |
| ], | |
| "transfers": [], | |
| "triggers": [], | |
| "notifications": [ | |
| "airflow.providers.amazon.aws.notifications.chime.FakeNotifier", | |
| ], | |
| "secrets-backends": [ | |
| "airflow.providers.amazon.aws.secrets.secrets_manager.FakeSecretBackend", | |
| ], | |
| "executors": [ | |
| "airflow.providers.amazon.aws.executors.ecs.FakeExecutor", | |
| ], | |
| "plugins": [ | |
| { | |
| "name": "AmazonS3Plugin", | |
| "plugin-class": "airflow.providers.amazon.aws.plugins.s3.FakePlugin", | |
| }, | |
| "not-a-dict-entry", | |
| ], | |
| "dialects": [ | |
| { | |
| "dialect-type": "redshift", | |
| "dialect-class-name": "airflow.providers.amazon.aws.dialects.redshift.FakeDialect", | |
| }, | |
| "not-a-dict-entry", | |
| ], | |
| "task-decorators": [ | |
| { | |
| "class-name": "airflow.providers.amazon.aws.decorators.fake.fake_decorator_task", | |
| "name": "fake_decorator", | |
| }, | |
| ], | |
| } | |
| # --------------------------------------------------------------------------- | |
| # TestDiscoverClassesFromProvider | |
| # --------------------------------------------------------------------------- | |
| class TestDiscoverClassesFromProvider: | |
| @pytest.fixture | |
| def provider_yaml_path(self, tmp_path): | |
| import yaml | |
| # The provider.yaml must be under a directory structure relative to PROVIDERS_DIR | |
| # to make relative_to() work. We patch PROVIDERS_DIR to tmp_path. | |
| provider_dir = tmp_path / "amazon" | |
| provider_dir.mkdir() | |
| yaml_path = provider_dir / "provider.yaml" | |
| yaml_path.write_text(yaml.dump(FAKE_PROVIDER_YAML)) | |
| return yaml_path | |
| @pytest.fixture | |
| def base_classes(self): | |
| return { | |
| "operator": FakeBaseOperator, | |
| "sensor": FakeBaseSensorOperator, | |
| "hook": FakeBaseHook, | |
| } | |
| def _mock_import(self, module_name): | |
| """Return fake modules for known paths.""" | |
| modules = { | |
| "airflow.providers.amazon.aws.operators.s3": _make_module( | |
| "airflow.providers.amazon.aws.operators.s3", | |
| { | |
| "FakeOperator": FakeOperator, | |
| "FakeReexportedClass": FakeReexportedClass, | |
| "_FakePrivateClass": _FakePrivateClass, | |
| "BaseCustomOperator": BaseCustomOperator, | |
| "AbstractThing": AbstractThing, | |
| "OperatorMixin": OperatorMixin, | |
| }, | |
| ), | |
| "airflow.providers.amazon.aws.sensors.s3": _make_module( | |
| "airflow.providers.amazon.aws.sensors.s3", | |
| {"FakeSensor": FakeSensor}, | |
| ), | |
| "airflow.providers.amazon.aws.hooks.s3": _make_module( | |
| "airflow.providers.amazon.aws.hooks.s3", | |
| {"FakeHook": FakeHook}, | |
| ), | |
| "airflow.providers.amazon.aws.notifications.chime": _make_module( | |
| "airflow.providers.amazon.aws.notifications.chime", | |
| {"FakeNotifier": FakeNotifier}, | |
| ), | |
| "airflow.providers.amazon.aws.secrets.secrets_manager": _make_module( | |
| "airflow.providers.amazon.aws.secrets.secrets_manager", | |
| {"FakeSecretBackend": FakeSecretBackend}, | |
| ), | |
| "airflow.providers.amazon.aws.executors.ecs": _make_module( | |
| "airflow.providers.amazon.aws.executors.ecs", | |
| {"FakeExecutor": FakeExecutor}, | |
| ), | |
| "airflow.providers.amazon.aws.plugins.s3": _make_module( | |
| "airflow.providers.amazon.aws.plugins.s3", | |
| {"FakePlugin": FakePlugin}, | |
| ), | |
| "airflow.providers.amazon.aws.dialects.redshift": _make_module( | |
| "airflow.providers.amazon.aws.dialects.redshift", | |
| {"FakeDialect": FakeDialect}, | |
| ), | |
| "airflow.providers.amazon.aws.decorators.fake": _make_module( | |
| "airflow.providers.amazon.aws.decorators.fake", | |
| {"fake_decorator_task": fake_decorator_task}, | |
| ), | |
| } | |
| if module_name in modules: | |
| return modules[module_name] | |
| raise ImportError(f"No module named {module_name!r}") | |
| def test_discovers_operator(self, provider_yaml_path, base_classes): | |
| with ( | |
| patch("extract_parameters.PROVIDERS_DIR", provider_yaml_path.parent.parent), | |
| patch("extract_parameters.importlib.import_module", side_effect=self._mock_import), | |
| ): | |
| result = discover_classes_from_provider(provider_yaml_path, base_classes) | |
| operators = [r for r in result if r["type"] == "operator"] | |
| assert len(operators) == 1 | |
| assert operators[0]["name"] == "FakeOperator" | |
| assert operators[0]["import_path"] == "airflow.providers.amazon.aws.operators.s3.FakeOperator" | |
| assert operators[0]["provider_id"] == "amazon" | |
| def test_discovers_sensor(self, provider_yaml_path, base_classes): | |
| with ( | |
| patch("extract_parameters.PROVIDERS_DIR", provider_yaml_path.parent.parent), | |
| patch("extract_parameters.importlib.import_module", side_effect=self._mock_import), | |
| ): | |
| result = discover_classes_from_provider(provider_yaml_path, base_classes) | |
| sensors = [r for r in result if r["type"] == "sensor"] | |
| assert len(sensors) == 1 | |
| assert sensors[0]["name"] == "FakeSensor" | |
| def test_discovers_hook(self, provider_yaml_path, base_classes): | |
| with ( | |
| patch("extract_parameters.PROVIDERS_DIR", provider_yaml_path.parent.parent), | |
| patch("extract_parameters.importlib.import_module", side_effect=self._mock_import), | |
| ): | |
| result = discover_classes_from_provider(provider_yaml_path, base_classes) | |
| hooks = [r for r in result if r["type"] == "hook"] | |
| assert len(hooks) == 1 | |
| assert hooks[0]["name"] == "FakeHook" | |
| def test_discovers_plugin(self, provider_yaml_path, base_classes): | |
| with ( | |
| patch("extract_parameters.PROVIDERS_DIR", provider_yaml_path.parent.parent), | |
| patch("extract_parameters.importlib.import_module", side_effect=self._mock_import), | |
| ): | |
| result = discover_classes_from_provider(provider_yaml_path, base_classes) | |
| plugins = [r for r in result if r["type"] == "plugin"] | |
| assert len(plugins) == 1 | |
| assert plugins[0]["name"] == "FakePlugin" | |
| assert plugins[0]["category"] == "plugins" | |
| def test_discovers_dialect(self, provider_yaml_path, base_classes): | |
| with ( | |
| patch("extract_parameters.PROVIDERS_DIR", provider_yaml_path.parent.parent), | |
| patch("extract_parameters.importlib.import_module", side_effect=self._mock_import), | |
| ): | |
| result = discover_classes_from_provider(provider_yaml_path, base_classes) | |
| dialects = [r for r in result if r["type"] == "dialect"] | |
| assert len(dialects) == 1 | |
| assert dialects[0]["name"] == "FakeDialect" | |
| assert dialects[0]["category"] == "dialects" | |
| def test_skips_non_dict_plugin_and_dialect_entries(self, provider_yaml_path, base_classes): | |
| """FAKE_PROVIDER_YAML's plugins/dialects each carry a bare-string entry | |
| alongside the valid dict entry; it must be skipped, not raise.""" | |
| with ( | |
| patch("extract_parameters.PROVIDERS_DIR", provider_yaml_path.parent.parent), | |
| patch("extract_parameters.importlib.import_module", side_effect=self._mock_import), | |
| ): | |
| result = discover_classes_from_provider(provider_yaml_path, base_classes) | |
| assert len([r for r in result if r["type"] == "plugin"]) == 1 | |
| assert len([r for r in result if r["type"] == "dialect"]) == 1 | |
| def test_filters_reexported_classes(self, provider_yaml_path, base_classes): | |
| """Classes where cls.__module__ != the module being scanned should be excluded.""" | |
| with ( | |
| patch("extract_parameters.PROVIDERS_DIR", provider_yaml_path.parent.parent), | |
| patch("extract_parameters.importlib.import_module", side_effect=self._mock_import), | |
| ): | |
| result = discover_classes_from_provider(provider_yaml_path, base_classes) | |
| names = {r["name"] for r in result} | |
| assert "FakeReexportedClass" not in names | |
| def test_filters_private_base_abstract_mixin(self, provider_yaml_path, base_classes): | |
| """Private, Base*, Abstract*, and Mixin classes should all be excluded.""" | |
| with ( | |
| patch("extract_parameters.PROVIDERS_DIR", provider_yaml_path.parent.parent), | |
| patch("extract_parameters.importlib.import_module", side_effect=self._mock_import), | |
| ): | |
| result = discover_classes_from_provider(provider_yaml_path, base_classes) | |
| names = {r["name"] for r in result} | |
| assert "_FakePrivateClass" not in names | |
| assert "BaseCustomOperator" not in names | |
| assert "AbstractThing" not in names | |
| assert "OperatorMixin" not in names | |
| def test_extracts_short_description(self, provider_yaml_path, base_classes): | |
| with ( | |
| patch("extract_parameters.PROVIDERS_DIR", provider_yaml_path.parent.parent), | |
| patch("extract_parameters.importlib.import_module", side_effect=self._mock_import), | |
| ): | |
| result = discover_classes_from_provider(provider_yaml_path, base_classes) | |
| operators = [r for r in result if r["type"] == "operator"] | |
| assert operators[0]["short_description"] == "Copy objects in S3." | |
| def test_all_module_fields_present(self, provider_yaml_path, base_classes): | |
| """Every discovered entry has every `Module` dataclass field (derived, not hardcoded).""" | |
| with ( | |
| patch("extract_parameters.PROVIDERS_DIR", provider_yaml_path.parent.parent), | |
| patch("extract_parameters.importlib.import_module", side_effect=self._mock_import), | |
| ): | |
| result = discover_classes_from_provider(provider_yaml_path, base_classes) | |
| required_fields = {f.name for f in fields(Module)} | |
| for entry in result: | |
| missing = required_fields - entry.keys() | |
| assert not missing, f"Missing fields {missing} in {entry['name']}" | |
| def test_decorator_entry_reports_the_capability_of_the_operator_it_builds( | |
| self, provider_yaml_path, base_classes | |
| ): | |
| """A `@task.*` entry registers the factory function, so its capability has to come | |
| from the `decorated_operator_class=` the factory passes on.""" | |
| with ( | |
| patch("extract_parameters.PROVIDERS_DIR", provider_yaml_path.parent.parent), | |
| patch("extract_parameters.importlib.import_module", side_effect=self._mock_import), | |
| ): | |
| result = discover_classes_from_provider( | |
| provider_yaml_path, base_classes, resumable_mixin=FakeResumableJobMixin | |
| ) | |
| decorators = [r for r in result if r["type"] == "decorator"] | |
| assert len(decorators) == 1 | |
| assert decorators[0]["name"] == "@task.fake_decorator" | |
| assert decorators[0]["supports_durable_execution"] is True | |
| def test_id_format(self, provider_yaml_path, base_classes): | |
| """ID follows the pattern {provider_id}-{module_name}-{class_name}.""" | |
| with ( | |
| patch("extract_parameters.PROVIDERS_DIR", provider_yaml_path.parent.parent), | |
| patch("extract_parameters.importlib.import_module", side_effect=self._mock_import), | |
| ): | |
| result = discover_classes_from_provider(provider_yaml_path, base_classes) | |
| operators = [r for r in result if r["type"] == "operator"] | |
| assert operators[0]["id"] == "amazon-s3-FakeOperator" | |
| def test_provider_name_from_yaml(self, provider_yaml_path, base_classes): | |
| """provider_name comes from the YAML 'name' field.""" | |
| with ( | |
| patch("extract_parameters.PROVIDERS_DIR", provider_yaml_path.parent.parent), | |
| patch("extract_parameters.importlib.import_module", side_effect=self._mock_import), | |
| ): | |
| result = discover_classes_from_provider(provider_yaml_path, base_classes) | |
| for entry in result: | |
| assert entry["provider_name"] == "Amazon" | |
| def test_category_from_integration_name(self, provider_yaml_path, base_classes): | |
| """Category is slugified from the integration-name in provider.yaml.""" | |
| with ( | |
| patch("extract_parameters.PROVIDERS_DIR", provider_yaml_path.parent.parent), | |
| patch("extract_parameters.importlib.import_module", side_effect=self._mock_import), | |
| ): | |
| result = discover_classes_from_provider(provider_yaml_path, base_classes) | |
| operators = [r for r in result if r["type"] == "operator"] | |
| assert operators[0]["category"] == "amazon-s3" | |
| def test_docs_url_uses_inventory(self, provider_yaml_path, base_classes): | |
| """When inventory contains the class, docs_url uses the inventory path.""" | |
| inventory = { | |
| "airflow.providers.amazon.aws.hooks.s3.FakeHook": "_api/hooks/s3.html#airflow.providers.amazon.aws.hooks.s3.FakeHook", | |
| } | |
| with ( | |
| patch("extract_parameters.PROVIDERS_DIR", provider_yaml_path.parent.parent), | |
| patch("extract_parameters.importlib.import_module", side_effect=self._mock_import), | |
| ): | |
| result = discover_classes_from_provider(provider_yaml_path, base_classes, inventory=inventory) | |
| hooks = [r for r in result if r["type"] == "hook"] | |
| assert "_api/hooks/s3.html#airflow.providers.amazon.aws.hooks.s3.FakeHook" in hooks[0]["docs_url"] | |
| def test_docs_url_fallback_without_inventory(self, provider_yaml_path, base_classes): | |
| """Without inventory, docs_url falls back to manual construction.""" | |
| with ( | |
| patch("extract_parameters.PROVIDERS_DIR", provider_yaml_path.parent.parent), | |
| patch("extract_parameters.importlib.import_module", side_effect=self._mock_import), | |
| ): | |
| result = discover_classes_from_provider(provider_yaml_path, base_classes) | |
| hooks = [r for r in result if r["type"] == "hook"] | |
| assert "/_api/airflow/providers/amazon/aws/hooks/s3/index.html" in hooks[0]["docs_url"] | |
| def test_source_url_contains_github(self, provider_yaml_path, base_classes): | |
| """source_url points to GitHub.""" | |
| with ( | |
| patch("extract_parameters.PROVIDERS_DIR", provider_yaml_path.parent.parent), | |
| patch("extract_parameters.importlib.import_module", side_effect=self._mock_import), | |
| ): | |
| result = discover_classes_from_provider(provider_yaml_path, base_classes, version="1.0.0") | |
| operators = [r for r in result if r["type"] == "operator"] | |
| assert "github.com/apache/airflow/blob/" in operators[0]["source_url"] | |
| assert "providers-amazon/1.0.0" in operators[0]["source_url"] | |
| # --------------------------------------------------------------------------- | |
| # TestSensorNotClassifiedAsOperator | |
| # --------------------------------------------------------------------------- | |
| class TestSensorNotClassifiedAsOperator: | |
| """A sensor inherits from BaseOperator via BaseSensorOperator. | |
| Since discovery iterates provider.yaml sections, a sensor should be classified | |
| as "sensor" (from the sensors section) not "operator", even though it passes | |
| issubclass(cls, BaseOperator). | |
| """ | |
| def test_sensor_in_sensor_section_is_sensor_type(self, tmp_path): | |
| import yaml | |
| # Sensor listed only in the sensors section | |
| provider_yaml = { | |
| "package-name": "apache-airflow-providers-test", | |
| "operators": [], | |
| "sensors": [ | |
| { | |
| "integration-name": "Test", | |
| "python-modules": ["airflow.providers.test.sensors.my_sensor"], | |
| } | |
| ], | |
| } | |
| provider_dir = tmp_path / "test" | |
| provider_dir.mkdir() | |
| yaml_path = provider_dir / "provider.yaml" | |
| yaml_path.write_text(yaml.dump(provider_yaml)) | |
| class MySensor(FakeBaseSensorOperator): | |
| """My custom sensor.""" | |
| __module__ = "airflow.providers.test.sensors.my_sensor" | |
| mod = _make_module( | |
| "airflow.providers.test.sensors.my_sensor", | |
| {"MySensor": MySensor}, | |
| ) | |
| base_classes = { | |
| "sensor": FakeBaseSensorOperator, | |
| "operator": FakeBaseOperator, | |
| } | |
| with ( | |
| patch("extract_parameters.PROVIDERS_DIR", tmp_path), | |
| patch("extract_parameters.importlib.import_module", return_value=mod), | |
| ): | |
| result = discover_classes_from_provider(yaml_path, base_classes) | |
| assert len(result) == 1 | |
| assert result[0]["type"] == "sensor" | |
| assert result[0]["name"] == "MySensor" | |
| # --------------------------------------------------------------------------- | |
| # TestDiscoverClassesFromProvider: supports_durable_execution wiring | |
| # --------------------------------------------------------------------------- | |
| class TestDiscoverClassesFromProviderDurableExecution: | |
| def test_marks_durable_capable_and_plain_operators(self, tmp_path): | |
| class ResumableOperator(FullyImplementedResumableOperator): | |
| __module__ = "airflow.providers.test.operators.spark" | |
| class PlainProviderOperator(PlainOperator): | |
| __module__ = "airflow.providers.test.operators.spark" | |
| provider_yaml = { | |
| "package-name": "apache-airflow-providers-test", | |
| "name": "Test", | |
| "operators": [ | |
| { | |
| "integration-name": "Test", | |
| "python-modules": ["airflow.providers.test.operators.spark"], | |
| }, | |
| ], | |
| } | |
| provider_dir = tmp_path / "test" | |
| provider_dir.mkdir() | |
| yaml_path = provider_dir / "provider.yaml" | |
| yaml_path.write_text(yaml.dump(provider_yaml)) | |
| mod = _make_module( | |
| "airflow.providers.test.operators.spark", | |
| { | |
| "ResumableOperator": ResumableOperator, | |
| "PlainProviderOperator": PlainProviderOperator, | |
| }, | |
| ) | |
| with ( | |
| patch("extract_parameters.PROVIDERS_DIR", tmp_path), | |
| patch("extract_parameters.importlib.import_module", return_value=mod), | |
| ): | |
| result = discover_classes_from_provider( | |
| yaml_path, base_classes={}, resumable_mixin=FakeResumableJobMixin | |
| ) | |
| by_name = {r["name"]: r for r in result} | |
| assert by_name["ResumableOperator"]["supports_durable_execution"] is True | |
| assert by_name["PlainProviderOperator"]["supports_durable_execution"] is False | |
| def test_defaults_to_false_when_mixin_unavailable(self, tmp_path): | |
| class ResumableOperator(FullyImplementedResumableOperator): | |
| __module__ = "airflow.providers.test.operators.spark" | |
| provider_yaml = { | |
| "package-name": "apache-airflow-providers-test", | |
| "name": "Test", | |
| "operators": [ | |
| { | |
| "integration-name": "Test", | |
| "python-modules": ["airflow.providers.test.operators.spark"], | |
| }, | |
| ], | |
| } | |
| provider_dir = tmp_path / "test" | |
| provider_dir.mkdir() | |
| yaml_path = provider_dir / "provider.yaml" | |
| yaml_path.write_text(yaml.dump(provider_yaml)) | |
| mod = _make_module( | |
| "airflow.providers.test.operators.spark", | |
| {"ResumableOperator": ResumableOperator}, | |
| ) | |
| with ( | |
| patch("extract_parameters.PROVIDERS_DIR", tmp_path), | |
| patch("extract_parameters.importlib.import_module", return_value=mod), | |
| ): | |
| result = discover_classes_from_provider(yaml_path, base_classes={}) | |
| assert result[0]["supports_durable_execution"] is False | |
| class TestDiscoverClassesFromProviderDeferrable: | |
| def test_marks_deferrable_and_plain_operators(self, tmp_path): | |
| class DeferrableProviderOperator(DeferrableOperator): | |
| __module__ = "airflow.providers.test.operators.spark" | |
| class PlainProviderOperator(PlainOperator): | |
| __module__ = "airflow.providers.test.operators.spark" | |
| provider_yaml = { | |
| "package-name": "apache-airflow-providers-test", | |
| "name": "Test", | |
| "operators": [ | |
| { | |
| "integration-name": "Test", | |
| "python-modules": ["airflow.providers.test.operators.spark"], | |
| }, | |
| ], | |
| } | |
| provider_dir = tmp_path / "test" | |
| provider_dir.mkdir() | |
| yaml_path = provider_dir / "provider.yaml" | |
| yaml_path.write_text(yaml.dump(provider_yaml)) | |
| mod = _make_module( | |
| "airflow.providers.test.operators.spark", | |
| { | |
| "DeferrableProviderOperator": DeferrableProviderOperator, | |
| "PlainProviderOperator": PlainProviderOperator, | |
| }, | |
| ) | |
| with ( | |
| patch("extract_parameters.PROVIDERS_DIR", tmp_path), | |
| patch("extract_parameters.importlib.import_module", return_value=mod), | |
| ): | |
| result = discover_classes_from_provider(yaml_path, base_classes={}) | |
| by_name = {r["name"]: r for r in result} | |
| assert by_name["DeferrableProviderOperator"]["supports_deferrable"] is True | |
| assert by_name["PlainProviderOperator"]["supports_deferrable"] is False | |
| # --------------------------------------------------------------------------- | |
| # TestDiscoverClassLevelEntries | |
| # --------------------------------------------------------------------------- | |
| class TestDiscoverClassLevelEntries: | |
| """Verify notifications, secrets-backends, executors are discovered from full class paths.""" | |
| def test_discovers_notifier(self, tmp_path): | |
| import yaml | |
| provider_yaml = { | |
| "package-name": "apache-airflow-providers-amazon", | |
| "notifications": [ | |
| "airflow.providers.amazon.aws.notifications.chime.ChimeNotifier", | |
| ], | |
| } | |
| provider_dir = tmp_path / "amazon" | |
| provider_dir.mkdir() | |
| yaml_path = provider_dir / "provider.yaml" | |
| yaml_path.write_text(yaml.dump(provider_yaml)) | |
| class ChimeNotifier: | |
| """Send Chime notification.""" | |
| __module__ = "airflow.providers.amazon.aws.notifications.chime" | |
| mod = _make_module( | |
| "airflow.providers.amazon.aws.notifications.chime", | |
| {"ChimeNotifier": ChimeNotifier}, | |
| ) | |
| with ( | |
| patch("extract_parameters.PROVIDERS_DIR", tmp_path), | |
| patch("extract_parameters.importlib.import_module", return_value=mod), | |
| ): | |
| result = discover_classes_from_provider(yaml_path, {}) | |
| notifiers = [r for r in result if r["type"] == "notifier"] | |
| assert len(notifiers) == 1 | |
| assert notifiers[0]["name"] == "ChimeNotifier" | |
| assert notifiers[0]["import_path"] == ( | |
| "airflow.providers.amazon.aws.notifications.chime.ChimeNotifier" | |
| ) | |
| assert notifiers[0]["category"] == "notifications" | |
| def test_discovers_secret_backend(self, tmp_path): | |
| import yaml | |
| provider_yaml = { | |
| "package-name": "apache-airflow-providers-amazon", | |
| "secrets-backends": [ | |
| "airflow.providers.amazon.aws.secrets.sm.SecretsManagerBackend", | |
| ], | |
| } | |
| provider_dir = tmp_path / "amazon" | |
| provider_dir.mkdir() | |
| yaml_path = provider_dir / "provider.yaml" | |
| yaml_path.write_text(yaml.dump(provider_yaml)) | |
| class SecretsManagerBackend: | |
| """Secrets Manager backend.""" | |
| mod = _make_module( | |
| "airflow.providers.amazon.aws.secrets.sm", | |
| {"SecretsManagerBackend": SecretsManagerBackend}, | |
| ) | |
| with ( | |
| patch("extract_parameters.PROVIDERS_DIR", tmp_path), | |
| patch("extract_parameters.importlib.import_module", return_value=mod), | |
| ): | |
| result = discover_classes_from_provider(yaml_path, {}) | |
| secrets = [r for r in result if r["type"] == "secret"] | |
| assert len(secrets) == 1 | |
| assert secrets[0]["name"] == "SecretsManagerBackend" | |
| assert secrets[0]["category"] == "secrets" | |
| def test_discovers_executor(self, tmp_path): | |
| import yaml | |
| provider_yaml = { | |
| "package-name": "apache-airflow-providers-amazon", | |
| "executors": [ | |
| "airflow.providers.amazon.aws.executors.ecs.EcsExecutor", | |
| ], | |
| } | |
| provider_dir = tmp_path / "amazon" | |
| provider_dir.mkdir() | |
| yaml_path = provider_dir / "provider.yaml" | |
| yaml_path.write_text(yaml.dump(provider_yaml)) | |
| class EcsExecutor: | |
| """ECS executor.""" | |
| mod = _make_module( | |
| "airflow.providers.amazon.aws.executors.ecs", | |
| {"EcsExecutor": EcsExecutor}, | |
| ) | |
| with ( | |
| patch("extract_parameters.PROVIDERS_DIR", tmp_path), | |
| patch("extract_parameters.importlib.import_module", return_value=mod), | |
| ): | |
| result = discover_classes_from_provider(yaml_path, {}) | |
| executors = [r for r in result if r["type"] == "executor"] | |
| assert len(executors) == 1 | |
| assert executors[0]["name"] == "EcsExecutor" | |
| assert executors[0]["category"] == "executors" | |
| def test_discovers_logging_handler(self, tmp_path): | |
| import yaml | |
| provider_yaml = { | |
| "package-name": "apache-airflow-providers-amazon", | |
| "logging": [ | |
| "airflow.providers.amazon.aws.log.s3.S3TaskHandler", | |
| ], | |
| } | |
| provider_dir = tmp_path / "amazon" | |
| provider_dir.mkdir() | |
| yaml_path = provider_dir / "provider.yaml" | |
| yaml_path.write_text(yaml.dump(provider_yaml)) | |
| class S3TaskHandler: | |
| """S3 task log handler.""" | |
| mod = _make_module( | |
| "airflow.providers.amazon.aws.log.s3", | |
| {"S3TaskHandler": S3TaskHandler}, | |
| ) | |
| with ( | |
| patch("extract_parameters.PROVIDERS_DIR", tmp_path), | |
| patch("extract_parameters.importlib.import_module", return_value=mod), | |
| ): | |
| result = discover_classes_from_provider(yaml_path, {}) | |
| logging_entries = [r for r in result if r["type"] == "logging"] | |
| assert len(logging_entries) == 1 | |
| assert logging_entries[0]["name"] == "S3TaskHandler" | |
| assert logging_entries[0]["category"] == "logging" | |
| # --------------------------------------------------------------------------- | |
| # TestCompareWithAst | |
| # --------------------------------------------------------------------------- | |
| class TestCompareWithAst: | |
| def test_detects_phantom_miss_and_mismatch(self, tmp_path): | |
| # AST modules.json has A, B, C | |
| ast_data = { | |
| "modules": [ | |
| { | |
| "import_path": "airflow.providers.x.A", | |
| "type": "operator", | |
| "name": "A", | |
| "provider_id": "x", | |
| }, | |
| { | |
| "import_path": "airflow.providers.x.B", | |
| "type": "hook", | |
| "name": "B", | |
| "provider_id": "x", | |
| }, | |
| { | |
| "import_path": "airflow.providers.x.C", | |
| "type": "operator", | |
| "name": "C", | |
| "provider_id": "x", | |
| }, | |
| ] | |
| } | |
| modules_json = tmp_path / "modules.json" | |
| modules_json.write_text(json.dumps(ast_data)) | |
| # Runtime has B (type mismatch), C (matches), D (AST miss) | |
| runtime = [ | |
| { | |
| "import_path": "airflow.providers.x.B", | |
| "type": "sensor", | |
| "name": "B", | |
| "provider_id": "x", | |
| }, | |
| { | |
| "import_path": "airflow.providers.x.C", | |
| "type": "operator", | |
| "name": "C", | |
| "provider_id": "x", | |
| }, | |
| { | |
| "import_path": "airflow.providers.x.D", | |
| "type": "trigger", | |
| "name": "D", | |
| "provider_id": "x", | |
| }, | |
| ] | |
| stats = compare_with_ast(runtime, modules_json) | |
| # A is in AST but not runtime = phantom | |
| assert stats["ast_phantoms"] == 1 | |
| assert "airflow.providers.x.A" in stats["phantom_paths"] | |
| # D is in runtime but not AST = miss | |
| assert stats["ast_misses"] == 1 | |
| assert "airflow.providers.x.D" in stats["miss_paths"] | |
| # B has type mismatch: AST=hook, runtime=sensor | |
| assert stats["type_mismatches"] == 1 | |
| assert stats["mismatch_details"][0]["ast_type"] == "hook" | |
| assert stats["mismatch_details"][0]["runtime_type"] == "sensor" | |
| def test_no_discrepancies_when_matching(self, tmp_path): | |
| ast_data = { | |
| "modules": [ | |
| { | |
| "import_path": "airflow.providers.x.A", | |
| "type": "operator", | |
| "name": "A", | |
| "provider_id": "x", | |
| }, | |
| ] | |
| } | |
| modules_json = tmp_path / "modules.json" | |
| modules_json.write_text(json.dumps(ast_data)) | |
| runtime = [ | |
| { | |
| "import_path": "airflow.providers.x.A", | |
| "type": "operator", | |
| "name": "A", | |
| "provider_id": "x", | |
| }, | |
| ] | |
| stats = compare_with_ast(runtime, modules_json) | |
| assert stats["ast_phantoms"] == 0 | |
| assert stats["ast_misses"] == 0 | |
| assert stats["type_mismatches"] == 0 | |
| def test_empty_ast_all_are_misses(self, tmp_path): | |
| ast_data = {"modules": []} | |
| modules_json = tmp_path / "modules.json" | |
| modules_json.write_text(json.dumps(ast_data)) | |
| runtime = [ | |
| { | |
| "import_path": "airflow.providers.x.A", | |
| "type": "operator", | |
| "name": "A", | |
| "provider_id": "x", | |
| }, | |
| ] | |
| stats = compare_with_ast(runtime, modules_json) | |
| assert stats["ast_phantoms"] == 0 | |
| assert stats["ast_misses"] == 1 | |
| def test_empty_runtime_all_are_phantoms(self, tmp_path): | |
| ast_data = { | |
| "modules": [ | |
| { | |
| "import_path": "airflow.providers.x.A", | |
| "type": "operator", | |
| "name": "A", | |
| "provider_id": "x", | |
| }, | |
| ] | |
| } | |
| modules_json = tmp_path / "modules.json" | |
| modules_json.write_text(json.dumps(ast_data)) | |
| stats = compare_with_ast([], modules_json) | |
| assert stats["ast_phantoms"] == 1 | |
| assert stats["ast_misses"] == 0 | |
| # --------------------------------------------------------------------------- | |
| # _parse_requested_providers | |
| # --------------------------------------------------------------------------- | |
| class TestParseRequestedProviders: | |
| def test_none_argument_returns_none(self): | |
| assert _parse_requested_providers(None) is None | |
| def test_empty_string_returns_none(self): | |
| assert _parse_requested_providers("") is None | |
| def test_whitespace_only_returns_empty_set(self): | |
| # Empty set is falsy, so downstream `if requested_providers` skips | |
| # the filter just like None does. | |
| assert _parse_requested_providers(" ") == set() | |
| def test_single_provider(self): | |
| assert _parse_requested_providers("amazon") == {"amazon"} | |
| def test_multiple_providers_space_separated(self): | |
| assert _parse_requested_providers("amazon google snowflake") == { | |
| "amazon", | |
| "google", | |
| "snowflake", | |
| } | |
| def test_extra_whitespace_is_tolerated(self): | |
| assert _parse_requested_providers(" amazon google ") == {"amazon", "google"} | |
| def test_duplicate_providers_collapsed(self): | |
| assert _parse_requested_providers("amazon amazon google") == {"amazon", "google"} | |
| class TestResolveDottedPath: | |
| @pytest.mark.parametrize( | |
| "class_path", | |
| ( | |
| "no_dot_here", | |
| "this.module.does.not.exist.at.all.Foo", | |
| ), | |
| ) | |
| def test__resolve_dotted_path_returns_none(self, class_path: str): | |
| assert _resolve_dotted_path(class_path) is None | |
| @pytest.mark.parametrize( | |
| ("class_path", "expected"), | |
| ( | |
| ( | |
| "extract_parameters.ThisAttrDoesNotExist", | |
| ("extract_parameters", "ThisAttrDoesNotExist", None), | |
| ), | |
| ( | |
| "extract_parameters.get_category", | |
| ("extract_parameters", "get_category", get_category), | |
| ), | |
| ), | |
| ) | |
| def test__resolve_dotted_path(self, class_path: str, expected: tuple[str, str, object]): | |
| assert _resolve_dotted_path(class_path) == expected |