From b6d400b27d8568f7e9a6e8b6d0b95ba9cf5f0fcf Mon Sep 17 00:00:00 2001 From: Leondon9 Date: Wed, 3 Jun 2026 16:43:32 +0900 Subject: [PATCH 1/4] Add asset partition sensor --- .../execution_api/routes/asset_events.py | 6 + .../src/airflow/jobs/triggerer_job_runner.py | 8 ++ .../versions/head/test_asset_events.py | 104 ++++++++++++++ providers/standard/provider.yaml | 2 + .../providers/standard/sensors/asset.py | 101 ++++++++++++++ .../providers/standard/triggers/asset.py | 94 +++++++++++++ .../tests/unit/standard/sensors/test_asset.py | 129 ++++++++++++++++++ .../unit/standard/triggers/test_asset.py | 101 ++++++++++++++ task-sdk/src/airflow/sdk/api/client.py | 3 + .../src/airflow/sdk/execution_time/comms.py | 2 + .../src/airflow/sdk/execution_time/context.py | 8 ++ .../sdk/execution_time/request_handlers.py | 34 +++++ .../airflow/sdk/execution_time/supervisor.py | 26 +--- task-sdk/tests/task_sdk/api/test_client.py | 4 + .../task_sdk/execution_time/test_context.py | 26 ++-- .../execution_time/test_supervisor.py | 10 ++ 16 files changed, 628 insertions(+), 30 deletions(-) create mode 100644 providers/standard/src/airflow/providers/standard/sensors/asset.py create mode 100644 providers/standard/src/airflow/providers/standard/triggers/asset.py create mode 100644 providers/standard/tests/unit/standard/sensors/test_asset.py create mode 100644 providers/standard/tests/unit/standard/triggers/test_asset.py diff --git a/airflow-core/src/airflow/api_fastapi/execution_api/routes/asset_events.py b/airflow-core/src/airflow/api_fastapi/execution_api/routes/asset_events.py index d0ecc3d3adaf2..c8cbc6c779e39 100644 --- a/airflow-core/src/airflow/api_fastapi/execution_api/routes/asset_events.py +++ b/airflow-core/src/airflow/api_fastapi/execution_api/routes/asset_events.py @@ -80,6 +80,7 @@ def get_asset_event_by_asset_name_uri( session: SessionDep, after: Annotated[UtcDateTime | None, Query(description="The start of the time range")] = None, before: Annotated[UtcDateTime | None, Query(description="The end of the time range")] = None, + partition_key: Annotated[str | None, Query(description="The partition key of the Asset Event")] = None, ascending: Annotated[bool, Query(description="Whether to sort results in ascending order")] = True, limit: Annotated[int | None, Query(description="The maximum number of results to return")] = None, ) -> AssetEventsResponse: @@ -102,6 +103,8 @@ def get_asset_event_by_asset_name_uri( where_clause = and_(where_clause, AssetEvent.timestamp >= after) if before: where_clause = and_(where_clause, AssetEvent.timestamp <= before) + if partition_key is not None: + where_clause = and_(where_clause, AssetEvent.partition_key == partition_key) return _get_asset_events_through_sql_clauses( join_clause=AssetEvent.asset, @@ -118,6 +121,7 @@ def get_asset_event_by_asset_alias( session: SessionDep, after: Annotated[UtcDateTime | None, Query(description="The start of the time range")] = None, before: Annotated[UtcDateTime | None, Query(description="The end of the time range")] = None, + partition_key: Annotated[str | None, Query(description="The partition key of the Asset Event")] = None, ascending: Annotated[bool, Query(description="Whether to sort results in ascending order")] = True, limit: Annotated[int | None, Query(description="The maximum number of results to return")] = None, ) -> AssetEventsResponse: @@ -126,6 +130,8 @@ def get_asset_event_by_asset_alias( where_clause = and_(where_clause, AssetEvent.timestamp >= after) if before: where_clause = and_(where_clause, AssetEvent.timestamp <= before) + if partition_key is not None: + where_clause = and_(where_clause, AssetEvent.partition_key == partition_key) return _get_asset_events_through_sql_clauses( join_clause=AssetEvent.source_aliases, diff --git a/airflow-core/src/airflow/jobs/triggerer_job_runner.py b/airflow-core/src/airflow/jobs/triggerer_job_runner.py index e8e1f66e7875c..c8d2f0052c278 100644 --- a/airflow-core/src/airflow/jobs/triggerer_job_runner.py +++ b/airflow-core/src/airflow/jobs/triggerer_job_runner.py @@ -66,6 +66,8 @@ DeleteXCom, DRCount, ErrorResponse, + GetAssetEventByAsset, + GetAssetEventByAssetAlias, GetConnection, GetDagRunState, GetDRCount, @@ -92,6 +94,8 @@ from airflow.sdk.execution_time.request_handlers import ( handle_delete_variable, handle_delete_xcom, + handle_get_asset_event_by_asset, + handle_get_asset_event_by_asset_alias, handle_get_connection, handle_get_dag_run_state, handle_get_dr_count, @@ -568,6 +572,10 @@ def _handle_request(self, msg: ToTriggerSupervisor, log: FilteringBoundLogger, r resp, dump_opts = handle_get_dr_count(self.client, msg) elif isinstance(msg, GetDagRunState): resp, dump_opts = handle_get_dag_run_state(self.client, msg) + elif isinstance(msg, GetAssetEventByAsset): + resp, dump_opts = handle_get_asset_event_by_asset(self.client, msg) + elif isinstance(msg, GetAssetEventByAssetAlias): + resp, dump_opts = handle_get_asset_event_by_asset_alias(self.client, msg) elif isinstance(msg, GetTICount): resp, dump_opts = handle_get_ti_count(self.client, msg) diff --git a/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_asset_events.py b/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_asset_events.py index e3839f19eafe8..83772e67777fd 100644 --- a/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_asset_events.py +++ b/airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_asset_events.py @@ -54,6 +54,33 @@ def make_timestamp(day): session.commit() +@pytest.fixture +def test_partitioned_asset_events(session, test_asset): + def make_timestamp(day): + return datetime(2021, 1, day, tzinfo=timezone.utc) + + common = { + "asset_id": test_asset.id, + "extra": {"foo": "bar"}, + "source_dag_id": "foo", + "source_task_id": "bar", + "source_run_id": "custom", + "source_map_index": -1, + } + + events = [ + AssetEvent(id=101, timestamp=make_timestamp(1), partition_key="2021-01-01", **common), + AssetEvent(id=102, timestamp=make_timestamp(2), partition_key="2021-01-02", **common), + ] + session.add_all(events) + session.commit() + yield events + + for event in events: + session.delete(event) + session.commit() + + @pytest.fixture def test_asset(session): asset = AssetModel( @@ -90,6 +117,20 @@ def test_asset_alias(session, test_asset_events, test_asset): session.commit() +@pytest.fixture +def test_partitioned_asset_alias(session, test_partitioned_asset_events, test_asset): + alias = AssetAliasModel(name="partitioned_alias") + alias.asset_events = test_partitioned_asset_events + alias.assets.append(test_asset) + session.add(alias) + session.commit() + + yield alias + + session.delete(alias) + session.commit() + + class TestGetAssetEventByAsset: @pytest.mark.parametrize( ("uri", "name"), @@ -457,6 +498,40 @@ def test_get_by_asset_get_last(self, uri, name, client): ] } + @pytest.mark.parametrize( + ("uri", "name"), + [ + (None, "test_get_asset_by_name"), + ("s3://bucket/key", None), + ("s3://bucket/key", "test_get_asset_by_name"), + ], + ) + def test_get_by_asset_with_partition_key_filter(self, uri, name, client, test_partitioned_asset_events): + response = client.get( + "/execution/asset-events/by-asset", + params={"name": name, "uri": uri, "partition_key": "2021-01-02"}, + ) + assert response.status_code == 200 + assert response.json()["asset_events"] == [ + { + "id": test_partitioned_asset_events[1].id, + "extra": {"foo": "bar"}, + "source_task_id": "bar", + "source_dag_id": "foo", + "source_run_id": "custom", + "source_map_index": -1, + "asset": { + "extra": {"foo": "bar"}, + "group": "asset", + "name": "test_get_asset_by_name", + "uri": "s3://bucket/key", + }, + "created_dagruns": [], + "timestamp": "2021-01-02T00:00:00Z", + "partition_key": "2021-01-02", + }, + ] + class TestGetAssetEventByAssetAlias: @pytest.mark.usefixtures("test_asset_alias") @@ -521,3 +596,32 @@ def test_get_by_asset(self, client): }, ] } + + def test_get_by_asset_alias_with_partition_key_filter( + self, client, test_partitioned_asset_alias, test_partitioned_asset_events + ): + response = client.get( + "/execution/asset-events/by-asset-alias", + params={"name": "partitioned_alias", "partition_key": "2021-01-02"}, + ) + + assert response.status_code == 200 + assert response.json()["asset_events"] == [ + { + "id": test_partitioned_asset_events[1].id, + "extra": {"foo": "bar"}, + "source_task_id": "bar", + "source_dag_id": "foo", + "source_run_id": "custom", + "source_map_index": -1, + "asset": { + "extra": {"foo": "bar"}, + "group": "asset", + "name": "test_get_asset_by_name", + "uri": "s3://bucket/key", + }, + "created_dagruns": [], + "timestamp": "2021-01-02T00:00:00Z", + "partition_key": "2021-01-02", + }, + ] diff --git a/providers/standard/provider.yaml b/providers/standard/provider.yaml index 0062afbf4182c..0059347d6d604 100644 --- a/providers/standard/provider.yaml +++ b/providers/standard/provider.yaml @@ -104,6 +104,7 @@ sensors: - airflow.providers.standard.sensors.python - airflow.providers.standard.sensors.filesystem - airflow.providers.standard.sensors.external_task + - airflow.providers.standard.sensors.asset hooks: - integration-name: Standard python-modules: @@ -118,6 +119,7 @@ triggers: - airflow.providers.standard.triggers.file - airflow.providers.standard.triggers.temporal - airflow.providers.standard.triggers.hitl + - airflow.providers.standard.triggers.asset extra-links: - airflow.providers.standard.operators.trigger_dagrun.TriggerDagRunLink diff --git a/providers/standard/src/airflow/providers/standard/sensors/asset.py b/providers/standard/src/airflow/providers/standard/sensors/asset.py new file mode 100644 index 0000000000000..53a6ea8981b0c --- /dev/null +++ b/providers/standard/src/airflow/providers/standard/sensors/asset.py @@ -0,0 +1,101 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from __future__ import annotations + +import datetime +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any + +from airflow.providers.common.compat.sdk import AirflowException, BaseSensorOperator, conf +from airflow.providers.standard.triggers.asset import AssetPartitionTrigger + +if TYPE_CHECKING: + from airflow.providers.common.compat.sdk import Asset, Context + + +class AssetPartitionSensor(BaseSensorOperator): + """ + Wait for an asset event with the given partition key. + + :param asset: asset to wait for. + :param partition_key: partition key for the asset event to wait for. + :param deferrable: If waiting for completion, whether to defer the task until done. + """ + + template_fields: Sequence[str] = ("partition_key",) + ui_color = "#e6f1f2" + + def __init__( + self, + *, + asset: Asset, + partition_key: str, + deferrable: bool = conf.getboolean("operators", "default_deferrable", fallback=False), + **kwargs, + ) -> None: + super().__init__(**kwargs) + self.asset = asset + self.partition_key = partition_key + self.deferrable = deferrable + + def poke(self, context: Context) -> bool: + from airflow.sdk.exceptions import AirflowRuntimeError + from airflow.sdk.execution_time.comms import ErrorResponse, GetAssetEventByAsset + from airflow.sdk.execution_time.task_runner import SUPERVISOR_COMMS + + self.log.info("Poking for asset event: asset=%s, partition_key=%s", self.asset, self.partition_key) + response = SUPERVISOR_COMMS.send( + GetAssetEventByAsset( + name=self.asset.name, + uri=self.asset.uri, + partition_key=self.partition_key, + ascending=False, + limit=1, + ) + ) + if isinstance(response, ErrorResponse): + raise AirflowRuntimeError(response) + return bool(response and response.asset_events) + + def execute(self, context: Context) -> None: + if not self.deferrable: + super().execute(context=context) + return + + if not self.poke(context=context): + self.defer( + timeout=datetime.timedelta(seconds=self.timeout), + trigger=AssetPartitionTrigger( + asset_name=self.asset.name, + asset_uri=self.asset.uri, + partition_key=self.partition_key, + poke_interval=self.poke_interval, + ), + method_name="execute_complete", + ) + + def execute_complete(self, context: Context, event: dict[str, Any] | None = None) -> None: + if event and event.get("status") == "success": + self.log.info( + "Asset partition event found: asset=%s, partition_key=%s", + self.asset, + self.partition_key, + ) + return + message = event.get("message") if event else "Trigger completed without an event" + raise AirflowException(message) diff --git a/providers/standard/src/airflow/providers/standard/triggers/asset.py b/providers/standard/src/airflow/providers/standard/triggers/asset.py new file mode 100644 index 0000000000000..8a8c0ef08f15d --- /dev/null +++ b/providers/standard/src/airflow/providers/standard/triggers/asset.py @@ -0,0 +1,94 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator +from typing import Any + +from airflow.providers.standard.version_compat import AIRFLOW_V_3_0_PLUS + +if AIRFLOW_V_3_0_PLUS: + from airflow.triggers.base import BaseTrigger, TriggerEvent +else: + from airflow.triggers.base import BaseTrigger, TriggerEvent # type: ignore + + +class AssetPartitionTrigger(BaseTrigger): + """ + Trigger when an asset event exists for the given partition key. + + :param asset_name: name of the asset to wait for. + :param asset_uri: URI of the asset to wait for. + :param partition_key: partition key for the asset event to wait for. + :param poke_interval: polling interval in seconds. + """ + + def __init__( + self, + *, + asset_name: str | None, + asset_uri: str | None, + partition_key: str, + poke_interval: float = 5.0, + ) -> None: + super().__init__() + self.asset_name = asset_name + self.asset_uri = asset_uri + self.partition_key = partition_key + self.poke_interval = poke_interval + + def serialize(self) -> tuple[str, dict[str, Any]]: + """Serialize AssetPartitionTrigger arguments and classpath.""" + return ( + "airflow.providers.standard.triggers.asset.AssetPartitionTrigger", + { + "asset_name": self.asset_name, + "asset_uri": self.asset_uri, + "partition_key": self.partition_key, + "poke_interval": self.poke_interval, + }, + ) + + async def run(self) -> AsyncIterator[TriggerEvent]: + """Poll until the requested asset partition event exists.""" + from airflow.sdk.execution_time.comms import ErrorResponse, GetAssetEventByAsset + from airflow.sdk.execution_time.task_runner import SUPERVISOR_COMMS + + while True: + response = await SUPERVISOR_COMMS.asend( + GetAssetEventByAsset( + name=self.asset_name, + uri=self.asset_uri, + partition_key=self.partition_key, + ascending=False, + limit=1, + ) + ) + if isinstance(response, ErrorResponse): + yield TriggerEvent( + { + "status": "error", + "message": f"{response.error.value}: {response.detail}", + } + ) + return + if response and response.asset_events: + yield TriggerEvent({"status": "success"}) + return + await asyncio.sleep(self.poke_interval) diff --git a/providers/standard/tests/unit/standard/sensors/test_asset.py b/providers/standard/tests/unit/standard/sensors/test_asset.py new file mode 100644 index 0000000000000..7596fe9d3c83b --- /dev/null +++ b/providers/standard/tests/unit/standard/sensors/test_asset.py @@ -0,0 +1,129 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from __future__ import annotations + +from unittest import mock + +import pytest + +from airflow.providers.common.compat.sdk import AirflowException, Asset, TaskDeferred +from airflow.providers.standard.sensors.asset import AssetPartitionSensor +from airflow.providers.standard.triggers.asset import AssetPartitionTrigger +from airflow.sdk import timezone +from airflow.sdk.api.datamodels._generated import AssetEventResponse, AssetResponse +from airflow.sdk.exceptions import AirflowRuntimeError, ErrorType +from airflow.sdk.execution_time import task_runner +from airflow.sdk.execution_time.comms import ( + AssetEventsResult, + ErrorResponse, + GetAssetEventByAsset, +) + + +class TestAssetPartitionSensor: + def test_template_fields(self): + assert AssetPartitionSensor.template_fields == ("partition_key",) + + def test_poke_returns_true_when_partition_event_exists(self, monkeypatch): + comms = mock.Mock() + comms.send.return_value = AssetEventsResult( + asset_events=[ + AssetEventResponse( + id=1, + timestamp=timezone.utcnow(), + asset=AssetResponse(name="orders", uri="s3://warehouse/orders", group="asset"), + partition_key="2024-01-01", + created_dagruns=[], + ) + ], + ) + monkeypatch.setattr(task_runner, "SUPERVISOR_COMMS", comms, raising=False) + + sensor = AssetPartitionSensor( + task_id="wait_orders", + asset=Asset(name="orders", uri="s3://warehouse/orders"), + partition_key="2024-01-01", + ) + + assert sensor.poke({}) is True + comms.send.assert_called_once_with( + GetAssetEventByAsset( + name="orders", + uri="s3://warehouse/orders", + partition_key="2024-01-01", + ascending=False, + limit=1, + ) + ) + + def test_poke_returns_false_when_partition_event_is_missing(self, monkeypatch): + comms = mock.Mock() + comms.send.return_value = AssetEventsResult(asset_events=[]) + monkeypatch.setattr(task_runner, "SUPERVISOR_COMMS", comms, raising=False) + + sensor = AssetPartitionSensor( + task_id="wait_orders", + asset=Asset(name="orders", uri="s3://warehouse/orders"), + partition_key="2024-01-01", + ) + + assert sensor.poke({}) is False + + def test_poke_raises_runtime_error_for_supervisor_error(self, monkeypatch): + comms = mock.Mock() + comms.send.return_value = ErrorResponse(error=ErrorType.ASSET_NOT_FOUND) + monkeypatch.setattr(task_runner, "SUPERVISOR_COMMS", comms, raising=False) + + sensor = AssetPartitionSensor( + task_id="wait_orders", + asset=Asset(name="orders", uri="s3://warehouse/orders"), + partition_key="2024-01-01", + ) + + with pytest.raises(AirflowRuntimeError): + sensor.poke({}) + + def test_execute_defers_when_partition_event_is_missing(self, monkeypatch): + comms = mock.Mock() + comms.send.return_value = AssetEventsResult(asset_events=[]) + monkeypatch.setattr(task_runner, "SUPERVISOR_COMMS", comms, raising=False) + + sensor = AssetPartitionSensor( + task_id="wait_orders", + asset=Asset(name="orders", uri="s3://warehouse/orders"), + partition_key="2024-01-01", + deferrable=True, + ) + + with pytest.raises(TaskDeferred) as exc: + sensor.execute({}) + + assert isinstance(exc.value.trigger, AssetPartitionTrigger) + assert exc.value.trigger.asset_name == "orders" + assert exc.value.trigger.asset_uri == "s3://warehouse/orders" + assert exc.value.trigger.partition_key == "2024-01-01" + + def test_execute_complete_raises_for_trigger_error(self): + sensor = AssetPartitionSensor( + task_id="wait_orders", + asset=Asset(name="orders", uri="s3://warehouse/orders"), + partition_key="2024-01-01", + ) + + with pytest.raises(AirflowException, match="failed"): + sensor.execute_complete({}, {"status": "error", "message": "failed"}) diff --git a/providers/standard/tests/unit/standard/triggers/test_asset.py b/providers/standard/tests/unit/standard/triggers/test_asset.py new file mode 100644 index 0000000000000..612c747552cec --- /dev/null +++ b/providers/standard/tests/unit/standard/triggers/test_asset.py @@ -0,0 +1,101 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from __future__ import annotations + +from unittest import mock + +import pytest + +from airflow.providers.standard.triggers.asset import AssetPartitionTrigger +from airflow.sdk import timezone +from airflow.sdk.api.datamodels._generated import AssetEventResponse, AssetResponse +from airflow.sdk.exceptions import ErrorType +from airflow.sdk.execution_time import task_runner +from airflow.sdk.execution_time.comms import AssetEventsResult, ErrorResponse, GetAssetEventByAsset +from airflow.triggers.base import TriggerEvent + + +class TestAssetPartitionTrigger: + def test_serialization(self): + trigger = AssetPartitionTrigger( + asset_name="orders", + asset_uri="s3://warehouse/orders", + partition_key="2024-01-01", + poke_interval=10, + ) + + classpath, kwargs = trigger.serialize() + + assert classpath == "airflow.providers.standard.triggers.asset.AssetPartitionTrigger" + assert kwargs == { + "asset_name": "orders", + "asset_uri": "s3://warehouse/orders", + "partition_key": "2024-01-01", + "poke_interval": 10, + } + + @pytest.mark.asyncio + async def test_run_yields_success_when_partition_event_exists(self, monkeypatch): + comms = mock.Mock() + comms.asend = mock.AsyncMock( + return_value=AssetEventsResult( + asset_events=[ + AssetEventResponse( + id=1, + timestamp=timezone.utcnow(), + asset=AssetResponse(name="orders", uri="s3://warehouse/orders", group="asset"), + partition_key="2024-01-01", + created_dagruns=[], + ) + ], + ) + ) + monkeypatch.setattr(task_runner, "SUPERVISOR_COMMS", comms, raising=False) + trigger = AssetPartitionTrigger( + asset_name="orders", + asset_uri="s3://warehouse/orders", + partition_key="2024-01-01", + poke_interval=0, + ) + + assert await trigger.run().__anext__() == TriggerEvent({"status": "success"}) + comms.asend.assert_awaited_once_with( + GetAssetEventByAsset( + name="orders", + uri="s3://warehouse/orders", + partition_key="2024-01-01", + ascending=False, + limit=1, + ) + ) + + @pytest.mark.asyncio + async def test_run_yields_error_for_supervisor_error(self, monkeypatch): + comms = mock.Mock() + comms.asend = mock.AsyncMock(return_value=ErrorResponse(error=ErrorType.ASSET_NOT_FOUND)) + monkeypatch.setattr(task_runner, "SUPERVISOR_COMMS", comms, raising=False) + trigger = AssetPartitionTrigger( + asset_name="orders", + asset_uri="s3://warehouse/orders", + partition_key="2024-01-01", + poke_interval=0, + ) + + assert await trigger.run().__anext__() == TriggerEvent( + {"status": "error", "message": "ASSET_NOT_FOUND: None"} + ) diff --git a/task-sdk/src/airflow/sdk/api/client.py b/task-sdk/src/airflow/sdk/api/client.py index 01aea509f971d..5f0851a1861d9 100644 --- a/task-sdk/src/airflow/sdk/api/client.py +++ b/task-sdk/src/airflow/sdk/api/client.py @@ -848,6 +848,7 @@ def get( alias_name: str | None = None, after: datetime | None = None, before: datetime | None = None, + partition_key: str | None = None, ascending: bool = True, limit: int | None = None, ) -> AssetEventsResponse: @@ -857,6 +858,8 @@ def get( common_params["after"] = after.isoformat() if before: common_params["before"] = before.isoformat() + if partition_key is not None: + common_params["partition_key"] = partition_key common_params["ascending"] = ascending if limit: common_params["limit"] = limit diff --git a/task-sdk/src/airflow/sdk/execution_time/comms.py b/task-sdk/src/airflow/sdk/execution_time/comms.py index 0e0483be70e68..f1276a21e50fe 100644 --- a/task-sdk/src/airflow/sdk/execution_time/comms.py +++ b/task-sdk/src/airflow/sdk/execution_time/comms.py @@ -1095,6 +1095,7 @@ class GetAssetsByAlias(BaseModel): class GetAssetEventByAsset(BaseModel): name: str | None uri: str | None + partition_key: str | None = None after: AwareDatetime | None = None before: AwareDatetime | None = None limit: int | None = None @@ -1104,6 +1105,7 @@ class GetAssetEventByAsset(BaseModel): class GetAssetEventByAssetAlias(BaseModel): alias_name: str + partition_key: str | None = None after: AwareDatetime | None = None before: AwareDatetime | None = None limit: int | None = None diff --git a/task-sdk/src/airflow/sdk/execution_time/context.py b/task-sdk/src/airflow/sdk/execution_time/context.py index d39238ab3b2f1..d7923054d5ab0 100644 --- a/task-sdk/src/airflow/sdk/execution_time/context.py +++ b/task-sdk/src/airflow/sdk/execution_time/context.py @@ -1012,6 +1012,7 @@ class InletEventsAccessor(Sequence["AssetEventResult"]): _before: str | datetime | None _ascending: bool _limit: int | None + _partition_key: str | None _asset_name: str | None _asset_uri: str | None _alias_name: str | None @@ -1026,6 +1027,7 @@ def __init__( self._before = None self._ascending = True self._limit = None + self._partition_key = None def after(self, after: str) -> Self: self._after = after @@ -1047,6 +1049,11 @@ def limit(self, limit: int) -> Self: self._reset_cache() return self + def partition_key(self, partition_key: str | None) -> Self: + self._partition_key = partition_key + self._reset_cache() + return self + @functools.cached_property def _asset_events(self) -> list[AssetEventResult]: from airflow.sdk.execution_time.comms import ( @@ -1062,6 +1069,7 @@ def _asset_events(self) -> list[AssetEventResult]: "before": self._before, "ascending": self._ascending, "limit": self._limit, + "partition_key": self._partition_key, } msg: ToSupervisor diff --git a/task-sdk/src/airflow/sdk/execution_time/request_handlers.py b/task-sdk/src/airflow/sdk/execution_time/request_handlers.py index 959be43fe93cf..e5c9dcb595b6e 100644 --- a/task-sdk/src/airflow/sdk/execution_time/request_handlers.py +++ b/task-sdk/src/airflow/sdk/execution_time/request_handlers.py @@ -40,10 +40,13 @@ XComSequenceSliceResponse, ) from airflow.sdk.execution_time.comms import ( + AssetEventsResult, ConnectionResult, DagRunStateResult, DeleteVariable, DeleteXCom, + GetAssetEventByAsset, + GetAssetEventByAssetAlias, GetConnection, GetDagRunState, GetDRCount, @@ -207,6 +210,37 @@ def handle_get_dag_run_state(client: Client, msg: GetDagRunState) -> tuple[BaseM return dr_resp, {} +def handle_get_asset_event_by_asset( + client: Client, msg: GetAssetEventByAsset +) -> tuple[BaseModel | None, dict[str, bool]]: + """Fetch asset events for an asset.""" + asset_event_resp = client.asset_events.get( + uri=msg.uri, + name=msg.name, + partition_key=msg.partition_key, + after=msg.after, + before=msg.before, + ascending=msg.ascending, + limit=msg.limit, + ) + return AssetEventsResult.from_asset_events_response(asset_event_resp), {"exclude_unset": True} + + +def handle_get_asset_event_by_asset_alias( + client: Client, msg: GetAssetEventByAssetAlias +) -> tuple[BaseModel | None, dict[str, bool]]: + """Fetch asset events for an asset alias.""" + asset_event_resp = client.asset_events.get( + alias_name=msg.alias_name, + partition_key=msg.partition_key, + after=msg.after, + before=msg.before, + ascending=msg.ascending, + limit=msg.limit, + ) + return AssetEventsResult.from_asset_events_response(asset_event_resp), {"exclude_unset": True} + + def handle_get_previous_dag_run( client: Client, msg: GetPreviousDagRun ) -> tuple[BaseModel | None, dict[str, bool]]: diff --git a/task-sdk/src/airflow/sdk/execution_time/supervisor.py b/task-sdk/src/airflow/sdk/execution_time/supervisor.py index 0005c36bc7e9b..ab2cd4b39b071 100644 --- a/task-sdk/src/airflow/sdk/execution_time/supervisor.py +++ b/task-sdk/src/airflow/sdk/execution_time/supervisor.py @@ -60,7 +60,6 @@ from airflow.sdk.exceptions import ErrorType from airflow.sdk.execution_time import comms from airflow.sdk.execution_time.comms import ( - AssetEventsResult, AssetResult, AssetStoreResult, ClearAssetStoreByName, @@ -134,6 +133,8 @@ from airflow.sdk.execution_time.request_handlers import ( handle_delete_variable, handle_delete_xcom, + handle_get_asset_event_by_asset, + handle_get_asset_event_by_asset_alias, handle_get_connection, handle_get_dag_run_state, handle_get_dr_count, @@ -1740,28 +1741,9 @@ def _handle_request(self, msg: ToSupervisor, log: FilteringBoundLogger, req_id: elif isinstance(msg, GetAssetsByAlias): resp = self.client.assets.get_by_alias(alias_name=msg.alias_name) elif isinstance(msg, GetAssetEventByAsset): - asset_event_resp = self.client.asset_events.get( - uri=msg.uri, - name=msg.name, - after=msg.after, - before=msg.before, - ascending=msg.ascending, - limit=msg.limit, - ) - asset_event_result = AssetEventsResult.from_asset_events_response(asset_event_resp) - resp = asset_event_result - dump_opts = {"exclude_unset": True} + resp, dump_opts = handle_get_asset_event_by_asset(self.client, msg) elif isinstance(msg, GetAssetEventByAssetAlias): - asset_event_resp = self.client.asset_events.get( - alias_name=msg.alias_name, - after=msg.after, - before=msg.before, - ascending=msg.ascending, - limit=msg.limit, - ) - asset_event_result = AssetEventsResult.from_asset_events_response(asset_event_resp) - resp = asset_event_result - dump_opts = {"exclude_unset": True} + resp, dump_opts = handle_get_asset_event_by_asset_alias(self.client, msg) elif isinstance(msg, GetPrevSuccessfulDagRun): resp, dump_opts = handle_get_prev_successful_dag_run(self.client, self.id) elif isinstance(msg, GetXComCount): diff --git a/task-sdk/tests/task_sdk/api/test_client.py b/task-sdk/tests/task_sdk/api/test_client.py index ce4afe51af5fe..dedc3d524ea5d 100644 --- a/task-sdk/tests/task_sdk/api/test_client.py +++ b/task-sdk/tests/task_sdk/api/test_client.py @@ -1177,6 +1177,8 @@ class TestAssetEventOperations: [ ({"name": "this_asset", "uri": "s3://bucket/key"}), ({"alias_name": "this_asset_alias"}), + ({"name": "this_asset", "uri": "s3://bucket/key", "partition_key": "2021-01-02"}), + ({"alias_name": "this_asset_alias", "partition_key": "2021-01-02"}), ], ) def test_by_name_get_success(self, request_params): @@ -1189,6 +1191,8 @@ def handle_request(request: httpx.Request) -> httpx.Response: assert params.get("name") == request_params.get("alias_name") else: return httpx.Response(status_code=400, json={"detail": "Bad Request"}) + if partition_key := request_params.get("partition_key"): + assert params.get("partition_key") == partition_key return httpx.Response( status_code=200, diff --git a/task-sdk/tests/task_sdk/execution_time/test_context.py b/task-sdk/tests/task_sdk/execution_time/test_context.py index a7d20c43bcfd4..fc01b13ec662a 100644 --- a/task-sdk/tests/task_sdk/execution_time/test_context.py +++ b/task-sdk/tests/task_sdk/execution_time/test_context.py @@ -774,12 +774,13 @@ def test__get_item__with_filters(self, sample_inlet_evnets_accessor, mock_superv asset=AssetResponse(name="test_uri", uri="test_uri", group="asset"), ) events_result = AssetEventsResult(asset_events=[asset_event_resp]) - mock_supervisor_comms.send.side_effect = [events_result] * 10 + mock_supervisor_comms.send.side_effect = [events_result] * 11 list(sample_inlet_evnets_accessor[TEST_ASSET]) list(sample_inlet_evnets_accessor[TEST_ASSET].after("2024-01-01T00:00:00Z")) list(sample_inlet_evnets_accessor[TEST_ASSET].before("2024-01-01T00:00:00Z")) list(sample_inlet_evnets_accessor[TEST_ASSET].limit(10)) + list(sample_inlet_evnets_accessor[TEST_ASSET].partition_key("2024-01-01")) list( sample_inlet_evnets_accessor[TEST_ASSET] .after("2024-01-01T00:00:00Z") @@ -788,33 +789,33 @@ def test__get_item__with_filters(self, sample_inlet_evnets_accessor, mock_superv ) list(sample_inlet_evnets_accessor[TEST_ASSET].ascending(False).limit(10)) - assert mock_supervisor_comms.send.call_count == 6 + assert mock_supervisor_comms.send.call_count == 7 # test accessing the accessor without list() or [] sample_inlet_evnets_accessor[TEST_ASSET].ascending(False).limit(10) - assert mock_supervisor_comms.send.call_count == 6 + assert mock_supervisor_comms.send.call_count == 7 # test accessing one of the elements res = sample_inlet_evnets_accessor[TEST_ASSET].ascending(False).limit(10)[0] assert res == asset_event_resp - assert mock_supervisor_comms.send.call_count == 7 + assert mock_supervisor_comms.send.call_count == 8 # test evaluating the accessor multiple times with the same filters res = sample_inlet_evnets_accessor[TEST_ASSET].ascending(False).limit(10) assert res[0] == asset_event_resp assert res[0] == asset_event_resp - assert mock_supervisor_comms.send.call_count == 8 + assert mock_supervisor_comms.send.call_count == 9 # test changing one of the filters assert res.after("2024-01-01T00:00:00Z")[0] == asset_event_resp - assert mock_supervisor_comms.send.call_count == 9 + assert mock_supervisor_comms.send.call_count == 10 # test len() assert len(sample_inlet_evnets_accessor[TEST_ASSET].ascending(True).limit(10)) == 1 - assert mock_supervisor_comms.send.call_count == 10 + assert mock_supervisor_comms.send.call_count == 11 calls = mock_supervisor_comms.send.call_args_list assert calls[0][0][0] == GetAssetEventByAsset( @@ -840,6 +841,15 @@ def test__get_item__with_filters(self, sample_inlet_evnets_accessor, mock_superv name="test_uri", uri="test://test/", after=None, before=None, limit=10, ascending=True ) assert calls[4][0][0] == GetAssetEventByAsset( + name="test_uri", + uri="test://test/", + after=None, + before=None, + limit=None, + ascending=True, + partition_key="2024-01-01", + ) + assert calls[5][0][0] == GetAssetEventByAsset( name="test_uri", uri="test://test/", after="2024-01-01T00:00:00Z", @@ -847,7 +857,7 @@ def test__get_item__with_filters(self, sample_inlet_evnets_accessor, mock_superv limit=10, ascending=True, ) - assert calls[5][0][0] == GetAssetEventByAsset( + assert calls[6][0][0] == GetAssetEventByAsset( name="test_uri", uri="test://test/", after=None, before=None, limit=10, ascending=False ) diff --git a/task-sdk/tests/task_sdk/execution_time/test_supervisor.py b/task-sdk/tests/task_sdk/execution_time/test_supervisor.py index b72fbdb1e30b6..04be9c62a6e93 100644 --- a/task-sdk/tests/task_sdk/execution_time/test_supervisor.py +++ b/task-sdk/tests/task_sdk/execution_time/test_supervisor.py @@ -2005,6 +2005,7 @@ class RequestTestCase: kwargs={ "uri": "s3://bucket/obj", "name": "test", + "partition_key": None, "after": None, "before": None, "limit": None, @@ -2031,6 +2032,7 @@ class RequestTestCase: before=datetime(2024, 10, 15, 12, 0, 0, tzinfo=timezone.utc), limit=5, ascending=False, + partition_key="2024-10-31", ), expected_body={ "asset_events": [ @@ -2048,6 +2050,7 @@ class RequestTestCase: kwargs={ "uri": "s3://bucket/obj", "name": "test", + "partition_key": "2024-10-31", "after": timezone.parse("2024-10-01T12:00:00Z"), "before": timezone.parse("2024-10-15T12:00:00Z"), "limit": 5, @@ -2084,6 +2087,7 @@ class RequestTestCase: kwargs={ "uri": "s3://bucket/obj", "name": None, + "partition_key": None, "after": None, "before": None, "limit": None, @@ -2127,6 +2131,7 @@ class RequestTestCase: kwargs={ "uri": "s3://bucket/obj", "name": None, + "partition_key": None, "after": timezone.parse("2024-10-01T12:00:00Z"), "before": timezone.parse("2024-10-15T12:00:00Z"), "limit": 5, @@ -2163,6 +2168,7 @@ class RequestTestCase: kwargs={ "uri": None, "name": "test", + "partition_key": None, "after": None, "before": None, "limit": None, @@ -2206,6 +2212,7 @@ class RequestTestCase: kwargs={ "uri": None, "name": "test", + "partition_key": None, "after": timezone.parse("2024-10-01T12:00:00Z"), "before": timezone.parse("2024-10-15T12:00:00Z"), "limit": 5, @@ -2241,6 +2248,7 @@ class RequestTestCase: method_path="asset_events.get", kwargs={ "alias_name": "test_alias", + "partition_key": None, "after": None, "before": None, "limit": None, @@ -2266,6 +2274,7 @@ class RequestTestCase: before=datetime(2024, 10, 15, 12, 0, 0, tzinfo=timezone.utc), limit=5, ascending=False, + partition_key="2024-10-31", ), expected_body={ "asset_events": [ @@ -2282,6 +2291,7 @@ class RequestTestCase: method_path="asset_events.get", kwargs={ "alias_name": "test_alias", + "partition_key": "2024-10-31", "after": timezone.parse("2024-10-01T12:00:00Z"), "before": timezone.parse("2024-10-15T12:00:00Z"), "limit": 5, From 5b85614781e502aa8e48af6117421f80b4677834 Mon Sep 17 00:00:00 2001 From: Leondon9 Date: Wed, 3 Jun 2026 16:45:25 +0900 Subject: [PATCH 2/4] Add newsfragment for asset partition sensor --- airflow-core/newsfragments/67941.feature.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 airflow-core/newsfragments/67941.feature.rst diff --git a/airflow-core/newsfragments/67941.feature.rst b/airflow-core/newsfragments/67941.feature.rst new file mode 100644 index 0000000000000..37aa553a4c353 --- /dev/null +++ b/airflow-core/newsfragments/67941.feature.rst @@ -0,0 +1 @@ +Add an asset partition sensor for waiting on a specific asset event partition. From 26efe622cf9031f29f0392698ff5da463b944846 Mon Sep 17 00:00:00 2001 From: Leondon9 Date: Fri, 19 Jun 2026 02:55:19 +0900 Subject: [PATCH 3/4] Fix asset partition sensor CI checks --- .../provider_dependencies.json.sha256sum | 2 +- .../providers/standard/sensors/asset.py | 10 ++++---- .../providers/standard/triggers/asset.py | 12 ++++++++-- .../tests/unit/standard/sensors/test_asset.py | 18 ++++++++++++-- .../unit/standard/triggers/test_asset.py | 16 +++++++++++++ .../sdk/execution_time/schema/schema.json | 24 +++++++++++++++++++ 6 files changed, 73 insertions(+), 9 deletions(-) diff --git a/generated/provider_dependencies.json.sha256sum b/generated/provider_dependencies.json.sha256sum index b7f44443a3ab6..71c3ff8a1f01c 100644 --- a/generated/provider_dependencies.json.sha256sum +++ b/generated/provider_dependencies.json.sha256sum @@ -1 +1 @@ -bb7437125421517dcc83ca840e1c068e25179eff8aab93b87766ec29d0dfa3b0 +a6f5177b803858c6313ab218f1ade0d7bbd0058eaf39832143dadaccab039830 diff --git a/providers/standard/src/airflow/providers/standard/sensors/asset.py b/providers/standard/src/airflow/providers/standard/sensors/asset.py index 53a6ea8981b0c..f85a3aaf8ec21 100644 --- a/providers/standard/src/airflow/providers/standard/sensors/asset.py +++ b/providers/standard/src/airflow/providers/standard/sensors/asset.py @@ -21,7 +21,7 @@ from collections.abc import Sequence from typing import TYPE_CHECKING, Any -from airflow.providers.common.compat.sdk import AirflowException, BaseSensorOperator, conf +from airflow.providers.common.compat.sdk import AirflowFailException, BaseSensorOperator, conf from airflow.providers.standard.triggers.asset import AssetPartitionTrigger if TYPE_CHECKING: @@ -55,7 +55,7 @@ def __init__( def poke(self, context: Context) -> bool: from airflow.sdk.exceptions import AirflowRuntimeError - from airflow.sdk.execution_time.comms import ErrorResponse, GetAssetEventByAsset + from airflow.sdk.execution_time.comms import AssetEventsResult, ErrorResponse, GetAssetEventByAsset from airflow.sdk.execution_time.task_runner import SUPERVISOR_COMMS self.log.info("Poking for asset event: asset=%s, partition_key=%s", self.asset, self.partition_key) @@ -70,7 +70,9 @@ def poke(self, context: Context) -> bool: ) if isinstance(response, ErrorResponse): raise AirflowRuntimeError(response) - return bool(response and response.asset_events) + if not isinstance(response, AssetEventsResult): + raise TypeError(f"Unexpected response from supervisor: {type(response).__name__}") + return bool(response.asset_events) def execute(self, context: Context) -> None: if not self.deferrable: @@ -98,4 +100,4 @@ def execute_complete(self, context: Context, event: dict[str, Any] | None = None ) return message = event.get("message") if event else "Trigger completed without an event" - raise AirflowException(message) + raise AirflowFailException(message) diff --git a/providers/standard/src/airflow/providers/standard/triggers/asset.py b/providers/standard/src/airflow/providers/standard/triggers/asset.py index 8a8c0ef08f15d..a11ec811eae99 100644 --- a/providers/standard/src/airflow/providers/standard/triggers/asset.py +++ b/providers/standard/src/airflow/providers/standard/triggers/asset.py @@ -67,7 +67,7 @@ def serialize(self) -> tuple[str, dict[str, Any]]: async def run(self) -> AsyncIterator[TriggerEvent]: """Poll until the requested asset partition event exists.""" - from airflow.sdk.execution_time.comms import ErrorResponse, GetAssetEventByAsset + from airflow.sdk.execution_time.comms import AssetEventsResult, ErrorResponse, GetAssetEventByAsset from airflow.sdk.execution_time.task_runner import SUPERVISOR_COMMS while True: @@ -88,7 +88,15 @@ async def run(self) -> AsyncIterator[TriggerEvent]: } ) return - if response and response.asset_events: + if not isinstance(response, AssetEventsResult): + yield TriggerEvent( + { + "status": "error", + "message": f"Unexpected response from supervisor: {type(response).__name__}", + } + ) + return + if response.asset_events: yield TriggerEvent({"status": "success"}) return await asyncio.sleep(self.poke_interval) diff --git a/providers/standard/tests/unit/standard/sensors/test_asset.py b/providers/standard/tests/unit/standard/sensors/test_asset.py index 7596fe9d3c83b..a8a4ecaf0088d 100644 --- a/providers/standard/tests/unit/standard/sensors/test_asset.py +++ b/providers/standard/tests/unit/standard/sensors/test_asset.py @@ -21,7 +21,7 @@ import pytest -from airflow.providers.common.compat.sdk import AirflowException, Asset, TaskDeferred +from airflow.providers.common.compat.sdk import AirflowFailException, Asset, TaskDeferred from airflow.providers.standard.sensors.asset import AssetPartitionSensor from airflow.providers.standard.triggers.asset import AssetPartitionTrigger from airflow.sdk import timezone @@ -98,6 +98,20 @@ def test_poke_raises_runtime_error_for_supervisor_error(self, monkeypatch): with pytest.raises(AirflowRuntimeError): sensor.poke({}) + def test_poke_raises_for_unexpected_supervisor_response(self, monkeypatch): + comms = mock.Mock() + comms.send.return_value = object() + monkeypatch.setattr(task_runner, "SUPERVISOR_COMMS", comms, raising=False) + + sensor = AssetPartitionSensor( + task_id="wait_orders", + asset=Asset(name="orders", uri="s3://warehouse/orders"), + partition_key="2024-01-01", + ) + + with pytest.raises(TypeError, match="Unexpected response from supervisor"): + sensor.poke({}) + def test_execute_defers_when_partition_event_is_missing(self, monkeypatch): comms = mock.Mock() comms.send.return_value = AssetEventsResult(asset_events=[]) @@ -125,5 +139,5 @@ def test_execute_complete_raises_for_trigger_error(self): partition_key="2024-01-01", ) - with pytest.raises(AirflowException, match="failed"): + with pytest.raises(AirflowFailException, match="failed"): sensor.execute_complete({}, {"status": "error", "message": "failed"}) diff --git a/providers/standard/tests/unit/standard/triggers/test_asset.py b/providers/standard/tests/unit/standard/triggers/test_asset.py index 612c747552cec..7f7d69b25940e 100644 --- a/providers/standard/tests/unit/standard/triggers/test_asset.py +++ b/providers/standard/tests/unit/standard/triggers/test_asset.py @@ -99,3 +99,19 @@ async def test_run_yields_error_for_supervisor_error(self, monkeypatch): assert await trigger.run().__anext__() == TriggerEvent( {"status": "error", "message": "ASSET_NOT_FOUND: None"} ) + + @pytest.mark.asyncio + async def test_run_yields_error_for_unexpected_supervisor_response(self, monkeypatch): + comms = mock.Mock() + comms.asend = mock.AsyncMock(return_value=object()) + monkeypatch.setattr(task_runner, "SUPERVISOR_COMMS", comms, raising=False) + trigger = AssetPartitionTrigger( + asset_name="orders", + asset_uri="s3://warehouse/orders", + partition_key="2024-01-01", + poke_interval=0, + ) + + assert await trigger.run().__anext__() == TriggerEvent( + {"status": "error", "message": "Unexpected response from supervisor: object"} + ) diff --git a/task-sdk/src/airflow/sdk/execution_time/schema/schema.json b/task-sdk/src/airflow/sdk/execution_time/schema/schema.json index 4807d9f53b353..e70e67e8832f0 100644 --- a/task-sdk/src/airflow/sdk/execution_time/schema/schema.json +++ b/task-sdk/src/airflow/sdk/execution_time/schema/schema.json @@ -1782,6 +1782,18 @@ ], "title": "Uri" }, + "partition_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Partition Key" + }, "after": { "anyOf": [ { @@ -1845,6 +1857,18 @@ "title": "Alias Name", "type": "string" }, + "partition_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Partition Key" + }, "after": { "anyOf": [ { From 5f4dac94e28450e1d199ca2f52408bd539e32eeb Mon Sep 17 00:00:00 2001 From: Leondon9 Date: Fri, 19 Jun 2026 09:49:11 +0900 Subject: [PATCH 4/4] Fix standard provider asset sensor CI checks --- .../providers/standard/get_provider_info.py | 2 + .../tests/unit/standard/sensors/test_asset.py | 30 +++--- .../unit/standard/triggers/test_asset.py | 22 +++-- uv.lock | 94 ++++++++++++++++++- 4 files changed, 129 insertions(+), 19 deletions(-) diff --git a/providers/standard/src/airflow/providers/standard/get_provider_info.py b/providers/standard/src/airflow/providers/standard/get_provider_info.py index 1f7b2049454d1..d3a2f063fd890 100644 --- a/providers/standard/src/airflow/providers/standard/get_provider_info.py +++ b/providers/standard/src/airflow/providers/standard/get_provider_info.py @@ -75,6 +75,7 @@ def get_provider_info(): "airflow.providers.standard.sensors.python", "airflow.providers.standard.sensors.filesystem", "airflow.providers.standard.sensors.external_task", + "airflow.providers.standard.sensors.asset", ], } ], @@ -96,6 +97,7 @@ def get_provider_info(): "airflow.providers.standard.triggers.file", "airflow.providers.standard.triggers.temporal", "airflow.providers.standard.triggers.hitl", + "airflow.providers.standard.triggers.asset", ], } ], diff --git a/providers/standard/tests/unit/standard/sensors/test_asset.py b/providers/standard/tests/unit/standard/sensors/test_asset.py index a8a4ecaf0088d..9e7ba9eb5a4bf 100644 --- a/providers/standard/tests/unit/standard/sensors/test_asset.py +++ b/providers/standard/tests/unit/standard/sensors/test_asset.py @@ -21,19 +21,27 @@ import pytest -from airflow.providers.common.compat.sdk import AirflowFailException, Asset, TaskDeferred -from airflow.providers.standard.sensors.asset import AssetPartitionSensor -from airflow.providers.standard.triggers.asset import AssetPartitionTrigger -from airflow.sdk import timezone -from airflow.sdk.api.datamodels._generated import AssetEventResponse, AssetResponse -from airflow.sdk.exceptions import AirflowRuntimeError, ErrorType -from airflow.sdk.execution_time import task_runner -from airflow.sdk.execution_time.comms import ( - AssetEventsResult, - ErrorResponse, - GetAssetEventByAsset, +from tests_common.test_utils.version_compat import AIRFLOW_V_3_1_PLUS + +pytestmark = pytest.mark.skipif( + not AIRFLOW_V_3_1_PLUS, + reason="Asset partition sensor tests require Airflow 3.1+ Task SDK supervisor comms", ) +if AIRFLOW_V_3_1_PLUS: + from airflow.providers.common.compat.sdk import AirflowFailException, Asset, TaskDeferred + from airflow.providers.standard.sensors.asset import AssetPartitionSensor + from airflow.providers.standard.triggers.asset import AssetPartitionTrigger + from airflow.sdk import timezone + from airflow.sdk.api.datamodels._generated import AssetEventResponse, AssetResponse + from airflow.sdk.exceptions import AirflowRuntimeError, ErrorType + from airflow.sdk.execution_time import task_runner + from airflow.sdk.execution_time.comms import ( + AssetEventsResult, + ErrorResponse, + GetAssetEventByAsset, + ) + class TestAssetPartitionSensor: def test_template_fields(self): diff --git a/providers/standard/tests/unit/standard/triggers/test_asset.py b/providers/standard/tests/unit/standard/triggers/test_asset.py index 7f7d69b25940e..161831cd50053 100644 --- a/providers/standard/tests/unit/standard/triggers/test_asset.py +++ b/providers/standard/tests/unit/standard/triggers/test_asset.py @@ -21,13 +21,21 @@ import pytest -from airflow.providers.standard.triggers.asset import AssetPartitionTrigger -from airflow.sdk import timezone -from airflow.sdk.api.datamodels._generated import AssetEventResponse, AssetResponse -from airflow.sdk.exceptions import ErrorType -from airflow.sdk.execution_time import task_runner -from airflow.sdk.execution_time.comms import AssetEventsResult, ErrorResponse, GetAssetEventByAsset -from airflow.triggers.base import TriggerEvent +from tests_common.test_utils.version_compat import AIRFLOW_V_3_1_PLUS + +pytestmark = pytest.mark.skipif( + not AIRFLOW_V_3_1_PLUS, + reason="Asset partition trigger tests require Airflow 3.1+ Task SDK supervisor comms", +) + +if AIRFLOW_V_3_1_PLUS: + from airflow.providers.standard.triggers.asset import AssetPartitionTrigger + from airflow.sdk import timezone + from airflow.sdk.api.datamodels._generated import AssetEventResponse, AssetResponse + from airflow.sdk.exceptions import ErrorType + from airflow.sdk.execution_time import task_runner + from airflow.sdk.execution_time.comms import AssetEventsResult, ErrorResponse, GetAssetEventByAsset + from airflow.triggers.base import TriggerEvent class TestAssetPartitionTrigger: 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"