From 05f7780295b1ec6ae3728a0dcedd8820090d2882 Mon Sep 17 00:00:00 2001 From: Jake Roach <116606359+jroachgolf84@users.noreply.github.com> Date: Fri, 8 May 2026 08:11:10 -0400 Subject: [PATCH 01/16] feature/aip-93-scoping: Passing Asset name/uri through to BaseEventTrigger --- .../airflow/executors/workloads/trigger.py | 4 ++++ .../src/airflow/jobs/triggerer_job_runner.py | 21 +++++++++++++++++++ airflow-core/src/airflow/triggers/base.py | 7 +++++++ 3 files changed, 32 insertions(+) diff --git a/airflow-core/src/airflow/executors/workloads/trigger.py b/airflow-core/src/airflow/executors/workloads/trigger.py index edde48f7f73f9..2a6c1ea2a855e 100644 --- a/airflow-core/src/airflow/executors/workloads/trigger.py +++ b/airflow-core/src/airflow/executors/workloads/trigger.py @@ -46,3 +46,7 @@ class RunTrigger(BaseModel): dag_run_data: dict | None = ( None # Serialized DagRun data in dict format so it can be deserialized in trigger subprocess. ) + + # aip-93 + watched_asset_name: str | None = None # Set for BaseEventTrigger asset watchers only. + watched_asset_uri: str | None = None # Set for BaseEventTrigger asset watchers only. diff --git a/airflow-core/src/airflow/jobs/triggerer_job_runner.py b/airflow-core/src/airflow/jobs/triggerer_job_runner.py index 660bba03c7236..31b32a5ad085a 100644 --- a/airflow-core/src/airflow/jobs/triggerer_job_runner.py +++ b/airflow-core/src/airflow/jobs/triggerer_job_runner.py @@ -760,11 +760,23 @@ def _create_workload( render_log_fname: Callable[..., str], session: Session, ) -> workloads.RunTrigger | None: + # aip-93: Why are we doing this? if trigger.task_instance is None: + asset_name: str | None = None + asset_uri: str | None = None + + # aip-93: Is it always going to be the first asset from the asset_watchers list? + if trigger.asset_watchers: + first_asset = trigger.asset_watchers[0].asset + asset_name = first_asset.name + asset_uri = first_asset.uri + return workloads.RunTrigger( id=trigger.id, classpath=trigger.classpath, encrypted_kwargs=trigger.encrypted_kwargs, + watched_asset_name=asset_name, + watched_asset_uri=asset_uri, ) if not trigger.task_instance.dag_version_id: @@ -1267,6 +1279,15 @@ async def create_triggers(self): trigger_instance.triggerer_job_id = self.job_id trigger_instance.timeout_after = workload.timeout_after + # aip-93 + if isinstance(trigger_instance, BaseEventTrigger) and workload.watched_asset_uri: + from airflow.sdk.definitions.asset import AssetUniqueKey + + trigger_instance.watched_asset = AssetUniqueKey( + name=workload.watched_asset_name, + uri=workload.watched_asset_uri, + ) + self.triggers[trigger_id] = { "task": asyncio.create_task( self.run_trigger(trigger_id, trigger_instance, workload.timeout_after, context), diff --git a/airflow-core/src/airflow/triggers/base.py b/airflow-core/src/airflow/triggers/base.py index f39b62facf7b2..bfa79a60d52cb 100644 --- a/airflow-core/src/airflow/triggers/base.py +++ b/airflow-core/src/airflow/triggers/base.py @@ -45,6 +45,7 @@ from airflow.models.mappedoperator import MappedOperator from airflow.models.taskinstance import TaskInstance + from airflow.sdk.definitions.asset import AssetUniqueKey from airflow.sdk.definitions.context import Context from airflow.serialization.serialized_objects import SerializedBaseOperator @@ -255,6 +256,12 @@ class BaseEventTrigger(BaseTrigger): supports_triggerer_queue: bool = False + def __init__(self, **kwargs): + super().__init__(**kwargs) + + # Injected by the triggerer before run() is called; mirrors how trigger_id is set + self.watched_asset: AssetUniqueKey | None = None + @staticmethod def hash(classpath: str, kwargs: dict[str, Any]) -> int: """ From 8788b3d11d01f3a13e7a6ecd46ab41f5726885a7 Mon Sep 17 00:00:00 2001 From: Jake Roach <116606359+jroachgolf84@users.noreply.github.com> Date: Fri, 8 May 2026 09:04:53 -0400 Subject: [PATCH 02/16] feature/aip-93-scoping: Removing comment --- airflow-core/src/airflow/jobs/triggerer_job_runner.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/airflow-core/src/airflow/jobs/triggerer_job_runner.py b/airflow-core/src/airflow/jobs/triggerer_job_runner.py index 31b32a5ad085a..0f08258629b61 100644 --- a/airflow-core/src/airflow/jobs/triggerer_job_runner.py +++ b/airflow-core/src/airflow/jobs/triggerer_job_runner.py @@ -760,7 +760,7 @@ def _create_workload( render_log_fname: Callable[..., str], session: Session, ) -> workloads.RunTrigger | None: - # aip-93: Why are we doing this? + # aip-93 if trigger.task_instance is None: asset_name: str | None = None asset_uri: str | None = None From e4c1db245950def036ee9066a7290cb44f8e916a Mon Sep 17 00:00:00 2001 From: Jake Roach <116606359+jroachgolf84@users.noreply.github.com> Date: Sun, 17 May 2026 13:08:16 -0400 Subject: [PATCH 03/16] feature/aip-93-scoping: Moving from single to multi-Asset --- .../airflow/executors/workloads/trigger.py | 5 ++-- .../src/airflow/jobs/triggerer_job_runner.py | 23 ++++++++----------- airflow-core/src/airflow/triggers/base.py | 2 +- 3 files changed, 12 insertions(+), 18 deletions(-) diff --git a/airflow-core/src/airflow/executors/workloads/trigger.py b/airflow-core/src/airflow/executors/workloads/trigger.py index 2a6c1ea2a855e..85c3d78cf506b 100644 --- a/airflow-core/src/airflow/executors/workloads/trigger.py +++ b/airflow-core/src/airflow/executors/workloads/trigger.py @@ -47,6 +47,5 @@ class RunTrigger(BaseModel): None # Serialized DagRun data in dict format so it can be deserialized in trigger subprocess. ) - # aip-93 - watched_asset_name: str | None = None # Set for BaseEventTrigger asset watchers only. - watched_asset_uri: str | None = None # Set for BaseEventTrigger asset watchers only. + # aip-93: name: uri of all "watched" Assets + watched_assets: dict[str, str] | None = None # Set for BaseEventTrigger asset watchers only diff --git a/airflow-core/src/airflow/jobs/triggerer_job_runner.py b/airflow-core/src/airflow/jobs/triggerer_job_runner.py index f39d9c9bd438a..cf94090765c50 100644 --- a/airflow-core/src/airflow/jobs/triggerer_job_runner.py +++ b/airflow-core/src/airflow/jobs/triggerer_job_runner.py @@ -774,21 +774,17 @@ def _create_workload( ) -> workloads.RunTrigger | None: # aip-93 if trigger.task_instance is None: - asset_name: str | None = None - asset_uri: str | None = None + watched_assets: dict[str, str] | None = None - # aip-93: Is it always going to be the first asset from the asset_watchers list? + # aip-93 if trigger.asset_watchers: - first_asset = trigger.asset_watchers[0].asset - asset_name = first_asset.name - asset_uri = first_asset.uri + watched_assets = {a.name: a.uri for a in trigger.assets} return workloads.RunTrigger( id=trigger.id, classpath=trigger.classpath, encrypted_kwargs=trigger.encrypted_kwargs, - watched_asset_name=asset_name, - watched_asset_uri=asset_uri, + watched_assets=watched_assets, ) if not trigger.task_instance.dag_version_id: @@ -1291,14 +1287,13 @@ async def create_triggers(self): trigger_instance.triggerer_job_id = self.job_id trigger_instance.timeout_after = workload.timeout_after - # aip-93 - if isinstance(trigger_instance, BaseEventTrigger) and workload.watched_asset_uri: + # aip-93: Pass through all watched assets from workload + if isinstance(trigger_instance, BaseEventTrigger) and workload.watched_assets: from airflow.sdk.definitions.asset import AssetUniqueKey - trigger_instance.watched_asset = AssetUniqueKey( - name=workload.watched_asset_name, - uri=workload.watched_asset_uri, - ) + trigger_instance.watched_assets = [ + AssetUniqueKey(name=name, uri=uri) for name, uri in workload.watched_assets.items() + ] self.triggers[trigger_id] = { "task": asyncio.create_task( diff --git a/airflow-core/src/airflow/triggers/base.py b/airflow-core/src/airflow/triggers/base.py index bfa79a60d52cb..9134269d52002 100644 --- a/airflow-core/src/airflow/triggers/base.py +++ b/airflow-core/src/airflow/triggers/base.py @@ -260,7 +260,7 @@ def __init__(self, **kwargs): super().__init__(**kwargs) # Injected by the triggerer before run() is called; mirrors how trigger_id is set - self.watched_asset: AssetUniqueKey | None = None + self.watched_assets: list[AssetUniqueKey] | None = None @staticmethod def hash(classpath: str, kwargs: dict[str, Any]) -> int: From ce953efbecb3513a1cce9f5cc2c35cff966465a6 Mon Sep 17 00:00:00 2001 From: Jake Roach <116606359+jroachgolf84@users.noreply.github.com> Date: Wed, 27 May 2026 08:01:41 -0400 Subject: [PATCH 04/16] feature/aip-93-scoping: Adding AssetStateAccessor --- airflow-core/src/airflow/jobs/triggerer_job_runner.py | 8 ++++++++ airflow-core/src/airflow/triggers/base.py | 2 ++ 2 files changed, 10 insertions(+) diff --git a/airflow-core/src/airflow/jobs/triggerer_job_runner.py b/airflow-core/src/airflow/jobs/triggerer_job_runner.py index 4faf3dd2d8392..7e602aaaa40eb 100644 --- a/airflow-core/src/airflow/jobs/triggerer_job_runner.py +++ b/airflow-core/src/airflow/jobs/triggerer_job_runner.py @@ -1270,6 +1270,14 @@ async def create_triggers(self): AssetUniqueKey(name=name, uri=uri) for name, uri in workload.watched_assets.items() ] + # aip-103: Reconstruct AssetStateAccessors from watched_assets + from airflow.sdk.definitions.asset import Asset + from airflow.sdk.execution_time.context import AssetStateAccessors + + trigger_instance.asset_states = AssetStateAccessors( + inlets=[Asset(name=name, uri=uri) for name, uri in workload.watched_assets.items()] + ) + self.triggers[trigger_id] = { "task": asyncio.create_task( self.run_trigger(trigger_id, trigger_instance, workload.timeout_after, context), diff --git a/airflow-core/src/airflow/triggers/base.py b/airflow-core/src/airflow/triggers/base.py index f6e45c13e2a69..b612c0556e3da 100644 --- a/airflow-core/src/airflow/triggers/base.py +++ b/airflow-core/src/airflow/triggers/base.py @@ -33,6 +33,7 @@ ) from airflow.sdk.definitions._internal.templater import Templater +from airflow.sdk.execution_time.context import AssetStateAccessors from airflow.utils.log.logging_mixin import LoggingMixin from airflow.utils.state import TaskInstanceState @@ -303,6 +304,7 @@ def __init__(self, **kwargs): # Injected by the triggerer before run() is called; mirrors how trigger_id is set self.watched_assets: list[AssetUniqueKey] | None = None + self.asset_states: AssetStateAccessors | None = None @staticmethod def hash(classpath: str, kwargs: dict[str, Any]) -> int: From 7be572d89d7f36ab2be8ba0f048989f643873a62 Mon Sep 17 00:00:00 2001 From: Jake Roach <116606359+jroachgolf84@users.noreply.github.com> Date: Wed, 27 May 2026 09:50:47 -0400 Subject: [PATCH 05/16] feature/aip-93-scoping: Adding comment --- airflow-core/src/airflow/jobs/triggerer_job_runner.py | 1 + 1 file changed, 1 insertion(+) diff --git a/airflow-core/src/airflow/jobs/triggerer_job_runner.py b/airflow-core/src/airflow/jobs/triggerer_job_runner.py index 7e602aaaa40eb..87850ff5dbbe6 100644 --- a/airflow-core/src/airflow/jobs/triggerer_job_runner.py +++ b/airflow-core/src/airflow/jobs/triggerer_job_runner.py @@ -1274,6 +1274,7 @@ async def create_triggers(self): from airflow.sdk.definitions.asset import Asset from airflow.sdk.execution_time.context import AssetStateAccessors + # Potentially address Asset vs. AssetRef, AssetUriRef, etc. trigger_instance.asset_states = AssetStateAccessors( inlets=[Asset(name=name, uri=uri) for name, uri in workload.watched_assets.items()] ) From 819795f884b1e0c8bd0466af571606707410152a Mon Sep 17 00:00:00 2001 From: Jake Roach <116606359+jroachgolf84@users.noreply.github.com> Date: Wed, 27 May 2026 14:35:00 -0400 Subject: [PATCH 06/16] feature/aip-93-scoping: Adding comment to address potential change --- airflow-core/src/airflow/jobs/triggerer_job_runner.py | 1 + 1 file changed, 1 insertion(+) diff --git a/airflow-core/src/airflow/jobs/triggerer_job_runner.py b/airflow-core/src/airflow/jobs/triggerer_job_runner.py index 87850ff5dbbe6..d9bf95ff27feb 100644 --- a/airflow-core/src/airflow/jobs/triggerer_job_runner.py +++ b/airflow-core/src/airflow/jobs/triggerer_job_runner.py @@ -1266,6 +1266,7 @@ async def create_triggers(self): if isinstance(trigger_instance, BaseEventTrigger) and workload.watched_assets: from airflow.sdk.definitions.asset import AssetUniqueKey + # If we only want asset_states, we can just remove this line! trigger_instance.watched_assets = [ AssetUniqueKey(name=name, uri=uri) for name, uri in workload.watched_assets.items() ] From 788acb3ea74a00164acdf79470ec08701554935b Mon Sep 17 00:00:00 2001 From: Jake Roach <116606359+jroachgolf84@users.noreply.github.com> Date: Thu, 28 May 2026 08:46:01 -0400 Subject: [PATCH 07/16] feature/aip-93-scoping: Updating asset_states -> asset_state to match context['asset_state'] --- airflow-core/src/airflow/jobs/triggerer_job_runner.py | 4 ++-- airflow-core/src/airflow/triggers/base.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/airflow-core/src/airflow/jobs/triggerer_job_runner.py b/airflow-core/src/airflow/jobs/triggerer_job_runner.py index f5513118685d8..f6238999eca5a 100644 --- a/airflow-core/src/airflow/jobs/triggerer_job_runner.py +++ b/airflow-core/src/airflow/jobs/triggerer_job_runner.py @@ -1274,7 +1274,7 @@ async def create_triggers(self): if isinstance(trigger_instance, BaseEventTrigger) and workload.watched_assets: from airflow.sdk.definitions.asset import AssetUniqueKey - # If we only want asset_states, we can just remove this line! + # If we only want asset_state, we can just remove this line! trigger_instance.watched_assets = [ AssetUniqueKey(name=name, uri=uri) for name, uri in workload.watched_assets.items() ] @@ -1284,7 +1284,7 @@ async def create_triggers(self): from airflow.sdk.execution_time.context import AssetStateAccessors # Potentially address Asset vs. AssetRef, AssetUriRef, etc. - trigger_instance.asset_states = AssetStateAccessors( + trigger_instance.asset_state = AssetStateAccessors( inlets=[Asset(name=name, uri=uri) for name, uri in workload.watched_assets.items()] ) diff --git a/airflow-core/src/airflow/triggers/base.py b/airflow-core/src/airflow/triggers/base.py index b612c0556e3da..7553fe9a110fb 100644 --- a/airflow-core/src/airflow/triggers/base.py +++ b/airflow-core/src/airflow/triggers/base.py @@ -304,7 +304,7 @@ def __init__(self, **kwargs): # Injected by the triggerer before run() is called; mirrors how trigger_id is set self.watched_assets: list[AssetUniqueKey] | None = None - self.asset_states: AssetStateAccessors | None = None + self.asset_state: AssetStateAccessors | None = None @staticmethod def hash(classpath: str, kwargs: dict[str, Any]) -> int: From b7cf568ea5dced9e73b9b9011610c6d499b833cc Mon Sep 17 00:00:00 2001 From: Jake Roach <116606359+jroachgolf84@users.noreply.github.com> Date: Sat, 30 May 2026 08:15:26 -0400 Subject: [PATCH 08/16] feature/aip-93-scoping: Only passing asset_state through to BaseEventTrigger --- .../src/airflow/executors/workloads/trigger.py | 2 +- .../src/airflow/jobs/triggerer_job_runner.py | 13 ++----------- airflow-core/src/airflow/triggers/base.py | 2 -- 3 files changed, 3 insertions(+), 14 deletions(-) diff --git a/airflow-core/src/airflow/executors/workloads/trigger.py b/airflow-core/src/airflow/executors/workloads/trigger.py index 85c3d78cf506b..d3b2d0627a7ec 100644 --- a/airflow-core/src/airflow/executors/workloads/trigger.py +++ b/airflow-core/src/airflow/executors/workloads/trigger.py @@ -47,5 +47,5 @@ class RunTrigger(BaseModel): None # Serialized DagRun data in dict format so it can be deserialized in trigger subprocess. ) - # aip-93: name: uri of all "watched" Assets + # name: uri of all "watched" Assets watched_assets: dict[str, str] | None = None # Set for BaseEventTrigger asset watchers only diff --git a/airflow-core/src/airflow/jobs/triggerer_job_runner.py b/airflow-core/src/airflow/jobs/triggerer_job_runner.py index f6238999eca5a..72e6cfb3dd946 100644 --- a/airflow-core/src/airflow/jobs/triggerer_job_runner.py +++ b/airflow-core/src/airflow/jobs/triggerer_job_runner.py @@ -737,11 +737,10 @@ def _create_workload( render_log_fname: Callable[..., str], session: Session, ) -> workloads.RunTrigger | None: - # aip-93 + # Pass the "watched" Assets through for downstream use in BaseEventTrigger if trigger.task_instance is None: watched_assets: dict[str, str] | None = None - # aip-93 if trigger.asset_watchers: watched_assets = {a.name: a.uri for a in trigger.assets} @@ -1270,16 +1269,8 @@ async def create_triggers(self): trigger_instance.triggerer_job_id = self.job_id trigger_instance.timeout_after = workload.timeout_after - # aip-93: Pass through all watched assets from workload if isinstance(trigger_instance, BaseEventTrigger) and workload.watched_assets: - from airflow.sdk.definitions.asset import AssetUniqueKey - - # If we only want asset_state, we can just remove this line! - trigger_instance.watched_assets = [ - AssetUniqueKey(name=name, uri=uri) for name, uri in workload.watched_assets.items() - ] - - # aip-103: Reconstruct AssetStateAccessors from watched_assets + # Reconstruct AssetStateAccessors from watched_assets from airflow.sdk.definitions.asset import Asset from airflow.sdk.execution_time.context import AssetStateAccessors diff --git a/airflow-core/src/airflow/triggers/base.py b/airflow-core/src/airflow/triggers/base.py index 7553fe9a110fb..124232e7d4097 100644 --- a/airflow-core/src/airflow/triggers/base.py +++ b/airflow-core/src/airflow/triggers/base.py @@ -46,7 +46,6 @@ from airflow.models.mappedoperator import MappedOperator from airflow.models.taskinstance import TaskInstance - from airflow.sdk.definitions.asset import AssetUniqueKey from airflow.sdk.definitions.context import Context from airflow.serialization.serialized_objects import SerializedBaseOperator @@ -303,7 +302,6 @@ def __init__(self, **kwargs): super().__init__(**kwargs) # Injected by the triggerer before run() is called; mirrors how trigger_id is set - self.watched_assets: list[AssetUniqueKey] | None = None self.asset_state: AssetStateAccessors | None = None @staticmethod From ec3590c20c28c2c29bb56d0cf088dd49d4c3ee51 Mon Sep 17 00:00:00 2001 From: Jake Roach <116606359+jroachgolf84@users.noreply.github.com> Date: Tue, 2 Jun 2026 15:30:02 -0400 Subject: [PATCH 09/16] feature/expore-asset-state-accessor: Implementing feedback --- airflow-core/src/airflow/jobs/triggerer_job_runner.py | 6 +++--- airflow-core/src/airflow/triggers/base.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/airflow-core/src/airflow/jobs/triggerer_job_runner.py b/airflow-core/src/airflow/jobs/triggerer_job_runner.py index f959fe3d3d17b..58b1b1741f69e 100644 --- a/airflow-core/src/airflow/jobs/triggerer_job_runner.py +++ b/airflow-core/src/airflow/jobs/triggerer_job_runner.py @@ -745,7 +745,7 @@ def _create_workload( if trigger.task_instance is None: watched_assets: dict[str, str] | None = None - if trigger.asset_watchers: + if trigger.assets: watched_assets = {a.name: a.uri for a in trigger.assets} return workloads.RunTrigger( @@ -1276,10 +1276,10 @@ async def create_triggers(self): if isinstance(trigger_instance, BaseEventTrigger) and workload.watched_assets: # Reconstruct AssetStateAccessors from watched_assets from airflow.sdk.definitions.asset import Asset - from airflow.sdk.execution_time.context import AssetStateAccessors + from airflow.sdk.execution_time.context import AssetStoreAccessors # Potentially address Asset vs. AssetRef, AssetUriRef, etc. - trigger_instance.asset_state = AssetStateAccessors( + trigger_instance.asset_store = AssetStoreAccessors( inlets=[Asset(name=name, uri=uri) for name, uri in workload.watched_assets.items()] ) diff --git a/airflow-core/src/airflow/triggers/base.py b/airflow-core/src/airflow/triggers/base.py index 124232e7d4097..0a47f6247d374 100644 --- a/airflow-core/src/airflow/triggers/base.py +++ b/airflow-core/src/airflow/triggers/base.py @@ -33,7 +33,6 @@ ) from airflow.sdk.definitions._internal.templater import Templater -from airflow.sdk.execution_time.context import AssetStateAccessors from airflow.utils.log.logging_mixin import LoggingMixin from airflow.utils.state import TaskInstanceState @@ -47,6 +46,7 @@ from airflow.models.mappedoperator import MappedOperator from airflow.models.taskinstance import TaskInstance from airflow.sdk.definitions.context import Context + from airflow.sdk.execution_time.context import AssetStoreAccessors from airflow.serialization.serialized_objects import SerializedBaseOperator Operator: TypeAlias = MappedOperator | SerializedBaseOperator @@ -302,7 +302,7 @@ def __init__(self, **kwargs): super().__init__(**kwargs) # Injected by the triggerer before run() is called; mirrors how trigger_id is set - self.asset_state: AssetStateAccessors | None = None + self.asset_store: AssetStoreAccessors | None = None @staticmethod def hash(classpath: str, kwargs: dict[str, Any]) -> int: From feb98b021f7db02f0c8bfbee211ccdf20dc87044 Mon Sep 17 00:00:00 2001 From: Jake Roach <116606359+jroachgolf84@users.noreply.github.com> Date: Fri, 5 Jun 2026 16:35:42 -0400 Subject: [PATCH 10/16] feature/expose-asset-state-accessor: Adding unit-tests --- .../tests/unit/jobs/test_triggerer_job.py | 242 ++++++++++++++++++ .../tests/unit/triggers/test_base_trigger.py | 24 ++ 2 files changed, 266 insertions(+) diff --git a/airflow-core/tests/unit/jobs/test_triggerer_job.py b/airflow-core/tests/unit/jobs/test_triggerer_job.py index eab9540df3cb9..2336e02e6a886 100644 --- a/airflow-core/tests/unit/jobs/test_triggerer_job.py +++ b/airflow-core/tests/unit/jobs/test_triggerer_job.py @@ -558,6 +558,248 @@ def test_create_workload_uses_supervisor_id_without_job(jobless_supervisor, mock assert factory.log_path == f"/logs/ti.trigger.{jobless_supervisor.id}.log" +def test_create_workload_sets_watched_assets_for_asset_only_trigger(jobless_supervisor, mocker): + """_create_workload() should populate watched_assets when trigger.task_instance is None and assets exist.""" + asset1 = mocker.Mock() + asset1.name = "my_asset" + asset1.uri = "s3://bucket/key" + + asset2 = mocker.Mock() + asset2.name = "other_asset" + asset2.uri = "gs://bucket/path" + + trigger = mocker.Mock() + trigger.id = 42 + trigger.classpath = "some.path.Trigger" + trigger.encrypted_kwargs = "encrypted" + trigger.task_instance = None # Not tied to a Task (similar to a BaseEventTrigger) + trigger.assets = [asset1, asset2] + + workload = jobless_supervisor._create_workload( + trigger=trigger, + dag_bag=mocker.Mock(), + render_log_fname=mocker.Mock(), + session=mocker.Mock(), + ) + + assert workload is not None + assert workload.watched_assets == {"my_asset": "s3://bucket/key", "other_asset": "gs://bucket/path"} + + +def test_create_workload_watched_assets_none_when_no_assets(jobless_supervisor, mocker): + """_create_workload() should set watched_assets=None when trigger.task_instance is None and assets is empty.""" + trigger = mocker.Mock() + trigger.id = 43 + trigger.classpath = "some.path.Trigger" + trigger.encrypted_kwargs = "encrypted" + trigger.task_instance = None + trigger.assets = [] # No Assets are attached to the trigger + + workload = jobless_supervisor._create_workload( + trigger=trigger, + dag_bag=mocker.Mock(), + render_log_fname=mocker.Mock(), + session=mocker.Mock(), + ) + + assert workload is not None + assert workload.watched_assets is None + + +def test_run_trigger_workload_includes_watched_assets_field(): + """RunTrigger workload should accept and store watched_assets.""" + from airflow.executors.workloads.trigger import RunTrigger + + workload = RunTrigger( + id=1, + classpath="airflow.triggers.testing.SuccessTrigger", + encrypted_kwargs="fake", + watched_assets={"asset_a": "s3://a", "asset_b": "gs://b"}, + ) + assert workload.watched_assets == {"asset_a": "s3://a", "asset_b": "gs://b"} + + +def test_run_trigger_workload_watched_assets_defaults_to_none(): + """RunTrigger workload watched_assets should default to None.""" + from airflow.executors.workloads.trigger import RunTrigger + + workload = RunTrigger( + id=1, + classpath="airflow.triggers.testing.SuccessTrigger", + encrypted_kwargs="fake", + ) + assert workload.watched_assets is None + + +@pytest.mark.asyncio +@patch("airflow.jobs.triggerer_job_runner.Trigger._decrypt_kwargs", return_value={}) +@patch("airflow.jobs.triggerer_job_runner.TriggerRunner.get_trigger_by_classpath") +async def test_create_triggers_injects_asset_store_for_base_event_trigger( + mock_get_classpath, mock_decrypt, session +): + """asset_store is populated on BaseEventTrigger instances when watched_assets is set.""" + from airflow.sdk.execution_time.context import AssetStoreAccessors + from airflow.triggers.base import BaseEventTrigger, TriggerEvent + + injected_instances = [] + + class _WatcherTrigger(BaseEventTrigger): + def __init__(self, **kwargs): + super().__init__(**kwargs) + injected_instances.append(self) + + def serialize(self): + return (f"{type(self).__module__}.{type(self).__qualname__}", {}) + + async def run(self): + yield TriggerEvent("done") + + mock_get_classpath.return_value = _WatcherTrigger + + runner = TriggerRunner() + runner.to_create.append( + workloads.RunTrigger.model_construct( + id=10, + ti=None, + classpath="fake.WatcherTrigger", + encrypted_kwargs="fake", + watched_assets={"my_asset": "s3://bucket/key"}, + ) + ) + + await runner.create_triggers() + + # This is only testing that an exception was NOT thrown when creating the Trigger + assert 10 in runner.triggers + + assert len(injected_instances) == 1 + assert injected_instances[0].asset_store is not None + assert isinstance(injected_instances[0].asset_store, AssetStoreAccessors) + + runner.triggers[10]["task"].cancel() + await runner.cleanup_finished_triggers() + + +@pytest.mark.asyncio +@patch("airflow.jobs.triggerer_job_runner.Trigger._decrypt_kwargs", return_value={}) +@patch("airflow.jobs.triggerer_job_runner.TriggerRunner.get_trigger_by_classpath") +async def test_create_triggers_asset_store_none_when_no_watched_assets( + mock_get_classpath, mock_decrypt, session +): + """asset_store stays None when watched_assets is not set on the workload.""" + from airflow.triggers.base import BaseEventTrigger, TriggerEvent + + injected_instances = [] + + class _WatcherTrigger(BaseEventTrigger): + def __init__(self, **kwargs): + super().__init__(**kwargs) + injected_instances.append(self) + + def serialize(self): + return (f"{type(self).__module__}.{type(self).__qualname__}", {}) + + async def run(self): + yield TriggerEvent("done") + + mock_get_classpath.return_value = _WatcherTrigger + + runner = TriggerRunner() + runner.to_create.append( + workloads.RunTrigger.model_construct( + id=11, + ti=None, + classpath="fake.WatcherTrigger", + encrypted_kwargs="fake", + watched_assets=None, + ) + ) + + await runner.create_triggers() + + assert len(injected_instances) == 1 + assert injected_instances[0].asset_store is None + + runner.triggers[11]["task"].cancel() + await runner.cleanup_finished_triggers() + + +@pytest.mark.asyncio +@patch("airflow.jobs.triggerer_job_runner.Trigger._decrypt_kwargs", return_value={}) +@patch("airflow.jobs.triggerer_job_runner.TriggerRunner.get_trigger_by_classpath") +async def test_create_triggers_skips_asset_store_for_non_event_trigger( + mock_get_classpath, mock_decrypt, session +): + """asset_store injection is skipped for plain BaseTrigger (non-BaseEventTrigger) instances.""" + from airflow.triggers.testing import SuccessTrigger + + mock_get_classpath.return_value = SuccessTrigger + + runner = TriggerRunner() + runner.to_create.append( + workloads.RunTrigger.model_construct( + id=12, ti=None, classpath="airflow.triggers.testing.SuccessTrigger", encrypted_kwargs="fake" + ) + ) + + await runner.create_triggers() + + assert 12 in runner.triggers + assert not hasattr(runner.triggers[12]["task"], "asset_store") + + runner.triggers[12]["task"].cancel() + await runner.cleanup_finished_triggers() + + +@pytest.mark.asyncio +@patch("airflow.jobs.triggerer_job_runner.Trigger._decrypt_kwargs", return_value={}) +@patch("airflow.jobs.triggerer_job_runner.TriggerRunner.get_trigger_by_classpath") +async def test_create_triggers_asset_store_contains_correct_assets(mock_get_classpath, mock_decrypt, session): + """AssetStoreAccessors built from watched_assets has entries for all provided name/URI pairs.""" + from airflow.sdk.definitions.asset import Asset + from airflow.sdk.execution_time.context import AssetStoreAccessors + from airflow.triggers.base import BaseEventTrigger, TriggerEvent + + injected_instances = [] + + class _WatcherTrigger(BaseEventTrigger): + def __init__(self, **kwargs): + super().__init__(**kwargs) + injected_instances.append(self) + + def serialize(self): + return (f"{type(self).__module__}.{type(self).__qualname__}", {}) + + async def run(self): + yield TriggerEvent("done") + + mock_get_classpath.return_value = _WatcherTrigger + + runner = TriggerRunner() + runner.to_create.append( + workloads.RunTrigger.model_construct( + id=13, + ti=None, + classpath="fake.WatcherTrigger", + encrypted_kwargs="fake", + watched_assets={"asset_a": "s3://bucket/a", "asset_b": "gs://bucket/b"}, + ) + ) + + await runner.create_triggers() + + assert len(injected_instances) == 1 + store = injected_instances[0].asset_store + + assert store is not None + assert isinstance(store, AssetStoreAccessors) + assert store[Asset(name="asset_a", uri="s3://bucket/a")] is not None + assert store[Asset(name="asset_b", uri="gs://bucket/b")] is not None + + runner.triggers[13]["task"].cancel() + await runner.cleanup_finished_triggers() + + def test_trigger_lifecycle(spy_agency: SpyAgency, session, testing_dag_bundle): """ Checks that the triggerer will correctly see a new Trigger in the database diff --git a/airflow-core/tests/unit/triggers/test_base_trigger.py b/airflow-core/tests/unit/triggers/test_base_trigger.py index 8e5690a3ef991..09643f3a08710 100644 --- a/airflow-core/tests/unit/triggers/test_base_trigger.py +++ b/airflow-core/tests/unit/triggers/test_base_trigger.py @@ -17,6 +17,8 @@ # under the License. from __future__ import annotations +from unittest.mock import MagicMock + import pytest from airflow.sdk.bases.operator import BaseOperator @@ -232,3 +234,25 @@ async def stream(): payloads = [event.payload async for event in us.filter_shared_stream(stream())] assert [p["region"] for p in payloads] == ["us", "us"] + + +def test_base_event_trigger_asset_store_initialized_to_none(): + """asset_store is None before it is set.""" + trigger = _PlainEventTrigger() + assert trigger.asset_store is None + + +def test_base_event_trigger_asset_store_can_be_set(): + """asset_store can be set once the Trigger is initialized.""" + trigger = _PlainEventTrigger() + mock_store = MagicMock() + trigger.asset_store = mock_store + assert trigger.asset_store is mock_store + + +def test_base_event_trigger_asset_store_independent_across_instances(): + """a.asset_store does not impact b.asset_store.""" + a = _PlainEventTrigger(name="a") + b = _PlainEventTrigger(name="b") + a.asset_store = MagicMock() + assert b.asset_store is None From 10b3b1727a426996bd14a95dfdc6231a3faa0cdb Mon Sep 17 00:00:00 2001 From: Jake Roach <116606359+jroachgolf84@users.noreply.github.com> Date: Thu, 11 Jun 2026 14:00:27 -0400 Subject: [PATCH 11/16] feature/expose-asset-state-accessor: Resolving comments from @amoghrajesh --- .../src/airflow/jobs/triggerer_job_runner.py | 7 +- .../tests/unit/jobs/test_triggerer_job.py | 167 ++++++++++-------- generated/provider_dependencies.json | 3 + .../provider_dependencies.json.sha256sum | 2 +- 4 files changed, 98 insertions(+), 81 deletions(-) diff --git a/airflow-core/src/airflow/jobs/triggerer_job_runner.py b/airflow-core/src/airflow/jobs/triggerer_job_runner.py index 67c8cadb34cd4..6285ffb90a5eb 100644 --- a/airflow-core/src/airflow/jobs/triggerer_job_runner.py +++ b/airflow-core/src/airflow/jobs/triggerer_job_runner.py @@ -58,6 +58,7 @@ from airflow.models.trigger import Trigger from airflow.observability.metrics import stats_utils from airflow.sdk.api.datamodels._generated import HITLDetailResponse +from airflow.sdk.definitions.asset import Asset from airflow.sdk.execution_time.comms import ( CommsDecoder, ConnectionResult, @@ -89,6 +90,7 @@ _new_encoder, _RequestFrame, ) +from airflow.sdk.execution_time.context import AssetStoreAccessors from airflow.sdk.execution_time.request_handlers import ( handle_delete_variable, handle_delete_xcom, @@ -1294,11 +1296,6 @@ async def create_triggers(self): trigger_instance.timeout_after = workload.timeout_after if isinstance(trigger_instance, BaseEventTrigger) and workload.watched_assets: - # Reconstruct AssetStateAccessors from watched_assets - from airflow.sdk.definitions.asset import Asset - from airflow.sdk.execution_time.context import AssetStoreAccessors - - # Potentially address Asset vs. AssetRef, AssetUriRef, etc. trigger_instance.asset_store = AssetStoreAccessors( inlets=[Asset(name=name, uri=uri) for name, uri in workload.watched_assets.items()] ) diff --git a/airflow-core/tests/unit/jobs/test_triggerer_job.py b/airflow-core/tests/unit/jobs/test_triggerer_job.py index d1a1b4cbae57c..215e864c72d50 100644 --- a/airflow-core/tests/unit/jobs/test_triggerer_job.py +++ b/airflow-core/tests/unit/jobs/test_triggerer_job.py @@ -50,6 +50,7 @@ from airflow._shared.timezones import timezone from airflow.executors import workloads from airflow.executors.workloads.task import TaskInstanceDTO +from airflow.executors.workloads.trigger import RunTrigger from airflow.jobs.job import Job from airflow.jobs.triggerer_job_runner import ( _USER_ACTION_CANCEL_MSG, @@ -72,10 +73,19 @@ from airflow.providers.standard.operators.python import PythonOperator from airflow.providers.standard.triggers.file import FileDeleteTrigger from airflow.providers.standard.triggers.temporal import DateTimeTrigger, TimeDeltaTrigger -from airflow.sdk import DAG, BaseHook, BaseOperator -from airflow.sdk.execution_time.comms import ToSupervisor, ToTask, _RequestFrame, _ResponseFrame +from airflow.sdk import DAG, Asset, BaseHook, BaseOperator +from airflow.sdk.execution_time.comms import ( + AssetStoreResult, + GetAssetStoreByName, + SetAssetStoreByName, + ToSupervisor, + ToTask, + _RequestFrame, + _ResponseFrame, +) +from airflow.sdk.execution_time.context import AssetStoreAccessors from airflow.serialization.serialized_objects import LazyDeserializedDAG -from airflow.triggers.base import BaseTrigger, TriggerEvent +from airflow.triggers.base import BaseEventTrigger, BaseTrigger, TriggerEvent from airflow.triggers.testing import FailureTrigger, SuccessTrigger from airflow.utils.state import State, TaskInstanceState from airflow.utils.types import DagRunType @@ -560,15 +570,15 @@ def test_create_workload_uses_supervisor_id_without_job(jobless_supervisor, mock def test_create_workload_sets_watched_assets_for_asset_only_trigger(jobless_supervisor, mocker): """_create_workload() should populate watched_assets when trigger.task_instance is None and assets exist.""" - asset1 = mocker.Mock() + asset1 = mocker.Mock(spec=Asset) asset1.name = "my_asset" asset1.uri = "s3://bucket/key" - asset2 = mocker.Mock() + asset2 = mocker.Mock(spec=Asset) asset2.name = "other_asset" asset2.uri = "gs://bucket/path" - trigger = mocker.Mock() + trigger = mocker.Mock(spec=BaseEventTrigger) trigger.id = 42 trigger.classpath = "some.path.Trigger" trigger.encrypted_kwargs = "encrypted" @@ -588,7 +598,7 @@ def test_create_workload_sets_watched_assets_for_asset_only_trigger(jobless_supe def test_create_workload_watched_assets_none_when_no_assets(jobless_supervisor, mocker): """_create_workload() should set watched_assets=None when trigger.task_instance is None and assets is empty.""" - trigger = mocker.Mock() + trigger = mocker.Mock(spec=BaseEventTrigger) trigger.id = 43 trigger.classpath = "some.path.Trigger" trigger.encrypted_kwargs = "encrypted" @@ -608,8 +618,6 @@ def test_create_workload_watched_assets_none_when_no_assets(jobless_supervisor, def test_run_trigger_workload_includes_watched_assets_field(): """RunTrigger workload should accept and store watched_assets.""" - from airflow.executors.workloads.trigger import RunTrigger - workload = RunTrigger( id=1, classpath="airflow.triggers.testing.SuccessTrigger", @@ -621,8 +629,6 @@ def test_run_trigger_workload_includes_watched_assets_field(): def test_run_trigger_workload_watched_assets_defaults_to_none(): """RunTrigger workload watched_assets should default to None.""" - from airflow.executors.workloads.trigger import RunTrigger - workload = RunTrigger( id=1, classpath="airflow.triggers.testing.SuccessTrigger", @@ -631,30 +637,35 @@ def test_run_trigger_workload_watched_assets_defaults_to_none(): assert workload.watched_assets is None +@pytest.fixture +def make_watcher_trigger(): + """Factory fixture: call with a list to get a BaseEventTrigger subclass that appends each new instance.""" + + def factory(injected_instances): + class WatcherTrigger(BaseEventTrigger): + def __init__(self, **kwargs): + super().__init__(**kwargs) + injected_instances.append(self) + + def serialize(self): + return (f"{type(self).__module__}.{type(self).__qualname__}", {}) + + async def run(self): + yield TriggerEvent("done") + + return WatcherTrigger + + return factory + + @pytest.mark.asyncio -@patch("airflow.jobs.triggerer_job_runner.Trigger._decrypt_kwargs", return_value={}) @patch("airflow.jobs.triggerer_job_runner.TriggerRunner.get_trigger_by_classpath") async def test_create_triggers_injects_asset_store_for_base_event_trigger( - mock_get_classpath, mock_decrypt, session + mock_get_classpath, session, make_watcher_trigger ): """asset_store is populated on BaseEventTrigger instances when watched_assets is set.""" - from airflow.sdk.execution_time.context import AssetStoreAccessors - from airflow.triggers.base import BaseEventTrigger, TriggerEvent - injected_instances = [] - - class _WatcherTrigger(BaseEventTrigger): - def __init__(self, **kwargs): - super().__init__(**kwargs) - injected_instances.append(self) - - def serialize(self): - return (f"{type(self).__module__}.{type(self).__qualname__}", {}) - - async def run(self): - yield TriggerEvent("done") - - mock_get_classpath.return_value = _WatcherTrigger + mock_get_classpath.return_value = make_watcher_trigger(injected_instances) runner = TriggerRunner() runner.to_create.append( @@ -662,7 +673,7 @@ async def run(self): id=10, ti=None, classpath="fake.WatcherTrigger", - encrypted_kwargs="fake", + encrypted_kwargs="{}", watched_assets={"my_asset": "s3://bucket/key"}, ) ) @@ -681,28 +692,13 @@ async def run(self): @pytest.mark.asyncio -@patch("airflow.jobs.triggerer_job_runner.Trigger._decrypt_kwargs", return_value={}) @patch("airflow.jobs.triggerer_job_runner.TriggerRunner.get_trigger_by_classpath") async def test_create_triggers_asset_store_none_when_no_watched_assets( - mock_get_classpath, mock_decrypt, session + mock_get_classpath, session, make_watcher_trigger ): """asset_store stays None when watched_assets is not set on the workload.""" - from airflow.triggers.base import BaseEventTrigger, TriggerEvent - injected_instances = [] - - class _WatcherTrigger(BaseEventTrigger): - def __init__(self, **kwargs): - super().__init__(**kwargs) - injected_instances.append(self) - - def serialize(self): - return (f"{type(self).__module__}.{type(self).__qualname__}", {}) - - async def run(self): - yield TriggerEvent("done") - - mock_get_classpath.return_value = _WatcherTrigger + mock_get_classpath.return_value = make_watcher_trigger(injected_instances) runner = TriggerRunner() runner.to_create.append( @@ -710,7 +706,7 @@ async def run(self): id=11, ti=None, classpath="fake.WatcherTrigger", - encrypted_kwargs="fake", + encrypted_kwargs="{}", watched_assets=None, ) ) @@ -725,20 +721,15 @@ async def run(self): @pytest.mark.asyncio -@patch("airflow.jobs.triggerer_job_runner.Trigger._decrypt_kwargs", return_value={}) @patch("airflow.jobs.triggerer_job_runner.TriggerRunner.get_trigger_by_classpath") -async def test_create_triggers_skips_asset_store_for_non_event_trigger( - mock_get_classpath, mock_decrypt, session -): +async def test_create_triggers_skips_asset_store_for_non_event_trigger(mock_get_classpath, session): """asset_store injection is skipped for plain BaseTrigger (non-BaseEventTrigger) instances.""" - from airflow.triggers.testing import SuccessTrigger - mock_get_classpath.return_value = SuccessTrigger runner = TriggerRunner() runner.to_create.append( workloads.RunTrigger.model_construct( - id=12, ti=None, classpath="airflow.triggers.testing.SuccessTrigger", encrypted_kwargs="fake" + id=12, ti=None, classpath="airflow.triggers.testing.SuccessTrigger", encrypted_kwargs="{}" ) ) @@ -752,28 +743,13 @@ async def test_create_triggers_skips_asset_store_for_non_event_trigger( @pytest.mark.asyncio -@patch("airflow.jobs.triggerer_job_runner.Trigger._decrypt_kwargs", return_value={}) @patch("airflow.jobs.triggerer_job_runner.TriggerRunner.get_trigger_by_classpath") -async def test_create_triggers_asset_store_contains_correct_assets(mock_get_classpath, mock_decrypt, session): +async def test_create_triggers_asset_store_contains_correct_assets( + mock_get_classpath, session, make_watcher_trigger +): """AssetStoreAccessors built from watched_assets has entries for all provided name/URI pairs.""" - from airflow.sdk.definitions.asset import Asset - from airflow.sdk.execution_time.context import AssetStoreAccessors - from airflow.triggers.base import BaseEventTrigger, TriggerEvent - injected_instances = [] - - class _WatcherTrigger(BaseEventTrigger): - def __init__(self, **kwargs): - super().__init__(**kwargs) - injected_instances.append(self) - - def serialize(self): - return (f"{type(self).__module__}.{type(self).__qualname__}", {}) - - async def run(self): - yield TriggerEvent("done") - - mock_get_classpath.return_value = _WatcherTrigger + mock_get_classpath.return_value = make_watcher_trigger(injected_instances) runner = TriggerRunner() runner.to_create.append( @@ -781,7 +757,7 @@ async def run(self): id=13, ti=None, classpath="fake.WatcherTrigger", - encrypted_kwargs="fake", + encrypted_kwargs="{}", watched_assets={"asset_a": "s3://bucket/a", "asset_b": "gs://bucket/b"}, ) ) @@ -800,6 +776,47 @@ async def run(self): await runner.cleanup_finished_triggers() +@pytest.mark.asyncio +@patch("airflow.jobs.triggerer_job_runner.TriggerRunner.get_trigger_by_classpath") +async def test_create_triggers_asset_store_accessor_reads_and_writes( + mock_get_classpath, session, mock_supervisor_comms, make_watcher_trigger +): + """asset_store accessor sends correct SUPERVISOR_COMMS messages on get() and set().""" + injected_instances = [] + mock_get_classpath.return_value = make_watcher_trigger(injected_instances) + + runner = TriggerRunner() + runner.to_create.append( + workloads.RunTrigger.model_construct( + id=14, + ti=None, + classpath="fake.WatcherTrigger", + encrypted_kwargs="{}", + watched_assets={"asset_a": "s3://bucket/a"}, + ) + ) + + await runner.create_triggers() + + assert len(injected_instances) == 1 + store = injected_instances[0].asset_store + accessor = store[Asset(name="asset_a", uri="s3://bucket/a")] + + mock_supervisor_comms.send.return_value = AssetStoreResult(value="2026-01-01") + result = accessor.get("watermark") + assert result == "2026-01-01" + + mock_supervisor_comms.send.assert_called_with(GetAssetStoreByName(name="asset_a", key="watermark")) + + accessor.set("watermark", "2026-06-11") + mock_supervisor_comms.send.assert_called_with( + SetAssetStoreByName(name="asset_a", key="watermark", value="2026-06-11") + ) + + runner.triggers[14]["task"].cancel() + await runner.cleanup_finished_triggers() + + def test_trigger_lifecycle(spy_agency: SpyAgency, session, testing_dag_bundle): """ Checks that the triggerer will correctly see a new Trigger in the database diff --git a/generated/provider_dependencies.json b/generated/provider_dependencies.json index e97e830bbd0a9..296bba3c24ab3 100644 --- a/generated/provider_dependencies.json +++ b/generated/provider_dependencies.json @@ -1013,6 +1013,7 @@ "http", "microsoft.azure", "microsoft.mssql", + "mongo", "mysql", "openlineage", "oracle", @@ -1253,6 +1254,8 @@ "amazon", "common.compat", "common.messaging", + "google", + "openlineage", "oracle", "sftp" ], diff --git a/generated/provider_dependencies.json.sha256sum b/generated/provider_dependencies.json.sha256sum index 49ab0db88c08e..659660a6a7bf5 100644 --- a/generated/provider_dependencies.json.sha256sum +++ b/generated/provider_dependencies.json.sha256sum @@ -1 +1 @@ -f042436099826662d45d5f59c100a363d5e12facd51a7c8b850ccbce08d8c4ee +c2a0259b8dbc5d60fdf336b2dbc8ee4860bc94313f620fb70b1d21e8a612072b From c420421c4f8068db58dae58a2854b27b85721297 Mon Sep 17 00:00:00 2001 From: Jake Roach <116606359+jroachgolf84@users.noreply.github.com> Date: Mon, 15 Jun 2026 08:10:23 -0400 Subject: [PATCH 12/16] feature/expose-asset-state-accessor: Moving from asset store -> asset state store --- .../src/airflow/jobs/triggerer_job_runner.py | 4 +- airflow-core/src/airflow/triggers/base.py | 4 +- .../tests/unit/jobs/test_triggerer_job.py | 56 +++++++++---------- .../tests/unit/triggers/test_base_trigger.py | 22 ++++---- 4 files changed, 43 insertions(+), 43 deletions(-) diff --git a/airflow-core/src/airflow/jobs/triggerer_job_runner.py b/airflow-core/src/airflow/jobs/triggerer_job_runner.py index 6285ffb90a5eb..528297ecd21fb 100644 --- a/airflow-core/src/airflow/jobs/triggerer_job_runner.py +++ b/airflow-core/src/airflow/jobs/triggerer_job_runner.py @@ -90,7 +90,7 @@ _new_encoder, _RequestFrame, ) -from airflow.sdk.execution_time.context import AssetStoreAccessors +from airflow.sdk.execution_time.context import AssetStateStoreAccessors from airflow.sdk.execution_time.request_handlers import ( handle_delete_variable, handle_delete_xcom, @@ -1296,7 +1296,7 @@ async def create_triggers(self): trigger_instance.timeout_after = workload.timeout_after if isinstance(trigger_instance, BaseEventTrigger) and workload.watched_assets: - trigger_instance.asset_store = AssetStoreAccessors( + trigger_instance.asset_state_store = AssetStateStoreAccessors( inlets=[Asset(name=name, uri=uri) for name, uri in workload.watched_assets.items()] ) diff --git a/airflow-core/src/airflow/triggers/base.py b/airflow-core/src/airflow/triggers/base.py index 0a47f6247d374..949c5174f4fe4 100644 --- a/airflow-core/src/airflow/triggers/base.py +++ b/airflow-core/src/airflow/triggers/base.py @@ -46,7 +46,7 @@ from airflow.models.mappedoperator import MappedOperator from airflow.models.taskinstance import TaskInstance from airflow.sdk.definitions.context import Context - from airflow.sdk.execution_time.context import AssetStoreAccessors + from airflow.sdk.execution_time.context import AssetStateStoreAccessors from airflow.serialization.serialized_objects import SerializedBaseOperator Operator: TypeAlias = MappedOperator | SerializedBaseOperator @@ -302,7 +302,7 @@ def __init__(self, **kwargs): super().__init__(**kwargs) # Injected by the triggerer before run() is called; mirrors how trigger_id is set - self.asset_store: AssetStoreAccessors | None = None + self.asset_state_store: AssetStateStoreAccessors | None = None @staticmethod def hash(classpath: str, kwargs: dict[str, Any]) -> int: diff --git a/airflow-core/tests/unit/jobs/test_triggerer_job.py b/airflow-core/tests/unit/jobs/test_triggerer_job.py index e444888935a25..38a0fc2079263 100644 --- a/airflow-core/tests/unit/jobs/test_triggerer_job.py +++ b/airflow-core/tests/unit/jobs/test_triggerer_job.py @@ -75,15 +75,15 @@ from airflow.providers.standard.triggers.temporal import DateTimeTrigger, TimeDeltaTrigger from airflow.sdk import DAG, Asset, BaseHook, BaseOperator from airflow.sdk.execution_time.comms import ( - AssetStoreResult, - GetAssetStoreByName, - SetAssetStoreByName, + AssetStateStoreResult, + GetAssetStateStoreByName, + SetAssetStateStoreByName, ToSupervisor, ToTask, _RequestFrame, _ResponseFrame, ) -from airflow.sdk.execution_time.context import AssetStoreAccessors +from airflow.sdk.execution_time.context import AssetStateStoreAccessors from airflow.serialization.serialized_objects import LazyDeserializedDAG from airflow.triggers.base import BaseEventTrigger, BaseTrigger, TriggerEvent from airflow.triggers.testing import FailureTrigger, SuccessTrigger @@ -660,10 +660,10 @@ async def run(self): @pytest.mark.asyncio @patch("airflow.jobs.triggerer_job_runner.TriggerRunner.get_trigger_by_classpath") -async def test_create_triggers_injects_asset_store_for_base_event_trigger( +async def test_create_triggers_injects_asset_state_store_for_base_event_trigger( mock_get_classpath, session, make_watcher_trigger ): - """asset_store is populated on BaseEventTrigger instances when watched_assets is set.""" + """asset_state_store is populated on BaseEventTrigger instances when watched_assets is set.""" injected_instances = [] mock_get_classpath.return_value = make_watcher_trigger(injected_instances) @@ -684,8 +684,8 @@ async def test_create_triggers_injects_asset_store_for_base_event_trigger( assert 10 in runner.triggers assert len(injected_instances) == 1 - assert injected_instances[0].asset_store is not None - assert isinstance(injected_instances[0].asset_store, AssetStoreAccessors) + assert injected_instances[0].asset_state_store is not None + assert isinstance(injected_instances[0].asset_state_store, AssetStateStoreAccessors) runner.triggers[10]["task"].cancel() await runner.cleanup_finished_triggers() @@ -693,10 +693,10 @@ async def test_create_triggers_injects_asset_store_for_base_event_trigger( @pytest.mark.asyncio @patch("airflow.jobs.triggerer_job_runner.TriggerRunner.get_trigger_by_classpath") -async def test_create_triggers_asset_store_none_when_no_watched_assets( +async def test_create_triggers_asset_state_store_none_when_no_watched_assets( mock_get_classpath, session, make_watcher_trigger ): - """asset_store stays None when watched_assets is not set on the workload.""" + """asset_state_store stays None when watched_assets is not set on the workload.""" injected_instances = [] mock_get_classpath.return_value = make_watcher_trigger(injected_instances) @@ -714,7 +714,7 @@ async def test_create_triggers_asset_store_none_when_no_watched_assets( await runner.create_triggers() assert len(injected_instances) == 1 - assert injected_instances[0].asset_store is None + assert injected_instances[0].asset_state_store is None runner.triggers[11]["task"].cancel() await runner.cleanup_finished_triggers() @@ -722,8 +722,8 @@ async def test_create_triggers_asset_store_none_when_no_watched_assets( @pytest.mark.asyncio @patch("airflow.jobs.triggerer_job_runner.TriggerRunner.get_trigger_by_classpath") -async def test_create_triggers_skips_asset_store_for_non_event_trigger(mock_get_classpath, session): - """asset_store injection is skipped for plain BaseTrigger (non-BaseEventTrigger) instances.""" +async def test_create_triggers_skips_asset_state_store_for_non_event_trigger(mock_get_classpath, session): + """asset_state_store injection is skipped for plain BaseTrigger (non-BaseEventTrigger) instances.""" mock_get_classpath.return_value = SuccessTrigger runner = TriggerRunner() @@ -736,7 +736,7 @@ async def test_create_triggers_skips_asset_store_for_non_event_trigger(mock_get_ await runner.create_triggers() assert 12 in runner.triggers - assert not hasattr(runner.triggers[12]["task"], "asset_store") + assert not hasattr(runner.triggers[12]["task"], "asset_state_store") runner.triggers[12]["task"].cancel() await runner.cleanup_finished_triggers() @@ -744,10 +744,10 @@ async def test_create_triggers_skips_asset_store_for_non_event_trigger(mock_get_ @pytest.mark.asyncio @patch("airflow.jobs.triggerer_job_runner.TriggerRunner.get_trigger_by_classpath") -async def test_create_triggers_asset_store_contains_correct_assets( +async def test_create_triggers_asset_state_store_contains_correct_assets( mock_get_classpath, session, make_watcher_trigger ): - """AssetStoreAccessors built from watched_assets has entries for all provided name/URI pairs.""" + """AssetStateStoreAccessors built from watched_assets has entries for all provided name/URI pairs.""" injected_instances = [] mock_get_classpath.return_value = make_watcher_trigger(injected_instances) @@ -765,12 +765,12 @@ async def test_create_triggers_asset_store_contains_correct_assets( await runner.create_triggers() assert len(injected_instances) == 1 - store = injected_instances[0].asset_store + state_store = injected_instances[0].asset_state_store - assert store is not None - assert isinstance(store, AssetStoreAccessors) - assert store[Asset(name="asset_a", uri="s3://bucket/a")] is not None - assert store[Asset(name="asset_b", uri="gs://bucket/b")] is not None + assert state_store is not None + assert isinstance(state_store, AssetStateStoreAccessors) + assert state_store[Asset(name="asset_a", uri="s3://bucket/a")] is not None + assert state_store[Asset(name="asset_b", uri="gs://bucket/b")] is not None runner.triggers[13]["task"].cancel() await runner.cleanup_finished_triggers() @@ -778,10 +778,10 @@ async def test_create_triggers_asset_store_contains_correct_assets( @pytest.mark.asyncio @patch("airflow.jobs.triggerer_job_runner.TriggerRunner.get_trigger_by_classpath") -async def test_create_triggers_asset_store_accessor_reads_and_writes( +async def test_create_triggers_asset_state_store_accessor_reads_and_writes( mock_get_classpath, session, mock_supervisor_comms, make_watcher_trigger ): - """asset_store accessor sends correct SUPERVISOR_COMMS messages on get() and set().""" + """asset_state_store accessor sends correct SUPERVISOR_COMMS messages on get() and set().""" injected_instances = [] mock_get_classpath.return_value = make_watcher_trigger(injected_instances) @@ -799,18 +799,18 @@ async def test_create_triggers_asset_store_accessor_reads_and_writes( await runner.create_triggers() assert len(injected_instances) == 1 - store = injected_instances[0].asset_store - accessor = store[Asset(name="asset_a", uri="s3://bucket/a")] + state_store = injected_instances[0].asset_state_store + accessor = state_store[Asset(name="asset_a", uri="s3://bucket/a")] - mock_supervisor_comms.send.return_value = AssetStoreResult(value="2026-01-01") + mock_supervisor_comms.send.return_value = AssetStateStoreResult(value="2026-01-01") result = accessor.get("watermark") assert result == "2026-01-01" - mock_supervisor_comms.send.assert_called_with(GetAssetStoreByName(name="asset_a", key="watermark")) + mock_supervisor_comms.send.assert_called_with(GetAssetStateStoreByName(name="asset_a", key="watermark")) accessor.set("watermark", "2026-06-11") mock_supervisor_comms.send.assert_called_with( - SetAssetStoreByName(name="asset_a", key="watermark", value="2026-06-11") + SetAssetStateStoreByName(name="asset_a", key="watermark", value="2026-06-11") ) runner.triggers[14]["task"].cancel() diff --git a/airflow-core/tests/unit/triggers/test_base_trigger.py b/airflow-core/tests/unit/triggers/test_base_trigger.py index 09643f3a08710..d1ea0265f6a99 100644 --- a/airflow-core/tests/unit/triggers/test_base_trigger.py +++ b/airflow-core/tests/unit/triggers/test_base_trigger.py @@ -236,23 +236,23 @@ async def stream(): assert [p["region"] for p in payloads] == ["us", "us"] -def test_base_event_trigger_asset_store_initialized_to_none(): - """asset_store is None before it is set.""" +def test_base_event_trigger_asset_state_store_initialized_to_none(): + """asset_state_store is None before it is set.""" trigger = _PlainEventTrigger() - assert trigger.asset_store is None + assert trigger.asset_state_store is None -def test_base_event_trigger_asset_store_can_be_set(): - """asset_store can be set once the Trigger is initialized.""" +def test_base_event_trigger_asset_state_store_can_be_set(): + """asset_state_store can be set once the Trigger is initialized.""" trigger = _PlainEventTrigger() mock_store = MagicMock() - trigger.asset_store = mock_store - assert trigger.asset_store is mock_store + trigger.asset_state_store = mock_store + assert trigger.asset_state_store is mock_store -def test_base_event_trigger_asset_store_independent_across_instances(): - """a.asset_store does not impact b.asset_store.""" +def test_base_event_trigger_asset_state_store_independent_across_instances(): + """a.asset_state_store does not impact b.asset_state_store.""" a = _PlainEventTrigger(name="a") b = _PlainEventTrigger(name="b") - a.asset_store = MagicMock() - assert b.asset_store is None + a.asset_state_store = MagicMock() + assert b.asset_state_store is None From aca23279e95466e8ae1b8a8c6c3650f9f07a3301 Mon Sep 17 00:00:00 2001 From: Jake McGrath <116606359+jroachgolf84@users.noreply.github.com> Date: Thu, 18 Jun 2026 08:49:00 -0400 Subject: [PATCH 13/16] Apply suggestions from code review Co-authored-by: Amogh Desai --- airflow-core/src/airflow/jobs/triggerer_job_runner.py | 1 - airflow-core/src/airflow/triggers/base.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/airflow-core/src/airflow/jobs/triggerer_job_runner.py b/airflow-core/src/airflow/jobs/triggerer_job_runner.py index 115178517b774..b32ea53a2cd7f 100644 --- a/airflow-core/src/airflow/jobs/triggerer_job_runner.py +++ b/airflow-core/src/airflow/jobs/triggerer_job_runner.py @@ -779,7 +779,6 @@ def _create_workload( render_log_fname: Callable[..., str], session: Session, ) -> workloads.RunTrigger | None: - # Pass the "watched" Assets through for downstream use in BaseEventTrigger if trigger.task_instance is None: watched_assets: dict[str, str] | None = None diff --git a/airflow-core/src/airflow/triggers/base.py b/airflow-core/src/airflow/triggers/base.py index 8ed2160f4b8d9..ee57b536d5ec7 100644 --- a/airflow-core/src/airflow/triggers/base.py +++ b/airflow-core/src/airflow/triggers/base.py @@ -300,7 +300,7 @@ class BaseEventTrigger(BaseTrigger): def __init__(self, **kwargs): super().__init__(**kwargs) - # Injected by the triggerer before run() is called; mirrors how trigger_id is set + # Injected by the triggerer before run() is called self.asset_state_store: AssetStateStoreAccessors | None = None @staticmethod From cc7d9fbd3be4146704792007b03fe27ec2870887 Mon Sep 17 00:00:00 2001 From: Jake Roach <116606359+jroachgolf84@users.noreply.github.com> Date: Thu, 18 Jun 2026 08:55:18 -0400 Subject: [PATCH 14/16] feature/expose-asset-state-accessor: Implement autospec for tests --- airflow-core/tests/unit/triggers/test_base_trigger.py | 7 ++++--- generated/provider_dependencies.json.sha256sum | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/airflow-core/tests/unit/triggers/test_base_trigger.py b/airflow-core/tests/unit/triggers/test_base_trigger.py index 12321c3ac298e..47e7b64b74661 100644 --- a/airflow-core/tests/unit/triggers/test_base_trigger.py +++ b/airflow-core/tests/unit/triggers/test_base_trigger.py @@ -17,11 +17,12 @@ # under the License. from __future__ import annotations -from unittest.mock import MagicMock +from unittest.mock import create_autospec import pytest from airflow.sdk.bases.operator import BaseOperator +from airflow.sdk.execution_time.context import AssetStateStoreAccessors from airflow.triggers.base import BaseEventTrigger, BaseTrigger, StartTriggerArgs, TriggerEvent @@ -264,7 +265,7 @@ def test_base_event_trigger_asset_state_store_initialized_to_none(): def test_base_event_trigger_asset_state_store_can_be_set(): """asset_state_store can be set once the Trigger is initialized.""" trigger = _PlainEventTrigger() - mock_store = MagicMock() + mock_store = create_autospec(AssetStateStoreAccessors, instance=True) trigger.asset_state_store = mock_store assert trigger.asset_state_store is mock_store @@ -273,7 +274,7 @@ def test_base_event_trigger_asset_state_store_independent_across_instances(): """a.asset_state_store does not impact b.asset_state_store.""" a = _PlainEventTrigger(name="a") b = _PlainEventTrigger(name="b") - a.asset_state_store = MagicMock() + a.asset_state_store = create_autospec(AssetStateStoreAccessors, instance=True) assert b.asset_state_store is None def test_create_shared_stream_producer_raises_by_default(): diff --git a/generated/provider_dependencies.json.sha256sum b/generated/provider_dependencies.json.sha256sum index b7f44443a3ab6..fa0c1dd153c22 100644 --- a/generated/provider_dependencies.json.sha256sum +++ b/generated/provider_dependencies.json.sha256sum @@ -1 +1 @@ -bb7437125421517dcc83ca840e1c068e25179eff8aab93b87766ec29d0dfa3b0 +9b84a720601b0b9235817dabf02b11ffe8b3a5c25833e724fc619393bd41e803 From e7992d9717c936eba604aaa8cd3dc005d01eccc1 Mon Sep 17 00:00:00 2001 From: Jake Roach <116606359+jroachgolf84@users.noreply.github.com> Date: Thu, 18 Jun 2026 12:37:02 -0400 Subject: [PATCH 15/16] feature/expose-asset-state-accessor: Fixing static checks --- .../tests/unit/triggers/test_base_trigger.py | 1 + uv.lock | 94 ++++++++++++++++++- 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/airflow-core/tests/unit/triggers/test_base_trigger.py b/airflow-core/tests/unit/triggers/test_base_trigger.py index 47e7b64b74661..06cb06c94ce9f 100644 --- a/airflow-core/tests/unit/triggers/test_base_trigger.py +++ b/airflow-core/tests/unit/triggers/test_base_trigger.py @@ -277,6 +277,7 @@ def test_base_event_trigger_asset_state_store_independent_across_instances(): a.asset_state_store = create_autospec(AssetStateStoreAccessors, instance=True) assert b.asset_state_store is None + def test_create_shared_stream_producer_raises_by_default(): """A subclass that does not override create_shared_stream_producer gets NotImplementedError. diff --git a/uv.lock b/uv.lock index a53c8e18ec4e4..94f79535a8b06 100644 --- a/uv.lock +++ b/uv.lock @@ -4332,6 +4332,9 @@ avro = [ bedrock = [ { name = "pydantic-ai-slim", extra = ["bedrock"] }, ] +code-mode = [ + { name = "pydantic-ai-harness", extra = ["codemode"] }, +] common-sql = [ { name = "apache-airflow-providers-common-sql" }, ] @@ -4411,6 +4414,7 @@ requires-dist = [ { name = "llama-index-llms-openai", marker = "extra == 'llamaindex'", specifier = ">=0.6.0" }, { name = "pyarrow", marker = "python_full_version >= '3.14' and extra == 'parquet'", specifier = ">=22.0.0" }, { name = "pyarrow", marker = "python_full_version < '3.14' and extra == 'parquet'", specifier = ">=18.0.0" }, + { name = "pydantic-ai-harness", extras = ["codemode"], marker = "extra == 'code-mode'", specifier = ">=0.3.0" }, { name = "pydantic-ai-skills", marker = "extra == 'skills'", specifier = ">=0.11.0" }, { name = "pydantic-ai-slim", specifier = ">=1.99.0" }, { name = "pydantic-ai-slim", extras = ["anthropic"], marker = "extra == 'anthropic'" }, @@ -4422,7 +4426,7 @@ requires-dist = [ { name = "python-docx", marker = "extra == 'docx'", specifier = ">=1.0.0" }, { name = "sqlglot", marker = "extra == 'sql'", specifier = ">=30.0.0" }, ] -provides-extras = ["anthropic", "bedrock", "google", "openai", "mcp", "skills", "avro", "parquet", "sql", "common-sql", "langchain", "llamaindex", "pdf", "docx", "git"] +provides-extras = ["anthropic", "bedrock", "google", "openai", "mcp", "code-mode", "skills", "avro", "parquet", "sql", "common-sql", "langchain", "llamaindex", "pdf", "docx", "git"] [package.metadata.requires-dev] dev = [ @@ -19045,6 +19049,23 @@ email = [ { name = "email-validator" }, ] +[[package]] +name = "pydantic-ai-harness" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic-ai-slim" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/ae/95ada09e80a7cf71a1a87fb2f824450387b324ddcf68a0dc7c54550c8d3e/pydantic_ai_harness-0.3.0.tar.gz", hash = "sha256:3a803c2569a3346830443ee7a646b0c2267659d2265ada560c12430cd16d2ffe", size = 536553, upload-time = "2026-05-13T19:23:08.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/08/6c3872d654ef40b0dde64bd839e857fc08f930386b583c369b7a07df27ef/pydantic_ai_harness-0.3.0-py3-none-any.whl", hash = "sha256:b3d363ce3bdadba89e6e3378c66a44ce77808a8fa959429d5d7bc07bea8c854f", size = 25497, upload-time = "2026-05-13T19:23:07.356Z" }, +] + +[package.optional-dependencies] +codemode = [ + { name = "pydantic-monty" }, +] + [[package]] name = "pydantic-ai-skills" version = "0.11.0" @@ -19240,6 +19261,77 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fc/72/621556e3f5068400d43a0375d38e5963de30256eaa5a702aba12e82ed0ff/pydantic_graph-1.107.0-py3-none-any.whl", hash = "sha256:71add94fe7e14c703977a895117c475aae6c0b02a774a036c4d00d9a63c78b00", size = 80106, upload-time = "2026-06-10T14:53:06.543Z" }, ] +[[package]] +name = "pydantic-monty" +version = "0.0.18" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/14/5b/bb6a8bfdf13eb9808c966bdac064a40ce9ac881ec6d64dba3e055888f22b/pydantic_monty-0.0.18.tar.gz", hash = "sha256:c43794c7c4664fa1403d4841459d0e23f01b4f552283db638f5b40ced4dac6a1", size = 1197105, upload-time = "2026-05-29T08:31:41.077Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/74/36d50926a7b53b85723960fad50b34b5fc8da79cc8f6091a1f1b44a02b79/pydantic_monty-0.0.18-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:857b62bfc6f06cd9853d4fc51011391e0431187fe9d08034ae24eafcb797c60a", size = 8464519, upload-time = "2026-05-29T08:30:49.301Z" }, + { url = "https://files.pythonhosted.org/packages/28/7b/941e3c9c4816864a2c260df63d3be36c523022732154d2853e5376fcf1e1/pydantic_monty-0.0.18-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:65918fac0835109de6f725069d0aa35b7454c26809634d5344d7b26686754381", size = 8719689, upload-time = "2026-05-29T08:30:27.115Z" }, + { url = "https://files.pythonhosted.org/packages/31/20/84cfdf92732651e68aa52d846a22ae573294241b4aa75ae84c0b3d2782b0/pydantic_monty-0.0.18-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c5bee11eecbadf03b2e764feb11fdea12a6b176bf071bb2fa922a23a704a83b4", size = 9042115, upload-time = "2026-05-29T08:29:18.039Z" }, + { url = "https://files.pythonhosted.org/packages/23/dc/e3dcdef2d0dc09751ed054c69c2363e05d94c985994197ce5748b22b8799/pydantic_monty-0.0.18-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de65d5a8c7ba74794d7f50dfa0b36014931abe2a5abe1a48778afc1bb7dd5d60", size = 8171553, upload-time = "2026-05-29T08:30:51.772Z" }, + { url = "https://files.pythonhosted.org/packages/61/93/45d2b8867f74ddff0a45b96e78d1ff5bdd4bfcd68f6fd622009096b4324c/pydantic_monty-0.0.18-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8694f0897d611d6f81901eee31d5c73c6d677cd50efe95368b40e1dc1d034e8a", size = 8586169, upload-time = "2026-05-29T08:29:58.806Z" }, + { url = "https://files.pythonhosted.org/packages/06/2c/e46629bf65a4017e905db9b87158253869d329cb884604be78e74c0e3d88/pydantic_monty-0.0.18-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dcd3286f6b74a959acd32cdb4c3f0a423f91ff6d7775a08315091766f74a76dc", size = 9181554, upload-time = "2026-05-29T08:31:03.712Z" }, + { url = "https://files.pythonhosted.org/packages/20/f6/91af3acf83fe6b156134e90e7739ff167247d7a48aa53735b3b6a050a335/pydantic_monty-0.0.18-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aa2cc2dda0c7a271c6b0792ce7e60dd0bc5114263b83dccb147c6d0c88d28614", size = 9286056, upload-time = "2026-05-29T08:30:17.643Z" }, + { url = "https://files.pythonhosted.org/packages/f0/71/1b008c633a4767e518e4aebfd79eb1c2c20259282853b6967373d70ca0f9/pydantic_monty-0.0.18-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e87e5953fe1ad15f9e67c5dc590ae240889da28bc84c344e255579d4f33281f5", size = 9266143, upload-time = "2026-05-29T08:30:01.364Z" }, + { url = "https://files.pythonhosted.org/packages/6b/c5/d2b44995729c884f682e499fea134f7b19883b3414c077431d80dc222802/pydantic_monty-0.0.18-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:83b82b7c235943081b31eb9a4c8a4af961640cfbc5b7d3a97dda1bf3efd83cff", size = 8350637, upload-time = "2026-05-29T08:30:20.061Z" }, + { url = "https://files.pythonhosted.org/packages/6e/7d/8326aca20b563cf656a2d7e52fca1ec98c9b2cde67eba06ecebffb5b73f7/pydantic_monty-0.0.18-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0526e5222cbb4cd0a253f49bfcf851dc87984b39d2a5e4eb041d8ce7d1b6987a", size = 8900794, upload-time = "2026-05-29T08:31:24.604Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a6/f62f187a1327ae3bf101de44439b62508da7895ca63c38295115c12a1006/pydantic_monty-0.0.18-cp310-cp310-win32.whl", hash = "sha256:668a4502e9bd67c7bb5d2c4c9d153e9f798d4f5f452845b9a72aaa1f8ce86ab8", size = 8280979, upload-time = "2026-05-29T08:30:47.128Z" }, + { url = "https://files.pythonhosted.org/packages/8c/62/455b679f3b5c00caf362b2388d8a191889f2496f834500989be404175997/pydantic_monty-0.0.18-cp310-cp310-win_amd64.whl", hash = "sha256:12c2ac68f2a12ac68bcd51beb1bf6c2e5fd81061584fd5a826d3454fc9220e36", size = 9482422, upload-time = "2026-05-29T08:31:10.421Z" }, + { url = "https://files.pythonhosted.org/packages/8f/50/06720fb35b73993aa9964403eff1ab35b1d7bd0db1b1ee0633e19311e254/pydantic_monty-0.0.18-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:5140382a6ea68778c76f04ccb91fdbfd1a77b8cae3a89534e23a0e5afaf21e75", size = 8464367, upload-time = "2026-05-29T08:29:46.855Z" }, + { url = "https://files.pythonhosted.org/packages/6c/8e/b3946ee663349fb35f9dceddf1aed394b8e5df1d8767b840844db9cee515/pydantic_monty-0.0.18-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421d1b7956e06a22dc13fe6a34bebf3a1bdde8cf78616eded018f7a9ca746295", size = 8718281, upload-time = "2026-05-29T08:31:06.121Z" }, + { url = "https://files.pythonhosted.org/packages/36/3f/9fb2e8d0ed660d0e5b281316be0c1cb1a023b156c02a8dc8a2c3ec007af7/pydantic_monty-0.0.18-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6609de4408ad54387ecd0b3eedce796497ee72c6ec888074519afcd4f6959a81", size = 9041289, upload-time = "2026-05-29T08:29:33.062Z" }, + { url = "https://files.pythonhosted.org/packages/d3/5e/cb242ba7bd63985eee94f0dff8864002bb5ded2da7190e73786ddcd5b4e8/pydantic_monty-0.0.18-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd2b37890d8a948606be184bd6e3fa4e445d26e3c7329c6a451a271bfc470f24", size = 8170676, upload-time = "2026-05-29T08:29:38.358Z" }, + { url = "https://files.pythonhosted.org/packages/f1/8d/d144775ea57b813e97aef9edaf5f867fb82960597af517125e1b513c983c/pydantic_monty-0.0.18-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:19b86e4fc4b73c2c925906bbc31488b9e8b99b54101f8ea7bbdccfd60ded38f0", size = 8585337, upload-time = "2026-05-29T08:31:27.206Z" }, + { url = "https://files.pythonhosted.org/packages/b3/81/6291a4871fbdb8dfa66d1b1f2406c11066757caa1c53092320e5d11ea49d/pydantic_monty-0.0.18-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4de83f38b3658152697c524ea87ce307a13701d807801c9be27870e837829a02", size = 9181594, upload-time = "2026-05-29T08:31:36.736Z" }, + { url = "https://files.pythonhosted.org/packages/d7/00/28879cee77e24f70c756c4603b4a21b013e601b7821bd07e06cf6718b75a/pydantic_monty-0.0.18-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ef3aaa4fb7af8fd84f42df5e1e43d7ff3eae7d81314580462c8ca716e7e6e361", size = 9285193, upload-time = "2026-05-29T08:30:58.727Z" }, + { url = "https://files.pythonhosted.org/packages/9f/07/52dece571ef47085d2f1053df4c1be8d5b42d8735da4797f5f79d650f81f/pydantic_monty-0.0.18-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d3dd72c195eca243b08c5d68b1f513124feaf22acb87b73cd8bfcdc3f6b4bb7", size = 9264997, upload-time = "2026-05-29T08:29:49.51Z" }, + { url = "https://files.pythonhosted.org/packages/38/12/b010315be2927c5be43d3a4036cd6857d9981d5116efda5e40540f43a014/pydantic_monty-0.0.18-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6b0409a5f314af54f704d73cd97fe2a0cc8ef2635952bf57c5879b9313281614", size = 8350432, upload-time = "2026-05-29T08:30:10.984Z" }, + { url = "https://files.pythonhosted.org/packages/84/8b/9674a90269dc0f1a080e606cba642b256e138e7fcee1a3a0b55969946f83/pydantic_monty-0.0.18-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:790f169bb5700e3ab24a8d44fd7016d915c701e27bcd2feabe0de917306bbff0", size = 8900736, upload-time = "2026-05-29T08:29:42.508Z" }, + { url = "https://files.pythonhosted.org/packages/28/6a/07351c22208814c466d9a26bab03642727e9f5570562cbd4fbde837e4644/pydantic_monty-0.0.18-cp311-cp311-win32.whl", hash = "sha256:3682f3bd67ef92ecd78a3f5f4efcd7659ba643aaca45412601a92691d6440250", size = 8280476, upload-time = "2026-05-29T08:31:17.814Z" }, + { url = "https://files.pythonhosted.org/packages/eb/de/937dcc0e828d324f037a5004f97c8ff245158fe735107023a10d8f672e32/pydantic_monty-0.0.18-cp311-cp311-win_amd64.whl", hash = "sha256:eecdf1175542ac2fd3f6a203c7744145be4e73e08755c6de1d35253dc6a872e7", size = 9480905, upload-time = "2026-05-29T08:30:15.287Z" }, + { url = "https://files.pythonhosted.org/packages/f2/d1/307df5ac3a694acc5922f00fc7ce96357ad4afaa41bbfeec0b8379bed6ec/pydantic_monty-0.0.18-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:1030bd49b813e67aedf4f7bb3dd4cc9edaa203554b3b8fe11eeab6d61139229f", size = 8462571, upload-time = "2026-05-29T08:29:21.081Z" }, + { url = "https://files.pythonhosted.org/packages/55/83/8ccf04b2f9642153702c6eb22d0a0abad57014fd85879ab1f6341b5a1946/pydantic_monty-0.0.18-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2988d3e511131680d9de60647bfe5c697b1e4e4cad474fecf1451c53314e8520", size = 8688756, upload-time = "2026-05-29T08:30:24.677Z" }, + { url = "https://files.pythonhosted.org/packages/81/84/e3ce3294636b92a5eb238273026dd2825d97deac44f76e901990a4eeb306/pydantic_monty-0.0.18-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:7d8d0f42162cb40da05f32d50d9d8d74411b3d4f1182117c8365f18457442c0d", size = 9046635, upload-time = "2026-05-29T08:30:06.38Z" }, + { url = "https://files.pythonhosted.org/packages/de/b8/c7881620a812850772ae0924863d1399cbecb3e4c8c455a9c7a9c20b06f8/pydantic_monty-0.0.18-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5c688dc7c7b28a2f389a61217bae1d50658e28f960abe33a254328cadc8a17a", size = 8171342, upload-time = "2026-05-29T08:29:44.773Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ea/6d10ea1657e303295a75a3854f6dd6b378cbd501dcd1782844107b932acd/pydantic_monty-0.0.18-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f469293f5a776231b9617787a5dd6c58048c6568833ee6848338be6391f15449", size = 8591152, upload-time = "2026-05-29T08:31:29.944Z" }, + { url = "https://files.pythonhosted.org/packages/93/fb/ab85c4676ccffd0f3b7f509a4c8b396b07c7860577def2f58a22b3fe8aef/pydantic_monty-0.0.18-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6e0cd4991947b8a47210985836f94c14139bc3ea06253d2f38d0d752b165517", size = 9183064, upload-time = "2026-05-29T08:31:12.776Z" }, + { url = "https://files.pythonhosted.org/packages/5c/12/11292178b487052f9e0a1ea7b3d17e1e3bfcba598fefce8cb9ed8712021e/pydantic_monty-0.0.18-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:58b4b96863abbc0ffa5baf64779b3fbb6376cc763488b027ecaddb5052d5ff17", size = 9285440, upload-time = "2026-05-29T08:29:35.642Z" }, + { url = "https://files.pythonhosted.org/packages/79/42/7afb8dde4414d84c042f2cc1b0870a7351cae2e4fbf3fef89b3aa683eca9/pydantic_monty-0.0.18-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1208bd5976c1254b2705559836511c86ea3cc51d9c6f688b1e3e22984715dbc", size = 9233438, upload-time = "2026-05-29T08:31:39.177Z" }, + { url = "https://files.pythonhosted.org/packages/5f/46/89124cf146725e354b44685b477da6b0b5dc07a8a3af2aec309e88c55405/pydantic_monty-0.0.18-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:977168253d8f6b49bb64f128d02c4b62ee8b435fd62876268377e1f2b00cc00f", size = 8351900, upload-time = "2026-05-29T08:31:08.348Z" }, + { url = "https://files.pythonhosted.org/packages/00/c5/dda512f5a9c68242faea368844aacefb54c2a13f9b40bee5ab48ccdc78c5/pydantic_monty-0.0.18-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:c21319a091dc1ff1fccb8647dae5bb543b3f528c556319ed15c7992dfa9b5e87", size = 8901559, upload-time = "2026-05-29T08:29:26.047Z" }, + { url = "https://files.pythonhosted.org/packages/45/97/496655362d4bb6e74ff791cf40be6a502e794c296f0089912783325075a7/pydantic_monty-0.0.18-cp312-cp312-win32.whl", hash = "sha256:220fe77920af9033ae644887e747b68567df630b1a8afa39b0a830d84a3438b5", size = 8277428, upload-time = "2026-05-29T08:30:35.322Z" }, + { url = "https://files.pythonhosted.org/packages/cc/24/2913a50a9afbce681629408814ae94929589bed9aa347b176caf17842957/pydantic_monty-0.0.18-cp312-cp312-win_amd64.whl", hash = "sha256:f965a62993bd3fe7be94f99c86349d61d987b3d8cc07fb729d7d8af87c7d481d", size = 9453897, upload-time = "2026-05-29T08:31:15.481Z" }, + { url = "https://files.pythonhosted.org/packages/70/86/5f1eb8b0743ba65821aa37285f131478672b2832baa08386e931c9e71969/pydantic_monty-0.0.18-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:765865634c2075ec816515db22acf0c71e42d25dcbf66638dc063d95c7d1a858", size = 8473615, upload-time = "2026-05-29T08:30:22.027Z" }, + { url = "https://files.pythonhosted.org/packages/c6/9c/7628423f955efb669d2cc1d3a8909bf8271b543ce27036e18229ad0e51e8/pydantic_monty-0.0.18-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d47976a18e3e3da0e86f8cf6068fc8a125930422dc10d3d3bf0b5410a9e9282e", size = 8689116, upload-time = "2026-05-29T08:29:30.906Z" }, + { url = "https://files.pythonhosted.org/packages/4c/dd/ec6cbbe997205063c679ef17220a48fcb0a4c319fc336ca81a3c7c248c6d/pydantic_monty-0.0.18-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:1c6bc7a776d9d97b899054263c0f0c7316523571da02b4c2a6d2ecd4793482e0", size = 9045884, upload-time = "2026-05-29T08:30:53.831Z" }, + { url = "https://files.pythonhosted.org/packages/c2/9c/51f8ffa4340bc1986eb9240b0756724f5fdf3c463d6d66c8cc8450e1446d/pydantic_monty-0.0.18-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eb84fe51e3f6e00a0cc9628e0acf5904d982c8cc85d4db42b7532d071602f703", size = 8178458, upload-time = "2026-05-29T08:30:09.015Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ec/7eb84aeb86631571f9acffc91552217dc2b524b00db37b8d10517df467d1/pydantic_monty-0.0.18-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a2e86f3ba67b094d498bc8b071d5e3b8b034bb9f3006216a357c5f71d49d6132", size = 8591295, upload-time = "2026-05-29T08:29:23.357Z" }, + { url = "https://files.pythonhosted.org/packages/da/69/d5210208fa116593bd81789e2e5abb6222d38087c9c1879e18f7e7620275/pydantic_monty-0.0.18-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eb8a412aa0336d4e0334a4e05a54b391d91fa334020bd295a280285ba17ab5ca", size = 9184647, upload-time = "2026-05-29T08:29:51.852Z" }, + { url = "https://files.pythonhosted.org/packages/ed/74/4d95c8f65072964c4cb798dbe87d2e1c1349607ab5905874bfa8a0b94de1/pydantic_monty-0.0.18-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1056ce3acef60ab880314caf97775c5ea41b30f9306d0dd28cefeffd42dda366", size = 9291637, upload-time = "2026-05-29T08:31:19.966Z" }, + { url = "https://files.pythonhosted.org/packages/d0/40/5817780313a3e089ca6f860fbdc836d3aa33790eb72c2e8fe2edc877820e/pydantic_monty-0.0.18-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9593caa45b68fd07ac67bea9974effbe2a1c5453d8a106b913596b0dff6d8471", size = 9233863, upload-time = "2026-05-29T08:31:42.838Z" }, + { url = "https://files.pythonhosted.org/packages/b3/55/f77565c5797502c7ba995dc23a26759cb33023590f9f2926bc4e8ab87afe/pydantic_monty-0.0.18-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:e15e18ed27a17ee607ad3bcbf82f25a9ec4d496ff493ff64cdabb83ca2174cec", size = 8358264, upload-time = "2026-05-29T08:31:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/1b/1f/c700eb800868d1be4078a99cb00e23fb7e5d8760c8e83b729bba27b5bf92/pydantic_monty-0.0.18-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:96d75a418d96640ff0c7f354a78fc4470a2d0f40ba69e886c2f32756d594d9a0", size = 8906664, upload-time = "2026-05-29T08:30:04.055Z" }, + { url = "https://files.pythonhosted.org/packages/7f/52/1b4599d5a6dccc65d46956431cfdf5a46df4a18005a93ffec4aab56a47b8/pydantic_monty-0.0.18-cp313-cp313-win32.whl", hash = "sha256:5cd5ff08e6749b3a4a2192856861b36feee8575e2cf81bd6bd1d8b4b39ac630e", size = 8276949, upload-time = "2026-05-29T08:30:12.968Z" }, + { url = "https://files.pythonhosted.org/packages/8e/eb/54c9011e2ef5e1358512ea23bb4a862b4f8fdda2d2f951a58854ff55ee3e/pydantic_monty-0.0.18-cp313-cp313-win_amd64.whl", hash = "sha256:52ce98be1e5bf76974597234ec857b7a6ef99374a036860cd2e2e1bd75c18f1e", size = 9453909, upload-time = "2026-05-29T08:31:01.275Z" }, + { url = "https://files.pythonhosted.org/packages/9a/04/e6462c2d4097189fc4af62b84273d6ef0a69473cbdf1c7f158d8cec25c11/pydantic_monty-0.0.18-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:8c38add825895ecfde75f3272126f07dd94f2db08440f165e76d7e321feec8da", size = 8473729, upload-time = "2026-05-29T08:30:32.648Z" }, + { url = "https://files.pythonhosted.org/packages/65/ff/6aca0ddd5c074b2757dd992b38e57f5e6b21ec0631007ec2d1b4dcbd3bff/pydantic_monty-0.0.18-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:186eaa80945c4a5bb19beba54471d423bffcf5daf64f93029b2d5df1939e201f", size = 8702897, upload-time = "2026-05-29T08:29:56.669Z" }, + { url = "https://files.pythonhosted.org/packages/29/37/d56705a23d7c5ff5112f5f82d56e70a9073a46d6ebfdbce09bcb31a52921/pydantic_monty-0.0.18-cp314-cp314-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c7a568fd6db2389743d0d28f355273c0d6d2009c5252c0971dcb1e5629e60d4f", size = 9046049, upload-time = "2026-05-29T08:30:56.314Z" }, + { url = "https://files.pythonhosted.org/packages/c6/00/82a6ddb1ca7bf2b1ef4b1751d960671324f3a0a498fc80d335f3f0962176/pydantic_monty-0.0.18-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:324443ea73eb70bfd57b34e554278f52501592c4580edc6b9708b0d3d6a42f44", size = 8178495, upload-time = "2026-05-29T08:31:34.62Z" }, + { url = "https://files.pythonhosted.org/packages/71/9b/1a1aab97a113d718d6e6db9a7e5ad2fb9fd9cde8623d4885188983fa349b/pydantic_monty-0.0.18-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:936438027363474eecc5573eb635d1c5f3bf061ad02334d5b545a132fbb76e45", size = 8593356, upload-time = "2026-05-29T08:30:37.618Z" }, + { url = "https://files.pythonhosted.org/packages/65/89/0f5212ccae4c29fa85c86dea553e45cf4729f94eba47187c747d44f7c890/pydantic_monty-0.0.18-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f700d2c7139f44ac29f51301c835a23e6c53402984cb23f3e3214e47df7ddaa3", size = 9184371, upload-time = "2026-05-29T08:29:28.574Z" }, + { url = "https://files.pythonhosted.org/packages/26/1a/a2f3f0016a1326ef50d732f53bd8f447b3535fdb59a40287e77d0914935f/pydantic_monty-0.0.18-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:10abc11ae00d712b866b2a64e6a0f34c6a7d5f99903228f4699eeb4ced50299a", size = 9292432, upload-time = "2026-05-29T08:30:30.051Z" }, + { url = "https://files.pythonhosted.org/packages/78/55/8bc4f8924c8bfd366b2c524a19083f86bb457c61f56c47e4ae4bd607536e/pydantic_monty-0.0.18-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8183aa8e2420aa4c1924cc05b87c8143f953b7c173f240cb7cf20e0b3f865cdb", size = 9247001, upload-time = "2026-05-29T08:30:40.061Z" }, + { url = "https://files.pythonhosted.org/packages/5c/5e/f6ae7d18cfc058f4df49765420800dd5d7de3adbba6be864a8bc919847d2/pydantic_monty-0.0.18-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:03fccf00fd925b616e0b7ce59c354f3fa1e50eb2d511391e260e28793b5d3b0c", size = 8357641, upload-time = "2026-05-29T08:29:40.372Z" }, + { url = "https://files.pythonhosted.org/packages/ac/d3/166961ca42ad855b7a2dd50d494be26ff0222b21b68750081962fb4568f9/pydantic_monty-0.0.18-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:d9dc4185bad6ca7f38d2d71b9d8ed2d68e48e4c4f0ccc89cd0188c08469bda7d", size = 8905773, upload-time = "2026-05-29T08:30:43.535Z" }, + { url = "https://files.pythonhosted.org/packages/df/88/d8bd8e82ca624ca6bebe9ebfb6cfc461214303158ba735751a6c2277043e/pydantic_monty-0.0.18-cp314-cp314-win32.whl", hash = "sha256:4840805ecfe5a38c07126f02181d907c687ca765a73aaabc2b128184390a2c52", size = 8277373, upload-time = "2026-05-29T08:31:22.247Z" }, + { url = "https://files.pythonhosted.org/packages/b6/0e/c395b22ddc32c746d7e2d271dd18bb585289576bb67483435e25643b7ecd/pydantic_monty-0.0.18-cp314-cp314-win_amd64.whl", hash = "sha256:83b6e2b73b0fa60c5641ecb6e8b588840023163ca6ab8b3e5da7ad088390ee7c", size = 9467375, upload-time = "2026-05-29T08:29:54.227Z" }, +] + [[package]] name = "pydantic-settings" version = "2.14.1" From 47680ad650566ad5a85c903e07a6cafd4aed0c4e Mon Sep 17 00:00:00 2001 From: Jake Roach <116606359+jroachgolf84@users.noreply.github.com> Date: Thu, 18 Jun 2026 22:29:30 -0400 Subject: [PATCH 16/16] feature/expose-asset-state-accessor: Improving tests --- .../tests/unit/jobs/test_triggerer_job.py | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/airflow-core/tests/unit/jobs/test_triggerer_job.py b/airflow-core/tests/unit/jobs/test_triggerer_job.py index aa2662dad94c0..4ae539600d47b 100644 --- a/airflow-core/tests/unit/jobs/test_triggerer_job.py +++ b/airflow-core/tests/unit/jobs/test_triggerer_job.py @@ -727,19 +727,33 @@ async def test_create_triggers_asset_state_store_none_when_no_watched_assets( @patch("airflow.jobs.triggerer_job_runner.TriggerRunner.get_trigger_by_classpath") async def test_create_triggers_skips_asset_state_store_for_non_event_trigger(mock_get_classpath, session): """asset_state_store injection is skipped for plain BaseTrigger (non-BaseEventTrigger) instances.""" - mock_get_classpath.return_value = SuccessTrigger + injected_instances: list[BaseTrigger] = [] + + class PlainTrigger(BaseTrigger): + def __init__(self, **kwargs): + super().__init__(**kwargs) + injected_instances.append(self) + + def serialize(self): + return (f"{type(self).__module__}.{type(self).__qualname__}", {}) + + async def run(self): + yield TriggerEvent("done") + + mock_get_classpath.return_value = PlainTrigger runner = TriggerRunner() runner.to_create.append( workloads.RunTrigger.model_construct( - id=12, ti=None, classpath="airflow.triggers.testing.SuccessTrigger", encrypted_kwargs="{}" + id=12, ti=None, classpath="fake.PlainTrigger", encrypted_kwargs="{}" ) ) await runner.create_triggers() assert 12 in runner.triggers - assert not hasattr(runner.triggers[12]["task"], "asset_state_store") + assert len(injected_instances) == 1 + assert not hasattr(injected_instances[0], "asset_state_store") runner.triggers[12]["task"].cancel() await runner.cleanup_finished_triggers()