This is an automated email from the ASF dual-hosted git repository.
vatsrahul1001 pushed a commit to branch v3-4-test
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/v3-4-test by this push:
new fe36adbcb75 [v3-4-test] Implement delta argument coercion (#74749)
(#74959)
fe36adbcb75 is described below
commit fe36adbcb75f575670290a3150276ac0a9c00287
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Sun Oct 18 15:07:37 2026 +0530
[v3-4-test] Implement delta argument coercion (#74749) (#74959)
(cherry picked from commit 5b32faffff8988ddefc7768eb0d01a90bf7d226c)
Co-authored-by: Tzu-ping Chung <[email protected]>
---
.../airflow/sdk/importers/yaml_importer/builder.py | 215 ++++++++++++++++-----
.../importers/yaml_importer/test_importer.py | 117 +++++++++++
2 files changed, 282 insertions(+), 50 deletions(-)
diff --git a/task-sdk/src/airflow/sdk/importers/yaml_importer/builder.py
b/task-sdk/src/airflow/sdk/importers/yaml_importer/builder.py
index 02d49c6a8ff..5baab192dfa 100644
--- a/task-sdk/src/airflow/sdk/importers/yaml_importer/builder.py
+++ b/task-sdk/src/airflow/sdk/importers/yaml_importer/builder.py
@@ -44,6 +44,7 @@ import re
from typing import TYPE_CHECKING, Any, NamedTuple, cast
import attrs
+from dateutil.relativedelta import relativedelta
from airflow.sdk import DAG, BaseOperator, XComArg, literal, task as
task_decorators
from airflow.sdk._shared.module_loading import import_string
@@ -176,15 +177,15 @@ class DagBuilder:
op_cls = import_string(task.uses)
template_fields = set(getattr(op_cls, "template_fields", ()))
- temporal = _temporal_fields(op_cls)
+ coercer = _FieldCoercer.for_target(op_cls)
kwargs: dict[str, Any] = {}
render_fields: list[str] = []
off_template: list[str] = []
for name, value in task.with_.items():
needs_render, has_marker = _field_markers(value)
lowered = self._lower(value, protect=needs_render)
- if name in temporal and not has_marker: # a literal date/ISO
string for a datetime arg
- lowered = _to_datetime(lowered)
+ if not has_marker:
+ lowered = coercer.coerce(name, lowered)
kwargs[name] = lowered
if has_marker and name not in template_fields:
off_template.append(name)
@@ -192,9 +193,10 @@ class DagBuilder:
render_fields.append(name)
if off_template:
raise YamlDagBuildError(
- f"task {task.id_!r}: value grammar markers are only allowed on
templated fields, not {off_template}"
+ f"task {task.id_!r}: value grammar markers are only allowed "
+ f"on templated fields, not {off_template}"
)
- if task.model_extra: # Merge in task-level arguments (raw, never
templated).
+ if task.model_extra: # Merge in task-level arguments (never
templated).
kwargs.update(_coerce_temporal(task.model_extra, BaseOperator))
return _restrict_template_fields(op_cls(task_id=task.id_, **kwargs),
task.with_, render_fields)
@@ -349,69 +351,182 @@ def _reject_reserved_extra_keys(extras: Any) -> None:
def _coerce_temporal(mapping: dict[str, Any], target: type) -> dict[str, Any]:
"""
- Promote YAML-native dates and ISO date/datetime strings to datetimes.
+ Coerce scalars to datetime or time delta types expected by *target*.
- Some fields on a Dag or operator are datetimes, but YAML has distinct date
- and date-time formats, so a bare ``2024-01-01`` or a quoted ISO string
would
- otherwise reach the datetime field unconverted. A date becomes midnight; an
- ISO string is parsed (which also helps JSON compatibility).
+ Coercion follows how each argument is typed in the argument list. A value
+ bound to ``datetime`` is promoted to a datetime; a YAML date becomes
+ midnight, an ISO string is parsed. A value bound to ``timedelta`` or
+ ``relativedelta`` is parsed from a duration string.
- Only values bound to a *target* argument annotated as ``datetime`` are
- coerced. A non-temporal argument is left untouched, so a string that merely
- looks like a date keeps its type.
+ An argument of neither type is left untouched, so a string that merely
looks
+ like a date or duration keeps its type.
"""
- temporal = _temporal_fields(target)
- return {key: _to_datetime(value) if key in temporal else value for key,
value in mapping.items()}
+ coercer = _FieldCoercer.for_target(target)
+ return {key: coercer.coerce(key, value) for key, value in mapping.items()}
-def _to_datetime(value: Any) -> Any:
- if isinstance(value, datetime.datetime):
- return value
- if isinstance(value, datetime.date):
- return datetime.datetime.combine(value, datetime.time.min)
- if isinstance(value, str):
+class _FieldCoercer(dict):
+ """
+ Maps an argument name to the coercion for its declared type.
+
+ This is built from a Dag or operator's constructor signature. An argument
+ with no known coercion is simply absent, and ``__missing__`` supplies an
+ identity no-op coercion.
+
+ A coercion is chosen by an argument's type annotation (by text matching,
not
+ resolved, to reduce calculation cost). This is not theoratically perfect,
+ but should be plenty good enough in practice.
+ """
+
+ _TYPE_NAME_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_.]*")
+
+ # Datetime arguments, stdlib or pendulum.
+ _DATETIME_TYPE_NAMES = frozenset({"datetime", "DateTime"})
+ # Time-delta arguments, stdlib timedelta or dateutil relativedelta.
+ _DELTA_TYPE_NAMES = frozenset({"timedelta", "relativedelta"})
+
+ # ISO 8601 duration format.
+ _ISO_DURATION_RE = re.compile(
+ r"^P(?=[\dYMWDT])(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?"
+ r"(?:T(?=[\dHMS])(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$"
+ )
+
+ # Short-hand duration format (e.g. 5m, 1h).
+ _SHORT_DURATION_RE =
re.compile(r"^(?=\d)(?:(\d+)w)?(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$")
+
+ @classmethod
+ @functools.cache
+ def for_target(cls, target: type) -> _FieldCoercer:
+ """Build the coercer for *target*'s constructor arguments."""
+ coercer = cls()
try:
- return datetime.datetime.fromisoformat(value)
- except ValueError:
+ params = inspect.signature(target).parameters
+ except (TypeError, ValueError):
+ return coercer
+ for name, param in params.items():
+ annotation = str(param.annotation)
+ if cls._is_datetime_annotation(annotation):
+ coercer[name] = cls._to_datetime
+ elif allowed := cls._is_delta_annotation(annotation):
+ coercer[name] = functools.partial(cls._to_delta,
allowed=allowed)
+ return coercer
+
+ def __missing__(self, name: str) -> Callable[[Any], Any]:
+ return lambda value: value
+
+ def coerce(self, name: str, value: Any) -> Any:
+ """Coerce *value* for argument *name*."""
+ return self[name](value)
+
+ @classmethod
+ def _is_datetime_annotation(cls, annotation: str) -> bool:
+ """Check if *annotation* names a datetime type."""
+ names = (ref.rsplit(".", 1)[-1] for ref in
cls._TYPE_NAME_RE.findall(annotation))
+ return any(name in cls._DATETIME_TYPE_NAMES for name in names)
+
+ @classmethod
+ def _to_datetime(cls, value: Any) -> Any:
+ if isinstance(value, datetime.datetime):
return value
- return value
+ if isinstance(value, datetime.date):
+ return datetime.datetime.combine(value, datetime.time.min)
+ if isinstance(value, str):
+ try:
+ return datetime.datetime.fromisoformat(value)
+ except ValueError:
+ return value
+ return value
+ @classmethod
+ def _is_delta_annotation(cls, annotation: str) -> frozenset[str]:
+ """Check if *annotation* names a duration delta type."""
+ names = (ref.rsplit(".", 1)[-1] for ref in
cls._TYPE_NAME_RE.findall(annotation))
+ return frozenset(name for name in names if name in
cls._DELTA_TYPE_NAMES)
-# Airflow annotates arguments with either stdlib datetime or pendulum DateTime.
-# Matched by name (see _temporal_fields) rather than resolved to avoid having
-# to evaluate dynamically.
-_TEMPORAL_TYPE_NAMES = frozenset({"datetime", "DateTime"})
-_TYPE_NAME_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_.]*")
+ @classmethod
+ def _to_delta(cls, value: Any, allowed: frozenset[str]) -> Any:
+ """
+ Parse a duration string into a ``timedelta`` or ``relativedelta``.
+ An ISO-8601 format duration (e.g. ``P1DT6H``, ``P1Y2M``) is accepted
for
+ both stdlib timedelta and dateutil relativedelta arguments; a short
form
+ (``5m``, ``1h30m``) is also accepted for timedelta. Years and months
+ need a relativedelta, so an ISO duration carrying them is left
untouched
+ unless relativedelta is allowed.
[email protected]
-def _temporal_fields(target: type) -> frozenset[str]:
- """
- Names of *target*'s constructor arguments annotated as a datetime type.
-
- The annotation text is matched against the datetime type names Airflow
uses;
- it is never resolved, so one referring to a ``TYPE_CHECKING``-only name
does
- not need importing. The constructor signature is read -- not class
- attributes -- so attrs field aliases and operator-specific arguments are
- covered. This is not perfect (a datetime type reached under another alias
is
- missed); we'll fix edge cases as we go.
- """
- try:
- params = inspect.signature(target).parameters
- except (TypeError, ValueError):
- return frozenset()
- return frozenset(name for name, p in params.items() if
_is_temporal_annotation(str(p.annotation)))
+ A value that does not parse as a duration string is returned unchanged.
+ """
+ if not isinstance(value, str):
+ return value
+ if (parts := cls._parse_iso_duration(value)) is not None:
+ if parts["years"] or parts["months"]:
+ return cls._as_relativedelta(parts) if "relativedelta" in
allowed else value
+ if "timedelta" in allowed:
+ return datetime.timedelta(
+ weeks=parts["weeks"],
+ days=parts["days"],
+ hours=parts["hours"],
+ minutes=parts["minutes"],
+ seconds=parts["seconds"],
+ )
+ if "relativedelta" in allowed:
+ return cls._as_relativedelta(parts)
+ return value
+ if "timedelta" in allowed and (parts :=
cls._parse_short_duration(value)) is not None:
+ return datetime.timedelta(**parts)
+ return value
+ @classmethod
+ def _as_relativedelta(cls, parts: dict[str, int]) -> relativedelta:
+ """Build a relativedelta from duration component ints."""
+ return relativedelta(
+ years=parts["years"],
+ months=parts["months"],
+ weeks=parts["weeks"],
+ days=parts["days"],
+ hours=parts["hours"],
+ minutes=parts["minutes"],
+ seconds=parts["seconds"],
+ )
-def _is_temporal_annotation(annotation: str) -> bool:
- """Return True if *annotation* names a datetime type, ignoring any module
prefix."""
- return any(ref.rsplit(".", 1)[-1] in _TEMPORAL_TYPE_NAMES for ref in
_TYPE_NAME_RE.findall(annotation))
+ @classmethod
+ def _parse_iso_duration(cls, value: str) -> dict[str, int] | None:
+ """Parse an ISO-8601 duration into component ints."""
+ match = cls._ISO_DURATION_RE.match(value)
+ if match is None or not any(match.groups()):
+ return None
+ years, months, weeks, days, hours, minutes, seconds = match.groups()
+ return {
+ "years": int(years or 0),
+ "months": int(months or 0),
+ "weeks": int(weeks or 0),
+ "days": int(days or 0),
+ "hours": int(hours or 0),
+ "minutes": int(minutes or 0),
+ "seconds": int(seconds or 0),
+ }
+
+ @classmethod
+ def _parse_short_duration(cls, value: str) -> dict[str, int] | None:
+ """Parse a short duration (e.g. ``1h30m``) into timedelta kwargs."""
+ match = cls._SHORT_DURATION_RE.match(value)
+ if match is None or not any(match.groups()):
+ return None
+ weeks, days, hours, minutes, seconds = match.groups()
+ return {
+ "weeks": int(weeks or 0),
+ "days": int(days or 0),
+ "hours": int(hours or 0),
+ "minutes": int(minutes or 0),
+ "seconds": int(seconds or 0),
+ }
def _build_schedule(schedule: Any) -> Any:
"""Build *schedule* into a schedule construct for :class:`DAG`."""
if isinstance(schedule, TimetableSchedule):
- return import_string(schedule.uses)(**schedule.with_)
+ timetable_cls = import_string(schedule.uses)
+ return timetable_cls(**_coerce_temporal(schedule.with_, timetable_cls))
return schedule
diff --git a/task-sdk/tests/task_sdk/importers/yaml_importer/test_importer.py
b/task-sdk/tests/task_sdk/importers/yaml_importer/test_importer.py
index 84b7c53511a..8c964c7e2c3 100644
--- a/task-sdk/tests/task_sdk/importers/yaml_importer/test_importer.py
+++ b/task-sdk/tests/task_sdk/importers/yaml_importer/test_importer.py
@@ -24,6 +24,7 @@ import zipfile
from types import SimpleNamespace
import pytest
+from dateutil.relativedelta import relativedelta
from airflow.sdk import BaseOperator, XComArg
from airflow.sdk.definitions._internal.templater import LiteralValue
@@ -81,6 +82,26 @@ class _TemporalOperator(BaseOperator):
OP_TEMPORAL = f"{__name__}.{_TemporalOperator.__name__}"
+class _PeriodOperator(BaseOperator):
+ """An operator with time-period-typed arguments."""
+
+ def __init__(
+ self,
+ *,
+ gap: datetime.timedelta | None = None,
+ window: relativedelta | None = None,
+ either: datetime.timedelta | relativedelta | None = None,
+ **kwargs,
+ ):
+ super().__init__(**kwargs)
+ self.gap = gap
+ self.window = window
+ self.either = either
+
+
+OP_PERIOD = f"{__name__}.{_PeriodOperator.__name__}"
+
+
@pytest.fixture
def mock_bundle(tmp_path):
bundle_dir = tmp_path / "bundle"
@@ -279,6 +300,102 @@ def test_import_coerces_temporal_with_arg(mock_bundle):
assert (cutoff.year, cutoff.month, cutoff.day) == (2024, 1, 1)
+def test_import_coerces_timedelta_with_arg(mock_bundle):
+ # a timedelta-typed argument accepts the short form and ISO-8601
+ definition = _def(
+ mock_bundle,
+ "dag.yaml",
+ _doc(f"""
+ - {{id: a, uses: {OP_PERIOD}, with: {{gap: 1h30m}}}}
+ - {{id: b, uses: {OP_PERIOD}, with: {{gap: PT90M}}}}
+ """),
+ )
+ result = YamlDagImporter().import_definition(definition,
bundle=mock_bundle)
+ assert result.success, result.errors
+ assert result.dags[0].get_task("a").gap == datetime.timedelta(hours=1,
minutes=30)
+ assert result.dags[0].get_task("b").gap == datetime.timedelta(minutes=90)
+
+
+def test_import_coerces_relativedelta_with_arg(mock_bundle):
+ definition = _def(
+ mock_bundle, "dag.yaml", _doc(f"- {{id: a, uses: {OP_PERIOD}, with:
{{window: P1Y2M}}}}")
+ )
+ result = YamlDagImporter().import_definition(definition,
bundle=mock_bundle)
+ assert result.success, result.errors
+ assert result.dags[0].get_task("a").window == relativedelta(years=1,
months=2)
+
+
+def test_import_period_union_picks_type_by_components(mock_bundle):
+ # for an arg that accepts both, an ISO duration with months/years becomes
a relativedelta,
+ # otherwise a timedelta
+ definition = _def(
+ mock_bundle,
+ "dag.yaml",
+ _doc(f"""
+ - {{id: a, uses: {OP_PERIOD}, with: {{either: P1D}}}}
+ - {{id: b, uses: {OP_PERIOD}, with: {{either: P1M}}}}
+ """),
+ )
+ result = YamlDagImporter().import_definition(definition,
bundle=mock_bundle)
+ assert result.success, result.errors
+ assert result.dags[0].get_task("a").either == datetime.timedelta(days=1)
+ assert result.dags[0].get_task("b").either == relativedelta(months=1)
+
+
+def test_import_does_not_coerce_unparseable_duration(mock_bundle):
+ # months need a relativedelta, so P1M on a timedelta-only arg is left
as-is; the short form is
+ # timedelta-only, so it is left as-is on a relativedelta arg
+ definition = _def(
+ mock_bundle,
+ "dag.yaml",
+ _doc(f"""
+ - {{id: a, uses: {OP_PERIOD}, with: {{gap: P1M}}}}
+ - {{id: b, uses: {OP_PERIOD}, with: {{window: 5m}}}}
+ """),
+ )
+ result = YamlDagImporter().import_definition(definition,
bundle=mock_bundle)
+ assert result.success, result.errors
+ assert result.dags[0].get_task("a").gap == "P1M"
+ assert result.dags[0].get_task("b").window == "5m"
+
+
+def test_import_coerces_task_level_timedelta(mock_bundle):
+ definition = _def(
+ mock_bundle, "dag.yaml", _doc(f"- {{id: a, uses: {OP}, with:
{{payload: x}}, retry_delay: PT5M}}")
+ )
+ result = YamlDagImporter().import_definition(definition,
bundle=mock_bundle)
+ assert result.success, result.errors
+ assert result.dags[0].get_task("a").retry_delay ==
datetime.timedelta(minutes=5)
+
+
+def test_import_coerces_dag_level_timedelta(mock_bundle):
+ definition = _def(
+ mock_bundle,
+ "dag.yaml",
+ _doc(f"- {{id: a, uses: {OP}, with: {{payload: x}}}}",
extra="dagrun_timeout: 5m\n"),
+ )
+ result = YamlDagImporter().import_definition(definition,
bundle=mock_bundle)
+ assert result.success, result.errors
+ assert result.dags[0].dagrun_timeout == datetime.timedelta(minutes=5)
+
+
+def test_import_schedule_timetable_coerces_delta(mock_bundle):
+ definition = _def(
+ mock_bundle,
+ "dag.yaml",
+ _doc(
+ f"- {{id: a, uses: {OP}, with: {{payload: x}}}}",
+ extra=(
+ "schedule: {uses: airflow.sdk.definitions.timetables.interval."
+ "DeltaDataIntervalTimetable, with: {delta: P1D}}\n"
+ ),
+ ),
+ )
+ result = YamlDagImporter().import_definition(definition,
bundle=mock_bundle)
+ assert result.success, result.errors
+ assert result.dags[0].timetable.delta == datetime.timedelta(days=1)
+
+
def test_import_code_task_builds_stub_operator(mock_bundle):
definition = _def(
mock_bundle,