This is an automated email from the ASF dual-hosted git repository.

uranusjr pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new a785a717c10 Add documentation for the new YAML Dag format (#74748)
a785a717c10 is described below

commit a785a717c10b908378929a3983f519d9110c60e2
Author: Tzu-ping Chung <[email protected]>
AuthorDate: Mon Oct 19 13:27:48 2026 +0800

    Add documentation for the new YAML Dag format (#74748)
---
 .../dag-importers.rst                              |  25 +-
 .../docs/authoring-and-scheduling/index.rst        |   1 +
 .../docs/authoring-and-scheduling/yaml-dags.rst    | 454 +++++++++++++++++++++
 .../src/airflow/config_templates/config.yml        |   8 +-
 devel-common/src/docs/utils/conf_constants.py      |   1 +
 .../docs_build/third_party_inventories.py          |   1 +
 .../airflow/sdk/importers/yaml_importer/builder.py | 155 +++++--
 .../importers/yaml_importer/test_importer.py       |  84 ++++
 8 files changed, 683 insertions(+), 46 deletions(-)

diff --git a/airflow-core/docs/administration-and-deployment/dag-importers.rst 
b/airflow-core/docs/administration-and-deployment/dag-importers.rst
index 81b74576e76..87c813cb4d6 100644
--- a/airflow-core/docs/administration-and-deployment/dag-importers.rst
+++ b/airflow-core/docs/administration-and-deployment/dag-importers.rst
@@ -45,6 +45,10 @@ Airflow ships with two importers:
 Each :doc:`language SDK <../authoring-and-scheduling/language-sdks/index>` 
with a configured coordinator
 also registers an importer for its bundle format.
 
+Airflow also has a YAML Dag importer at 
**airflow.sdk.importers.yaml_importer.YamlDagImporter**. Unlike the
+two importers above, it is not enabled by default. Register it as shown under 
`Configuring importers`_.
+See :ref:`yaml-dags` for the document format.
+
 How Airflow uses importers
 --------------------------
 
@@ -73,18 +77,29 @@ Register importers in 
:ref:`config:dag_processor__dag_importer_configs`. Each en
 ``classpath`` of the importer and, optionally, the ``extensions`` it handles 
and the ``kwargs`` to create it
 with:
 
+.. code-block:: ini
+
+    [dag_processor]
+    dag_importer_configs = [
+        {"classpath": "airflow.sdk.importers.yaml_importer.YamlDagImporter"}
+      ]
+
+Additional configurations may be added if the importer accepts them. The YAML
+importer, for example, allows customizing the file extensions it scans (by
+default, ``.yaml`` and ``.yml``):
+
 .. code-block:: ini
 
     [dag_processor]
     dag_importer_configs = [
         {
-          "classpath": "my_company.importers.YamlDagImporter",
-          "extensions": [".yaml", ".yml"]
+          "classpath": "airflow.sdk.importers.yaml_importer.YamlDagImporter",
+          "extensions": [".yaml", ".yml", ".json"]
         }
       ]
 
-Without ``extensions``, the importer handles the extensions its class 
declares. An entry with only a
-classpath can be given as a string, for example 
``["my_company.importers.YamlDagImporter"]``.
+If no additional arguments are needed, an entry with only a classpath can also
+be given as a string, for example ``["my_company.importers.TomlDagImporter"]``.
 
 To register an importer for one bundle only, add an ``importers`` key, in the 
same format, to that bundle's
 entry in :ref:`config:dag_processor__dag_bundle_config_list`:
@@ -97,7 +112,7 @@ entry in :ref:`config:dag_processor__dag_bundle_config_list`:
           "name": "pipelines",
           "classpath": "airflow.dag_processing.bundles.local.LocalDagBundle",
           "kwargs": {"path": "/opt/airflow/pipelines"},
-          "importers": ["my_company.importers.YamlDagImporter"]
+          "importers": ["my_company.importers.TomlDagImporter"]
         }
       ]
 
diff --git a/airflow-core/docs/authoring-and-scheduling/index.rst 
b/airflow-core/docs/authoring-and-scheduling/index.rst
index 67a7ab19f0d..837f3cb40b1 100644
--- a/airflow-core/docs/authoring-and-scheduling/index.rst
+++ b/airflow-core/docs/authoring-and-scheduling/index.rst
@@ -37,6 +37,7 @@ It's recommended that you first review the pages in 
:doc:`core concepts </core-c
     assets
     dag-result
     Non-Python Task SDKs <language-sdks/index>
+    Writing Dags in YAML <yaml-dags>
 
 .. _scheduling-section:
 
diff --git a/airflow-core/docs/authoring-and-scheduling/yaml-dags.rst 
b/airflow-core/docs/authoring-and-scheduling/yaml-dags.rst
new file mode 100644
index 00000000000..2d563b5d4bd
--- /dev/null
+++ b/airflow-core/docs/authoring-and-scheduling/yaml-dags.rst
@@ -0,0 +1,454 @@
+ .. 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.
+
+.. _yaml-dags:
+
+Writing Dags in YAML
+====================
+
+|experimental|
+
+.. versionadded:: 3.4.0
+
+Airflow can build a Dag from a YAML document. One YAML document describes one
+Dag, and the YAML Dag importer turns it into the same Dag structure that 
Airflow
+runs like any other source. The tasks can be ordinary Airflow operators,
+or a pointer to a task handler implemented in a Language SDK. (Support for the
+Python Task SDK is planned for a future release.)
+
+A document is declarative. Everything in it is a value, and the shape of each
+value decides what it means.
+
+The following is an example YAML file defining a Dag:
+
+.. code-block:: yaml
+
+    $schema: https://airflow.apache.org/schemas/dag/2026-10-30.json
+    dag_id: daily_report
+    schedule: "0 3 * * *"
+    start_date: 2024-01-01
+
+    tasks:
+      - id: extract
+        uses: airflow.providers.standard.operators.bash.BashOperator
+        with:
+          bash_command:
+            $t: "extract.sh {{ ds }}"
+
+      - id: publish
+        uses: airflow.providers.standard.operators.bash.BashOperator
+        needs: [extract]
+        with:
+          bash_command: "publish.sh"
+
+This Dag runs every day at 03:00. The first task ``extract`` renders the date
+into its command. The second ``publish`` waits for ``extract`` through
+``needs`` and runs a fixed command.
+
+Enabling the importer
+---------------------
+
+The YAML importer is opt-in. Register it in
+:ref:`config:dag_processor__dag_importer_configs`:
+
+.. code-block:: ini
+
+    [dag_processor]
+    dag_importer_configs = [{"classpath": 
"airflow.sdk.importers.yaml_importer.YamlDagImporter"}]
+
+Set this same configuration on every component that loads Dags, including the
+Dag processor, the workers, and wherever ``airflow dags`` commands run.
+
+The importer handles ``.yaml`` and ``.yml`` files that declare a Dag 
``$schema``
+(see below).
+
+A document can also be written as JSON, which the YAML importer reads as-is.
+Discovery is still by file extension, so write such a document in a ``.yaml`` 
or
+``.yml`` file, or add ``.json`` to the importer's ``extensions``:
+
+.. code-block:: ini
+
+    [dag_processor]
+    dag_importer_configs = [
+        {
+          "classpath": "airflow.sdk.importers.yaml_importer.YamlDagImporter",
+          "extensions": [".yaml", ".yml", ".json"]
+        }
+      ]
+
+For per-bundle registration and other options, see
+:doc:`../administration-and-deployment/dag-importers`.
+
+The shape of a document
+-----------------------
+
+``$schema`` (required)
+    The format version the document is written against, given as the URL of a
+    published schema. The version it names selects the rules the document is
+    parsed with. ``$schema`` is also how Airflow recognizes that a file 
contains
+    Dags. By default (see :ref:`config:core__dag_discovery_safe_mode`), a file
+    that does not carry the canonical ``$schema`` URL is skipped, so a mistyped
+    URL makes the file disappear the same way. Editors that understand
+    ``$schema`` will offer validation and completion with no extra setup.
+
+Dag-level keys
+    Dag attributes sit at the top level, such as ``dag_id``, ``schedule``,
+    ``params``. An unknown key is reported as an error.
+
+``tasks``
+    A list of tasks. Each task has an ``id`` and at most one body.
+
+Task body: ``uses`` or ``run``
+    ``uses:`` names a ready-made operator by its fully-qualified Python import
+    path (e.g. ``airflow.providers.standard.operators.bash.BashOperator``).
+    ``run:`` holds the arguments of a code task you implement elsewhere (see
+    `Code tasks`_ below). A task can have one or the other. It can even have
+    neither; such task becomes an ``EmptyOperator`` by default, which is handy
+    as a join or marker point.
+
+``with``
+    Operator-specific arguments. These are what you would pass to the 
operator's
+    constructor. Read on to understand the value grammar you can use in these
+    arguments.
+
+Task-level keys
+    Scheduling and common attributes sit next to the body, distinct from
+    ``with:``. These include things like ``retries``, ``trigger_rule``,
+    ``queue``, etc. Any key that is not a structural key (``id``, ``uses``,
+    ``run``, ``with``, ``needs``, ``extends``) must be a base operator 
argument;
+    an operator-specific argument goes under ``with:`` instead, and a key that
+    is neither is an error.
+
+``needs``
+    A list of upstream tasks (referenced by ``id``) that must run before this
+    one. This describes an explicit dependency edge; XCom references in
+    ``with:`` or ``run:`` are discovered automatically and do not need to be
+    repeated here.
+
+
+Templates and extends
+---------------------
+
+Tasks often share arguments. You can define a named partial task under the
+top-level ``templates:`` key, and pull it into a task with ``extends``:
+
+.. code-block:: yaml
+
+    templates:
+      retryable:
+        retries: 2
+      shell:
+        extends: [retryable] # a template can extend other templates
+        uses: airflow.providers.standard.operators.bash.BashOperator
+        with:
+          cwd: /opt/airflow/scripts
+
+    tasks:
+      - id: extract
+        extends: [shell] # pull the template into this task
+        with:
+          bash_command: "extract.sh"
+      - id: load
+        extends: [shell]
+        with:
+          bash_command: "load.sh"
+
+Both tasks run a ``BashOperator`` with ``retries: 2`` and the shared ``cwd``,
+and add only their own ``bash_command``. A template may set anything a task 
can,
+and may itself ``extends`` other templates.
+
+The templates named in ``extends`` are applied left to right, and the task's 
own
+inline fields are applied last. Merging is shallow: ``with:`` and ``run:`` are
+combined one key at a time, while every other field is taken as a whole rather
+than deep-merged.
+
+The assembled task is validated against the real operator, so a field the
+operator does not accept is reported as an error. It is also an error if a
+non-existent template is referenced.
+
+
+Values are literal by default
+-----------------------------
+
+A value under ``with:`` (or ``run:``, with the restrictions in `Code tasks`_) 
is
+taken literally, unless it is a single-key object whose key is a reserved
+``$``-marker. For example, ``"echo foo.sh"`` and ``"{{ not rendered }}"`` are
+taken as exactly that string, not a template, even if ``bash_command`` is 
listed
+on the operator as a template field.
+
+Four markers opt a value into special handling:
+
+``{$t: "..."}`` is a template string
+    A Jinja template, rendered when the task runs. This is the only way a value
+    is templated: ``{$t: "{{ ds }}"}``, ``{$t: "{{ params.region }}"}``,
+    ``{$t: "{{ conn.my_db.host }}"}``. The long form ``{$template: "..."}`` is
+    also accepted.
+
+``{$x: id}`` references an upstream output
+    The XCom output of an upstream task. ``{$x: extract}`` references
+    ``extract``'s return value, and ``{$x: {task: extract, key: rows}}`` a
+    specific key. Binding a value this way also creates the dependency edge
+    implicitly, so a task that consumes another's output does not need to also
+    list it in ``needs:``. The long form is ``{$xcom: ...}``.
+
+``{$f: path}`` reads a file's contents
+    The contents of a file are read and used. The path is resolved against the
+    directory containing the current YAML document. A path that leaves the
+    containing directory after resolution (including symlinks) is rejected.
+    ``{$t: {$f: path}}`` allows reading the file and then rendering its 
contents
+    as a template. The long form is ``{$file: path}``. A file is loaded only
+    when you ask with ``$f``; it is never inferred from a filename suffix.
+
+``{$const: value}`` is a forced literal
+    This can be used to wrap contents that would otherwise carry a reserved 
``$``
+    meaning. For example, ``{$const: {$x: 1}}`` is the dict ``{"$x": 1}``, not
+    an XCom.
+
+The ``$t``, ``$x`` and ``$f`` markers may only be used on a field specified by
+the operator as template-able (listed in ``template_fields``). Using them on 
any
+other field causes an error. ``$const`` and plain values may be used anywhere.
+For an operator you write yourself, the operator decides which fields may 
accept
+markers; see `Using your own operators`_.
+
+The ``$`` prefix in mapping keys is reserved for these markers. Prefixing any
+other key with ``$`` causes a parse error. To include a key that must start 
with
+``$``, wrap the mapping in ``$const``.
+
+Markers nest. A marker inside a list, or inside a nested mapping such as an
+``env`` dict, is resolved by the same rules, so references and templates 
compose
+through structure:
+
+.. code-block:: yaml
+
+    with:
+      env:
+        RUN_DATE:
+          $t: "{{ ds }}" # rendered
+        REGION: us-east-1 # literal
+
+Dates, datetimes, and deltas
+----------------------------
+
+A :class:`~datetime.date` or :class:`~datetime.datetime` argument accepts an
+ISO-8601 string or a YAML-native value, and they mean the same thing:
+
+.. code-block:: yaml
+
+    start_date: "2024-01-01" # ISO-8601 string
+    # start_date: 2024-01-01 # or a YAML-native date (same value)
+    end_date: "2024-06-01T12:00:00" # ISO-8601 datetime string
+    # end_date: 2024-06-01 12:00:00 # or a YAML-native datetime (same value)
+
+A bare date on a :class:`~datetime.datetime` argument becomes midnight. An
+ISO-8601 string is parsed.
+
+A :class:`~datetime.timedelta` or 
:class:`~dateutil.relativedelta.relativedelta`
+argument accepts a duration string, written in a short form or as an ISO-8601
+duration:
+
+.. code-block:: yaml
+
+    retry_delay: 5m # short form, 5 minutes
+    execution_timeout: PT1H # ISO-8601 duration, 1 hour
+
+The short form combines ``w``, ``d``, ``h``, ``m`` and ``s`` (for example
+``1h30m``) and builds a ``timedelta``. The `ISO-8601 duration
+<https://en.wikipedia.org/wiki/ISO_8601#Durations>`_ form (``P...``) can build 
a
+``timedelta`` or ``relativedelta``. Only whole-number components are
+accepted, so a fractional value like ``PT0.5H`` is not recognized; write
+``PT30M`` instead.
+
+Each conversion applies only to an argument whose **type annotation** names the
+matching type; a value bound to any other argument keeps its original type.
+
+Using your own operators
+------------------------
+
+``uses:`` takes any operator's fully-qualified Python import path, so your own
+operator class can work exactly like a provider's.
+
+.. code-block:: yaml
+
+    - id: ingest
+      uses: my_company.operators.IngestOperator
+      trigger_rule: all_done # base arguments at the task level
+      with: # your operator's own arguments
+        source: s3://bucket/incoming
+        cutoff: 2024-01-01
+
+An operator takes two kinds of argument, kept in separate places. Your
+operator's own constructor arguments go under ``with:``. The arguments every
+operator inherits from the base operator, such as ``retries``, ``retry_delay``,
+``trigger_rule``, ``queue``, ``pool``, ``priority_weight``, 
``depends_on_past``,
+``start_date``, and the rest of the task model go at the task level beside the
+body. Placing a base argument under ``with:``, or an operator-specific one at
+the task level, is an error, so each argument has a single home.
+
+The task is constructed roughly like this:
+
+.. code-block:: python
+
+    IngestOperator(
+        task_id="ingest",
+        source="s3://bucket/incoming",
+        cutoff=datetime.datetime(2024, 1, 1, 0, 0),
+        trigger_rule="all_done",
+    )
+
+Templated argument rendering
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+A task argument is literal by default; only a value marked with ``{$t: ...}``
+is rendered as a Jinja template. As `Values are literal by default`_ notes, a
+marker may be used only on a field the operator templates, so an operator you
+write must list the field in its ``template_fields`` before a marker works on 
it.
+
+A field left unset keeps the operator's own default, including any templating
+the operator applies to that default. An operator can also derive one of its 
own
+templated fields from the arguments you give it, and that derived field still
+renders, so an unmarked value can reach a rendered field indirectly. A value is
+also never read from a file because of its name or suffix (in other words,
+Python's ``template_ext`` does not apply when the operator is used in YAML); a
+file is loaded only where you ask with ``{$f: ...}``.
+
+Literal values inside a rendered container
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+A literal also stays literal when it sits in the same list or mapping as a
+``{$t: ...}`` or ``{$x: ...}``. The marked entry renders, and the plain entries
+beside it do not. Most operators receive the finished value and never notice. 
An
+operator whose ``__init__`` reads the individual entries of such a container 
may
+notice edge cases, but operator authors should be aware that argument 
processing
+in ``__init__`` is generally considered bad practice in the first place. Such
+processing should generally be done in ``execute`` instead, which is always
+called after template rendering has been performed.
+
+Accepting a date, datetime, or delta argument
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Whether a value is converted depends on how the receiving argument is 
annotated.
+An argument annotated as a date, datetime, or delta type receives the 
conversion
+described in `Dates, datetimes, and deltas`_; every other argument is left as
+written. To let your operator take these values, annotate the arguments:
+
+.. code-block:: python
+
+    import datetime
+
+    from airflow.sdk import BaseOperator
+
+
+    class IngestOperator(BaseOperator):
+        def __init__(
+            self,
+            *,
+            cutoff: datetime.datetime | None = None,
+            retention: datetime.timedelta | None = None,
+            **kwargs,
+        ):
+            super().__init__(**kwargs)
+            self.cutoff = cutoff
+            self.retention = retention
+
+With the annotations above, ``cutoff: 2024-01-01`` arrives as
+``datetime.datetime(2024, 1, 1, 0, 0)`` and ``retention: 5d`` as
+``datetime.timedelta(days=5)``. Pendulum types are also coerced automatically.
+
+The annotation is matched by the name, so the following are all recognized
+(plus other similar variants):
+
+* ``datetime.datetime``
+* ``pendulum.DateTime``
+* ``datetime.date``
+* ``pendulum.Date``
+* ``datetime.timedelta``
+* ``dateutil.relativedelta.relativedelta``
+* ``pendulum.Duration``
+
+Unions are also parsed, so things like ``datetime | None`` are recognized too.
+A type imported under another alias is not.
+
+Reserved arguments
+~~~~~~~~~~~~~~~~~~
+
+A few names are reserved and cannot be set on a task. The task id comes from
+``id``, not ``task_id``; arguments ``dag``, ``task_group``, and 
``default_args``
+are not accepted. Use ``templates:`` and ``extends:`` for shared defaults in
+place of ``default_args``. (Support for task groups is planned for a future
+release.)
+
+Scheduling
+----------
+
+``schedule:`` accepts the forms Airflow already understands, chosen by shape:
+
+* a cron string (``"0 3 * * *"``) or a preset (``"@daily"``);
+* ``null`` or an omitted key, for a Dag that is only triggered manually or
+  externally;
+* an object ``{uses: <timetable import path>, with: {...}}`` to construct a
+  timetable, the same ``uses`` / ``with`` idea used for operators.
+
+Values under a timetable's ``with:`` receive the same date and delta coercion 
as
+operator arguments (see `Dates, datetimes, and deltas`_), matched against the
+timetable's constructor annotations. Value markers (``$t``, ``$x``, ``$f``) are
+not interpreted there since timetables do not accept execution-time resolution.
+
+Support for asset-triggering is planned for a future release.
+
+Code tasks
+----------
+
+A task can run your own code instead of a ready-made operator with ``run:``,
+which holds the code's arguments directly:
+
+.. code-block:: yaml
+
+    - id: transform
+      queue: go-workers # to route to a coordinator
+      run:
+        source: {$x: extract}
+        factor: 2
+
+A ``run:`` task is executed by a Task SDK runtime, so it must be routed to a
+coordinator. In practice, a ``run:`` task always needs a task-level ``queue:``
+mapped to a coordinator under ``[sdk] queue_to_coordinator``. See
+:doc:`language-sdks/index` for writing the handler and configuring the
+coordinator.
+
+Each argument in ``run:`` is either a JSON-serializable literal, or an upstream
+task's output, in the ``{$x: task}`` form, which also wires the dependency.
+``$f`` and ``$const`` resolve to literals. Due to restrictions imposed by the
+cross-language task handler design, the ``run:`` syntax does not support
+a template (``$t``), a ``$x`` referencing a custom XCom key (not the return
+value), or an ``$x`` nested inside a larger value.
+
+Current limitations
+-------------------
+
+Some features are not yet available when a Dag is defined in YAML. These may be
+implemented in a future release.
+
+* **Assets.** A task's ``inlets`` and ``outlets`` are rejected, and a Dag 
cannot
+  be scheduled on an asset (see `Scheduling`_).
+* **Task groups.** Tasks cannot be nested into groups (see `Reserved
+  arguments`_).
+* **Dynamic task mapping.** There is no equivalent of ``.expand()``.
+* **Arguments that must be a Python object.** This particularly affects
+  callbacks (which need a Python function).
+
+A rejected key is reported as an error rather than silently ignored, as is any
+other argument the target operator does not accept.
diff --git a/airflow-core/src/airflow/config_templates/config.yml 
b/airflow-core/src/airflow/config_templates/config.yml
index 586553e4f09..9bae77fb9bf 100644
--- a/airflow-core/src/airflow/config_templates/config.yml
+++ b/airflow-core/src/airflow/config_templates/config.yml
@@ -3345,15 +3345,15 @@ dag_processor:
         If ``extensions`` is omitted, the extensions declared by the importer 
class are used.
 
         An entry may also be given as a bare classpath string when it needs no 
other
-        settings, so ``["my_company.importers.YamlDagImporter"]`` is 
equivalent to
-        ``[{"classpath": "my_company.importers.YamlDagImporter"}]``.
+        settings, so ``["my_company.importers.TomlDagImporter"]`` is 
equivalent to
+        ``[{"classpath": "my_company.importers.TomlDagImporter"}]``.
       version_added: 3.4.0
       type: string
       example: >
         [
               {
-                "classpath": "my_company.importers.YamlDagImporter",
-                "extensions": [".yaml", ".yml"],
+                "classpath": "my_company.importers.TomlDagImporter",
+                "extensions": [".toml"],
                 "kwargs": {}
               }
             ]
diff --git a/devel-common/src/docs/utils/conf_constants.py 
b/devel-common/src/docs/utils/conf_constants.py
index fffdadea96a..fd2116bbab6 100644
--- a/devel-common/src/docs/utils/conf_constants.py
+++ b/devel-common/src/docs/utils/conf_constants.py
@@ -296,6 +296,7 @@ def get_intersphinx_mapping() -> dict[str, tuple[str, 
tuple[str]]]:
         [
             "boto3",
             "celery",
+            "dateutil",
             "docker",
             "hdfs",
             "jinja2",
diff --git a/devel-common/src/sphinx_exts/docs_build/third_party_inventories.py 
b/devel-common/src/sphinx_exts/docs_build/third_party_inventories.py
index acae9376c01..1b2fa537e0a 100644
--- a/devel-common/src/sphinx_exts/docs_build/third_party_inventories.py
+++ b/devel-common/src/sphinx_exts/docs_build/third_party_inventories.py
@@ -19,6 +19,7 @@ from __future__ import annotations
 THIRD_PARTY_INDEXES = {
     "boto3": "https://boto3.amazonaws.com/v1/documentation/api/latest";,
     "celery": "https://docs.celeryq.dev/en/stable/";,
+    "dateutil": "https://dateutil.readthedocs.io/en/stable";,
     "docker": "https://docker-py.readthedocs.io/en/stable";,
     "hdfs": "https://hdfscli.readthedocs.io/en/latest";,
     "jinja2": "https://jinja.palletsprojects.com/en/3.1.x";,
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 5baab192dfa..207ef0dc1f6 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
+import pendulum
 from dateutil.relativedelta import relativedelta
 
 from airflow.sdk import DAG, BaseOperator, XComArg, literal, task as 
task_decorators
@@ -354,12 +355,23 @@ def _coerce_temporal(mapping: dict[str, Any], target: 
type) -> dict[str, Any]:
     Coerce scalars to datetime or time delta types expected by *target*.
 
     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.
+    bound to ``datetime`` is promoted to a datetime. A YAML date becomes
+    midnight. An ISO string is parsed
 
-    An argument of neither type is left untouched, so a string that merely 
looks
-    like a date or duration keeps its type.
+    A value bound to ``date`` is parsed from an ISO string; a YAML date or
+    datetime is passed through, so a datetime keeps its time rather than being
+    narrowed.
+
+    A value bound to ``timedelta``, ``relativedelta``, or ``pendulum.Duration``
+    is parsed from a duration string.
+
+    The concrete type follows the annotation. A ``pendulum`` annotation
+    (``DateTime``, ``Date``, or ``Duration``) yields a Pendulum value, and a
+    standard library or ``dateutil`` annotation yields a value of that type. A
+    datetime passed to a ``date`` argument is the one exception, keeping its
+    stdlib type.
+
+    An argument of none of these types is left untouched.
     """
     coercer = _FieldCoercer.for_target(target)
     return {key: coercer.coerce(key, value) for key, value in mapping.items()}
@@ -382,8 +394,10 @@ class _FieldCoercer(dict):
 
     # Datetime arguments, stdlib or pendulum.
     _DATETIME_TYPE_NAMES = frozenset({"datetime", "DateTime"})
+    # Date arguments, stdlib or pendulum.
+    _DATE_TYPE_NAMES = frozenset({"date", "Date"})
     # Time-delta arguments, stdlib timedelta or dateutil relativedelta.
-    _DELTA_TYPE_NAMES = frozenset({"timedelta", "relativedelta"})
+    _DELTA_TYPE_NAMES = frozenset({"timedelta", "relativedelta", "Duration"})
 
     # ISO 8601 duration format.
     _ISO_DURATION_RE = re.compile(
@@ -405,9 +419,11 @@ class _FieldCoercer(dict):
             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):
+            if allowed := cls._match_datetime_annotation(annotation):
+                coercer[name] = functools.partial(cls._to_datetime, 
allowed=allowed)
+            elif allowed := cls._match_date_annotation(annotation):
+                coercer[name] = functools.partial(cls._to_date, 
allowed=allowed)
+            elif allowed := cls._match_delta_annotation(annotation):
                 coercer[name] = functools.partial(cls._to_delta, 
allowed=allowed)
         return coercer
 
@@ -419,61 +435,102 @@ class _FieldCoercer(dict):
         return self[name](value)
 
     @classmethod
-    def _is_datetime_annotation(cls, annotation: str) -> bool:
-        """Check if *annotation* names a datetime type."""
+    def _match_datetime_annotation(cls, annotation: str) -> frozenset[str]:
+        """Return the datetime type names *annotation* mentions, stdlib or 
pendulum."""
         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)
+        return frozenset(name for name in names if name in 
cls._DATETIME_TYPE_NAMES)
 
     @classmethod
-    def _to_datetime(cls, value: Any) -> Any:
-        if isinstance(value, datetime.datetime):
+    def _to_datetime(cls, value: Any, allowed: frozenset[str]) -> Any:
+        if isinstance(value, str):
+            try:
+                result = datetime.datetime.fromisoformat(value)
+            except ValueError:
+                return value
+        elif isinstance(value, datetime.datetime):
+            result = value
+        elif isinstance(value, datetime.date):
+            result = datetime.datetime.combine(value, datetime.time.min)
+        else:
             return value
-        if isinstance(value, datetime.date):
-            return datetime.datetime.combine(value, datetime.time.min)
+        if "DateTime" in allowed:
+            return pendulum.instance(result)
+        return result
+
+    @classmethod
+    def _match_date_annotation(cls, annotation: str) -> frozenset[str]:
+        """Return the date type names *annotation* mentions, stdlib or 
pendulum."""
+        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._DATE_TYPE_NAMES)
+
+    @classmethod
+    def _to_date(cls, value: Any, allowed: frozenset[str]) -> Any:
         if isinstance(value, str):
             try:
-                return datetime.datetime.fromisoformat(value)
+                result = datetime.date.fromisoformat(value)
             except ValueError:
                 return value
-        return value
+        elif isinstance(value, datetime.date):
+            result = value
+        else:
+            return value
+        # A datetime is a date subtype, so it is left as-is; only a plain date 
is
+        # wrapped for a ``pendulum.Date`` annotation.
+        if "Date" in allowed and type(result) is datetime.date:
+            return pendulum.instance(result)
+        return result
 
     @classmethod
-    def _is_delta_annotation(cls, annotation: str) -> frozenset[str]:
-        """Check if *annotation* names a duration delta type."""
+    def _match_delta_annotation(cls, annotation: str) -> frozenset[str]:
+        """Return the duration delta type names *annotation* mentions, stdlib, 
dateutil, or pendulum."""
         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)
 
     @classmethod
     def _to_delta(cls, value: Any, allowed: frozenset[str]) -> Any:
         """
-        Parse a duration string into a ``timedelta`` or ``relativedelta``.
+        Parse a duration string into a Python type.
+
+        This can produce a :class:`datetime.timedelta`,
+        :class:`dateutil.relativedelta.relativedelta`, or 
``pendulum.Duration``.
+
+        An ISO-8601 format duration (e.g. ``P1DT6H``, ``P1Y2M``) and a short
+        form (``5m``, ``1h30m``) are accepted. A value that cannot be parsed
+        into the target duration type is returned unchanged.
 
-        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.
+        The short form does not support specifying years or months. Similarly,
+        when ``timedelta`` is the only accepted duration type on an argument, 
an
+        ISO duration string carrying year or month components would be left
+        unparsed. A ``relativedelta`` or ``pendulum.Duration`` argument would
+        accept those components, so the same ISO duration string would be 
parsed.
 
-        A value that does not parse as a duration string is returned unchanged.
+        The concrete type follows the annotation. When more than one annotated
+        type can hold the value, a ``timedelta`` is preferred for a value 
without
+        years or months, and a ``relativedelta`` over a ``pendulum.Duration`` 
for
+        years and months (which ``pendulum.Duration`` only approximates).
         """
         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 "relativedelta" in allowed:
+                    return cls._as_relativedelta(parts)
+                if "Duration" in allowed:
+                    return cls._as_duration(parts)
+                return value
             if "timedelta" in allowed:
-                return datetime.timedelta(
-                    weeks=parts["weeks"],
-                    days=parts["days"],
-                    hours=parts["hours"],
-                    minutes=parts["minutes"],
-                    seconds=parts["seconds"],
-                )
+                return cls._as_timedelta(parts)
+            if "Duration" in allowed:
+                return cls._as_duration(parts)
             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)
+        if (parts := cls._parse_short_duration(value)) is not None:
+            if "timedelta" in allowed:
+                return datetime.timedelta(**parts)
+            if "Duration" in allowed:
+                return pendulum.Duration(**parts)
+            return value
         return value
 
     @classmethod
@@ -489,6 +546,30 @@ class _FieldCoercer(dict):
             seconds=parts["seconds"],
         )
 
+    @classmethod
+    def _as_timedelta(cls, parts: dict[str, int]) -> datetime.timedelta:
+        """Build a timedelta from duration component ints (years/months 
ignored)."""
+        return datetime.timedelta(
+            weeks=parts["weeks"],
+            days=parts["days"],
+            hours=parts["hours"],
+            minutes=parts["minutes"],
+            seconds=parts["seconds"],
+        )
+
+    @classmethod
+    def _as_duration(cls, parts: dict[str, int]) -> pendulum.Duration:
+        """Build a pendulum.Duration from duration component ints."""
+        return pendulum.Duration(
+            years=parts["years"],
+            months=parts["months"],
+            weeks=parts["weeks"],
+            days=parts["days"],
+            hours=parts["hours"],
+            minutes=parts["minutes"],
+            seconds=parts["seconds"],
+        )
+
     @classmethod
     def _parse_iso_duration(cls, value: str) -> dict[str, int] | None:
         """Parse an ISO-8601 duration into component ints."""
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 8c964c7e2c3..092c47c7f3d 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
@@ -23,6 +23,7 @@ import textwrap
 import zipfile
 from types import SimpleNamespace
 
+import pendulum
 import pytest
 from dateutil.relativedelta import relativedelta
 
@@ -82,6 +83,29 @@ class _TemporalOperator(BaseOperator):
 OP_TEMPORAL = f"{__name__}.{_TemporalOperator.__name__}"
 
 
+class _DateOperator(BaseOperator):
+    """An operator with a date-typed argument (``day``)."""
+
+    def __init__(self, *, day: datetime.date | None = None, **kwargs):
+        super().__init__(**kwargs)
+        self.day = day
+
+
+OP_DATE = f"{__name__}.{_DateOperator.__name__}"
+
+
+class _PendulumOperator(BaseOperator):
+    """An operator with pendulum-typed temporal arguments."""
+
+    def __init__(self, *, when: pendulum.DateTime | None = None, day: 
pendulum.Date | None = None, **kwargs):
+        super().__init__(**kwargs)
+        self.when = when
+        self.day = day
+
+
+OP_PENDULUM = f"{__name__}.{_PendulumOperator.__name__}"
+
+
 class _PeriodOperator(BaseOperator):
     """An operator with time-period-typed arguments."""
 
@@ -91,12 +115,14 @@ class _PeriodOperator(BaseOperator):
         gap: datetime.timedelta | None = None,
         window: relativedelta | None = None,
         either: datetime.timedelta | relativedelta | None = None,
+        span: pendulum.Duration | None = None,
         **kwargs,
     ):
         super().__init__(**kwargs)
         self.gap = gap
         self.window = window
         self.either = either
+        self.span = span
 
 
 OP_PERIOD = f"{__name__}.{_PeriodOperator.__name__}"
@@ -300,6 +326,43 @@ def test_import_coerces_temporal_with_arg(mock_bundle):
     assert (cutoff.year, cutoff.month, cutoff.day) == (2024, 1, 1)
 
 
[email protected](
+    ("value", "exp_type"),
+    [
+        ("2024-01-01", datetime.date),  # YAML date -> plain date
+        ("'2024-01-01'", datetime.date),  # ISO date string -> plain date
+        ("2024-01-01T03:30:00", datetime.datetime),  # a datetime is a date 
subtype, left as-is
+    ],
+)
+def test_import_coerces_date_with_arg(mock_bundle, value, exp_type):
+    definition = _def(
+        mock_bundle,
+        "dag.yaml",
+        _doc(f"- {{id: a, uses: {OP_DATE}, with: {{day: {value}}}}}"),
+    )
+    result = YamlDagImporter().import_definition(definition, 
bundle=mock_bundle)
+    assert result.success, result.errors
+    day = result.dags[0].get_task("a").day
+    assert type(day) is exp_type
+    assert (day.year, day.month, day.day) == (2024, 1, 1)
+
+
+def test_import_coerces_pendulum_temporal_args(mock_bundle):
+    # a pendulum.DateTime / pendulum.Date annotation yields pendulum values, 
not stdlib
+    definition = _def(
+        mock_bundle,
+        "dag.yaml",
+        _doc(f"- {{id: a, uses: {OP_PENDULUM}, with: {{when: 
2024-01-01T03:30:00, day: 2024-01-01}}}}"),
+    )
+    result = YamlDagImporter().import_definition(definition, 
bundle=mock_bundle)
+    assert result.success, result.errors
+    task = result.dags[0].get_task("a")
+    assert type(task.when) is pendulum.DateTime
+    assert (task.when.year, task.when.month, task.when.day, task.when.hour) == 
(2024, 1, 1, 3)
+    assert type(task.day) is pendulum.Date
+    assert (task.day.year, task.day.month, task.day.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(
@@ -325,6 +388,27 @@ def 
test_import_coerces_relativedelta_with_arg(mock_bundle):
     assert result.dags[0].get_task("a").window == relativedelta(years=1, 
months=2)
 
 
+def test_import_coerces_pendulum_duration_with_arg(mock_bundle):
+    # a pendulum.Duration arg accepts the short form and an ISO duration 
carrying
+    # years/months (which a timedelta cannot represent)
+    definition = _def(
+        mock_bundle,
+        "dag.yaml",
+        _doc(
+            f"- {{id: a, uses: {OP_PERIOD}, with: {{span: 5m}}}}\n"
+            f"- {{id: b, uses: {OP_PERIOD}, with: {{span: P1Y2M}}}}",
+        ),
+    )
+    result = YamlDagImporter().import_definition(definition, 
bundle=mock_bundle)
+    assert result.success, result.errors
+    span_a = result.dags[0].get_task("a").span
+    span_b = result.dags[0].get_task("b").span
+    assert type(span_a) is pendulum.Duration
+    assert span_a == pendulum.Duration(minutes=5)
+    assert type(span_b) is pendulum.Duration
+    assert (span_b.years, span_b.months) == (1, 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

Reply via email to