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 337642574a6 YAML Dag file parser (#74084)
337642574a6 is described below

commit 337642574a653e54f111ff675adce0adc49ed918
Author: Tzu-ping Chung <[email protected]>
AuthorDate: Mon Oct 12 18:54:20 2026 +0800

    YAML Dag file parser (#74084)
---
 .pre-commit-config.yaml                            |  14 +
 .../ci/prek/check_yaml_importer_schema_versions.py | 137 +++++
 scripts/ci/prek/generate_yaml_importer_schema.py   |  66 +++
 task-sdk/pyproject.toml                            |   1 +
 .../sdk/importers/yaml_importer/__init__.py        |  32 ++
 .../sdk/importers/yaml_importer/migrator.py        | 168 ++++++
 .../airflow/sdk/importers/yaml_importer/models.py  | 459 ++++++++++++++++
 .../airflow/sdk/importers/yaml_importer/parser.py  | 102 ++++
 .../sdk/importers/yaml_importer/schema.json        | 365 +++++++++++++
 .../importers/yaml_importer/versions/__init__.py   |  38 ++
 .../task_sdk/importers/yaml_importer/__init__.py   |  16 +
 .../importers/yaml_importer/example_dag.yaml       |  69 +++
 .../importers/yaml_importer/test_migrator.py       | 188 +++++++
 .../importers/yaml_importer/test_parser.py         | 576 +++++++++++++++++++++
 14 files changed, 2231 insertions(+)

diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 3bbe42de850..3a2c3fb9661 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1649,6 +1649,20 @@ repos:
         pass_filenames: true
         files: 
^(task-sdk/src/airflow/sdk/execution_time/(comms\.py|schema/.*\.py)|airflow-core/src/airflow/dag_processing/processor\.py)$
         require_serial: true
+      - id: generate-yaml-importer-schema-snapshot
+        name: Regenerate YAML DAG importer schema snapshot
+        entry: uv run -p 3.12 --frozen --no-progress --project task-sdk -s 
scripts/ci/prek/generate_yaml_importer_schema.py
+        language: python
+        pass_filenames: false
+        files: 
^task-sdk/src/airflow/sdk/importers/yaml_importer/(models|parser|versions/.*)\.py$
+        require_serial: true
+      - id: check-yaml-importer-schema-versions
+        name: Check YAML DAG importer schema changes have corresponding 
version updates
+        entry: ./scripts/ci/prek/check_yaml_importer_schema_versions.py
+        language: python
+        pass_filenames: true
+        files: 
^task-sdk/src/airflow/sdk/importers/yaml_importer/(models\.py|schema\.json|versions/.*\.py)$
+        require_serial: true
       - id: generate-tasksdk-datamodels
         name: Generate Datamodels for TaskSDK client
         language: python
diff --git a/scripts/ci/prek/check_yaml_importer_schema_versions.py 
b/scripts/ci/prek/check_yaml_importer_schema_versions.py
new file mode 100755
index 00000000000..d2190867485
--- /dev/null
+++ b/scripts/ci/prek/check_yaml_importer_schema_versions.py
@@ -0,0 +1,137 @@
+#!/usr/bin/env python
+# 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.
+# /// script
+# requires-python = ">=3.10,<3.11"
+# dependencies = [
+#   "rich>=13.6.0",
+# ]
+# ///
+"""
+Fail when the YAML Dag schema changes without a matching version update.
+
+This mirrors :mod:`scripts.ci.prek.check_supervisor_schemas_versions` for the
+YAML Cadwyn bundle. Documents pin their format to a published ``$schema``, so a
+change to the generated snapshot that is not paired with a version update would
+silently reinterpret already-pinned documents once the current head date ships.
+
+The check is per-commit; if a schema-affecting source files or the committed
+snapshot changed, but nothing under ``versions/`` did, the hook asks the author
+to record the change as a new dated version. A ``versions/`` change is trusted.
+
+The comparison is against the committed ``schema.json`` on the target branch;
+the paired ``generate-yaml-importer-schema-snapshot`` hook guarantees the
+snapshot tracks the models, so the committed file is the source of truth here.
+"""
+
+from __future__ import annotations
+
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+from common_prek_utils import console, get_remote_for_main
+
+SCHEMA_DIR = "task-sdk/src/airflow/sdk/importers/yaml_importer/"
+SNAPSHOT_PATH = SCHEMA_DIR + "schema.json"
+MODELS_PATH = SCHEMA_DIR + "models.py"
+VERSIONS_PREFIX = SCHEMA_DIR + "versions/"
+
+
+def get_target_branch() -> str:
+    """Branch to compare against. GITHUB_BASE_REF for PRs, DEFAULT_BRANCH in 
CI, else main."""
+    return os.environ.get("GITHUB_BASE_REF") or 
os.environ.get("DEFAULT_BRANCH") or "main"
+
+
+def get_changed_files(filenames: list[str]) -> list[str]:
+    """Get changed files. Uses filenames from prek when provided, else staged 
files for local runs."""
+    if filenames:
+        return filenames
+    result = subprocess.run(
+        ["git", "diff", "--cached", "--name-only"],
+        capture_output=True,
+        text=True,
+        check=True,
+    )
+    return [f for f in result.stdout.strip().splitlines() if f]
+
+
+def _target_ref() -> str:
+    """Fetch and return the target-branch ref to compare against."""
+    remote = get_remote_for_main()
+    target_branch = get_target_branch()
+    subprocess.run(["git", "fetch", remote, target_branch], 
capture_output=True, check=False)
+    return f"{remote}/{target_branch}"
+
+
+def _snapshot_on_target(ref: str) -> str | None:
+    """Return the committed ``schema.json`` at *ref*, or ``None`` if it does 
not exist there yet."""
+    result = subprocess.run(
+        ["git", "show", f"{ref}:{SNAPSHOT_PATH}"],
+        capture_output=True,
+        text=True,
+        check=False,
+    )
+    return result.stdout if result.returncode == 0 else None
+
+
+def main() -> int:
+    changed_files = get_changed_files(sys.argv[1:])
+    schema_source_files = [f for f in changed_files if f in (MODELS_PATH, 
SNAPSHOT_PATH)]
+    version_files = [f for f in changed_files if f.startswith(VERSIONS_PREFIX)]
+
+    if not schema_source_files:
+        return 0
+    if version_files:
+        # A version update accompanies the change -- trust the contributor.
+        return 0
+
+    ref = _target_ref()
+    target_snapshot = _snapshot_on_target(ref)
+    if target_snapshot is None:
+        # The snapshot is being introduced in this PR -- nothing on the target
+        # branch to compare against. The check activates once it merges.
+        console.print(
+            "[yellow]Skipping YAML importer schema version check:[/] target 
branch "
+            "has no schema snapshot yet. The check activates once this PR 
merges."
+        )
+        return 0
+
+    if Path(SNAPSHOT_PATH).read_text() != target_snapshot:
+        console.print("[bold red]ERROR:[/] YAML importer schema changed but no 
version was updated.")
+        console.print("")
+        console.print("The following files were changed:")
+        for f in schema_source_files:
+            console.print(f"  - [magenta]{f}[/]")
+        console.print("")
+        console.print(
+            f"The committed snapshot differs from [cyan]{ref}[/].\n"
+            "\n"
+            "Once a schema date is released, changing it reinterprets 
documents pinned to that\n"
+            "version. Record the change as a new dated version carrying a 
``VersionChange`` under:\n"
+            f"  [cyan]{VERSIONS_PREFIX}[/]\n"
+            "(as the supervisor and execution-api bundles do), then regenerate 
the snapshot."
+        )
+        return 1
+
+    console.print("[green]Snapshot unchanged:[/] source changes do not affect 
the YAML importer schema.")
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/scripts/ci/prek/generate_yaml_importer_schema.py 
b/scripts/ci/prek/generate_yaml_importer_schema.py
new file mode 100755
index 00000000000..e785ea9826d
--- /dev/null
+++ b/scripts/ci/prek/generate_yaml_importer_schema.py
@@ -0,0 +1,66 @@
+#!/usr/bin/env python
+# 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.
+
+"""
+Regenerate the YAML Dag importer schema snapshot.
+
+The snapshot is the head-version JSON Schema generated from Pydantic models for
+the head ``$schema`` version (from the Cadwyn bundle). If the committed 
snapshot
+at ``task-sdk/src/airflow/sdk/importers/yaml_importer/schema.json`` differs 
from
+the freshly generated content, the hook rewrites it and exits non-zero.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import sys
+from pathlib import Path
+
+os.environ["_AIRFLOW__AS_LIBRARY"] = "1"
+
+from airflow.sdk.importers.yaml_importer.models import DagDocument
+from airflow.sdk.importers.yaml_importer.versions import get_bundle
+
+SNAPSHOT_PATH = (
+    Path(__file__)
+    .parents[3]
+    .joinpath("task-sdk", "src", "airflow", "sdk", "importers", 
"yaml_importer", "schema.json")
+)
+
+
+def build_schema() -> str:
+    """Assemble the published head-version JSON Schema from the pydantic 
models."""
+    head = get_bundle().versions[0].value  # newest-first
+    schema = DagDocument.model_json_schema(by_alias=True)
+    schema["$schema"] = "https://json-schema.org/draft/2020-12/schema";
+    schema["title"] = f"Airflow YAML Dag ({head})"
+    return json.dumps(schema, indent=2) + "\n"
+
+
+def main() -> int:
+    new_content = build_schema()
+    if SNAPSHOT_PATH.exists() and SNAPSHOT_PATH.read_text() == new_content:
+        return 0
+    SNAPSHOT_PATH.write_text(new_content)
+    print(f"Regenerated {SNAPSHOT_PATH.name}. Please review the diff and 
re-stage the file.")
+    return 1
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/task-sdk/pyproject.toml b/task-sdk/pyproject.toml
index 653d407f3d0..0ce16d4b713 100644
--- a/task-sdk/pyproject.toml
+++ b/task-sdk/pyproject.toml
@@ -52,6 +52,7 @@ dependencies = [
     "asgiref>=3.11.1; python_version >= '3.14'",
     "attrs>=24.2.0, !=25.2.0",
     "babel>=2.17.0",
+    "cadwyn>=6.1.1",
     "fsspec>=2023.10.0",
     "httpx>=0.27.0",
     "jinja2>=3.1.5",
diff --git a/task-sdk/src/airflow/sdk/importers/yaml_importer/__init__.py 
b/task-sdk/src/airflow/sdk/importers/yaml_importer/__init__.py
new file mode 100644
index 00000000000..2f229838802
--- /dev/null
+++ b/task-sdk/src/airflow/sdk/importers/yaml_importer/__init__.py
@@ -0,0 +1,32 @@
+# 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.
+
+"""Native YAML/JSON Dag format for Airflow."""
+
+from __future__ import annotations
+
+from airflow.sdk.importers.yaml_importer.models import DagDocument
+from airflow.sdk.importers.yaml_importer.parser import (
+    YamlDagParseError,
+    parse_documents,
+)
+
+__all__ = [
+    "DagDocument",
+    "YamlDagParseError",
+    "parse_documents",
+]
diff --git a/task-sdk/src/airflow/sdk/importers/yaml_importer/migrator.py 
b/task-sdk/src/airflow/sdk/importers/yaml_importer/migrator.py
new file mode 100644
index 00000000000..9a27c07e4b9
--- /dev/null
+++ b/task-sdk/src/airflow/sdk/importers/yaml_importer/migrator.py
@@ -0,0 +1,168 @@
+# 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.
+"""
+Schema version resolution and forward migration to the head shape.
+
+A document with an older ``$schema`` is migrated to the head shape by walking
+the Cadwyn version bundle and applying each ``VersionChange``'s forward request
+converters (``@convert_request_to_next_version_for(DagDocument)``), oldest to
+newest.
+
+Only whole-document request converters keyed on ``DagDocument`` are applied; a
+bundle carrying any other cadwyn instruction is rejected at construction rather
+than migrating a document with half its changes applied. (See documentation on
+:meth:`DagDocumentMigrator._reject_unsupported_instructions` for rationale.)
+"""
+
+from __future__ import annotations
+
+import copy
+import functools
+import re
+from typing import TYPE_CHECKING, Any
+
+import attrs
+
+from airflow.sdk.importers.yaml_importer.models import DagDocument
+from airflow.sdk.importers.yaml_importer.versions import get_bundle
+
+if TYPE_CHECKING:
+    from cadwyn import VersionBundle
+
+_SCHEMA_URL = "https://airflow.apache.org/schemas/dag/{date}.json";
+# The canonical ``$schema`` URL with the version captured; keep in sync with 
``_SCHEMA_URL``.
+_SCHEMA_URL_RE = 
re.compile(r"https://airflow\.apache\.org/schemas/dag/(\d{4}-\d{2}-\d{2})\.json")
+
+
+def schema_from_version(date: str) -> str:
+    """Build the canonical ``$schema`` URL for a published version *date*."""
+    return _SCHEMA_URL.format(date=date)
+
+
+def version_from_schema(schema: str) -> str | None:
+    """Read the version (``YYYY-MM-DD``) out of a canonical ``$schema`` URL, 
or ``None``."""
+    match = _SCHEMA_URL_RE.fullmatch(schema)
+    return match.group(1) if match else None
+
+
[email protected]
+class _RequestInfo:
+    """
+    Duck-type stand-in for Cadwyn's ``RequestInfo``.
+
+    ``cadwyn.structure.data._AlterDataInstruction.__call__`` only reads
+    and writes ``info.body``; the by-schema transformers we drive never
+    touch FastAPI's Request/Response. Passing this minimal object lets
+    us run cadwyn's migrations from a pure in-process code path with no
+    HTTP stack.
+    """
+
+    body: dict[str, Any]
+
+
+def _calculate_version_values(migrator: DagDocumentMigrator) -> frozenset[str]:
+    return frozenset(v.value for v in migrator._bundle.versions)
+
+
[email protected]
+class DagDocumentMigrator:
+    """YAML Dag document migrator; pins each document to its ``$schema`` 
version."""
+
+    _bundle: VersionBundle
+    _version_values: frozenset[str] = attrs.field(
+        init=False,
+        default=attrs.Factory(_calculate_version_values, takes_self=True),
+    )
+
+    def __attrs_post_init__(self) -> None:
+        self._reject_unsupported_instructions()
+
+    def _reject_unsupported_instructions(self) -> None:
+        """
+        Fail if the bundle carries unsupported migrations.
+
+        Only forward request converters keyed on :class:`DagDocument` may run.
+        This is due to difficulties posed by things like templates and XCom,
+        which can present in nested locations inside task arguments. 
Technically
+        we could just ban model-level migration only for them, but that's
+        considered unnecessarily complicated since you can already do all
+        per-model-level changes on :class:`DagDocument`. We can relax this
+        restriction in the future if the ergonomics prove to be suboptimal.
+        """
+        unsupported = (
+            "alter_endpoint_instructions",
+            "alter_enum_instructions",
+            "alter_request_by_path_instructions",
+            "alter_response_by_path_instructions",
+            "alter_response_by_schema_instructions",
+            "alter_schema_instructions",
+        )
+        for version in self._bundle.versions:
+            for change in version.changes:
+                name = change.__name__
+                other_models = 
set(change.alter_request_by_schema_instructions) - {DagDocument}
+                if other_models:
+                    raise RuntimeError(
+                        f"{name}: request converters for {sorted(m.__name__ 
for m in other_models)} "
+                        f"are not applied; only whole-document 
{DagDocument.__name__} converters "
+                        f"are supported"
+                    )
+                if used := [b for b in unsupported if getattr(change, b, 
None)]:
+                    raise RuntimeError(
+                        f"{name}: unsupported cadwyn instructions {used}; only 
"
+                        f"{DagDocument.__name__} request converters are 
applied"
+                    )
+
+    def resolve_version(self, version: str | None) -> str:
+        """Validate *version* is a published version in the bundle."""
+        if version is None or version not in self._version_values:
+            raise ValueError(f"$schema version {version!r} is not valid")
+        return version
+
+    def resolve_and_migrate(self, body: dict[str, Any]) -> dict[str, Any]:
+        """
+        Resolve *body*'s ``$schema`` version and migrate it to the head shape.
+
+        The version token is read from the ``$schema`` URL. Every valid
+        (published) version stays in the bundle and is migrated forward. An
+        unknown version is rejected.
+
+        :raises ValueError: if the ``$schema`` version is not valid.
+        :return: The migrated result. If *body* is already at head, it is
+            returned as-is; otherwise a migrated copy is returned.
+        """
+        if (schema_version := version_from_schema(raw_schema := 
body["$schema"])) is None:
+            raise ValueError(
+                f"$schema {raw_schema!r} is not a canonical schema URL "
+                f"(expected {_SCHEMA_URL.format(date='YYYY-MM-DD')!r})"
+            )
+        if (source_version := self.resolve_version(schema_version)) == 
self._bundle.versions[0].value:
+            return body
+        info = _RequestInfo(copy.deepcopy(dict(body)))
+        for version in self._bundle.reversed_versions:
+            if version.value <= source_version:
+                continue
+            for change in version.changes:
+                for instruction in 
change.alter_request_by_schema_instructions.get(DagDocument, ()):
+                    instruction(info)  # type: ignore[arg-type]
+        return info.body
+
+
[email protected]
+def get_migrator() -> DagDocumentMigrator:
+    """Get the process-wide migrator bound to the format's version bundle."""
+    return DagDocumentMigrator(get_bundle())
diff --git a/task-sdk/src/airflow/sdk/importers/yaml_importer/models.py 
b/task-sdk/src/airflow/sdk/importers/yaml_importer/models.py
new file mode 100644
index 00000000000..94fdd50f852
--- /dev/null
+++ b/task-sdk/src/airflow/sdk/importers/yaml_importer/models.py
@@ -0,0 +1,459 @@
+# 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.
+"""
+Pydantic models for the YAML/JSON Dag format.
+
+These are the single source of truth for the format; they validate an authored
+document (rich Pydantic errors) and back the published JSON Schema. Only the
+format skeleton and value grammar are described; operator ``with:`` args,
+Dag- and task-level attributes pass through untyped because the Python
+constructors validate those at import time.
+"""
+
+from __future__ import annotations
+
+import collections
+import contextlib
+import datetime
+import itertools
+from typing import TYPE_CHECKING, Annotated, Any, ClassVar
+
+from pydantic import (
+    AliasChoices,
+    BaseModel,
+    ConfigDict,
+    Discriminator,
+    Field,
+    RootModel,
+    Tag,
+    model_validator,
+)
+
+if TYPE_CHECKING:
+    from pydantic import GetJsonSchemaHandler
+    from pydantic.json_schema import JsonSchemaValue
+    from pydantic_core import CoreSchema
+
+XCOM_KEYS = ("$x", "$xcom")
+TEMPLATE_KEYS = ("$t", "$template")
+CONST_KEY = "$const"
+
+# Keys deferred to a later edition...
+DAG_CALLBACK_KEYS = {"on_success_callback", "on_failure_callback"}
+TASK_CALLBACK_KEYS = {
+    "on_success_callback",
+    "on_failure_callback",
+    "on_retry_callback",
+    "on_execute_callback",
+    "on_skipped_callback",
+}
+TASK_ASSET_IO_KEYS = {"inlets", "outlets"}
+
+
+def _as_exclusive_union(schema: JsonSchemaValue) -> JsonSchemaValue:
+    """
+    Rewrite a disjoint ``anyOf`` union in place to ``oneOf``.
+
+    Pydantic emits ``anyOf`` (one or more match) for ``X | Y`` unions, but when
+    the branches are mutually exclusive, ``oneOf`` (exactly one match) accepts
+    the same values but generates better client code.
+
+    Only call this on unions whose branches are provably disjoint; on an
+    overlapping union, ``oneOf`` incorrectly rejects valid values.
+
+    This operation is performed in-place. The same modified object is returned.
+    """
+    with contextlib.suppress(KeyError):
+        schema["oneOf"] = schema.pop("anyOf")
+    return schema
+
+
+class XComTarget(BaseModel):
+    """The object form of an XCom reference."""
+
+    task: str
+    key: str | None = None
+
+    model_config = ConfigDict(extra="forbid")
+
+    @classmethod
+    def __get_pydantic_json_schema__(
+        cls, core_schema: CoreSchema, handler: GetJsonSchemaHandler
+    ) -> JsonSchemaValue:
+        schema = handler(core_schema)
+        _as_exclusive_union(schema["properties"]["key"])
+        return schema
+
+
+class _MarkerRef(BaseModel):
+    """Base for a reserved ``$``-marker object."""
+
+    model_config = ConfigDict(extra="forbid")
+
+    _marker_keys: ClassVar[tuple[str, ...]]
+
+    @classmethod
+    def __get_pydantic_json_schema__(
+        cls, core_schema: CoreSchema, handler: GetJsonSchemaHandler
+    ) -> JsonSchemaValue:
+        schema = handler(core_schema)
+        value_schema = _as_exclusive_union(next(iter(schema.get("properties", 
{}).values())))
+        schema["properties"] = {key: value_schema for key in cls._marker_keys}
+        schema["additionalProperties"] = False
+        if len(cls._marker_keys) > 1:  # Pydantic sets the correct 'required' 
for the single-variant case.
+            schema.pop("required", None)
+            schema["oneOf"] = [{"required": [key]} for key in cls._marker_keys]
+        return schema
+
+
+class XComRef(_MarkerRef):
+    """An upstream task's XCom output."""
+
+    _marker_keys = XCOM_KEYS
+
+    target: str | XComTarget = Field(
+        serialization_alias="$x",
+        validation_alias=AliasChoices(*XCOM_KEYS),
+    )
+
+
+class TemplateRef(_MarkerRef):
+    """A Jinja template."""
+
+    _marker_keys = TEMPLATE_KEYS
+
+    source: str = Field(serialization_alias="$t", 
validation_alias=AliasChoices(*TEMPLATE_KEYS))
+
+
+class ConstRef(_MarkerRef):
+    """Force a literal; the value is taken verbatim."""
+
+    _marker_keys = (CONST_KEY,)
+
+    value: Any = Field(serialization_alias="$const", 
validation_alias=AliasChoices(CONST_KEY))
+
+
+def _value_discriminator(v: Any) -> str:
+    """
+    Route a raw value to a marker branch, or to ``literal``.
+
+    The prefix ``$`` is reserved to express Airflow constructs. To prevent
+    typos, any unknown ``$``-prefixed strings are routed here and rejected.
+
+    If such a key is needed, it can be wrapped in ``$const``, which would
+    avoid this discriminator (only used by :class:`Literal`).
+    """
+    if isinstance(v, dict) and len(v) == 1:
+        (key,) = v
+        if key in XCOM_KEYS:
+            return "xcom"
+        if key in TEMPLATE_KEYS:
+            return "template"
+        if key == CONST_KEY:
+            return "const"
+    if isinstance(v, XComRef):
+        return "xcom"
+    if isinstance(v, TemplateRef):
+        return "template"
+    if isinstance(v, ConstRef):
+        return "const"
+    return "literal"
+
+
+class Literal(RootModel):
+    """
+    A literal value.
+
+    Containers are validated recursively so nested markers resolve; scalars 
pass
+    through. A ``$``-prefixed key here is a misused marker, so it is rejected.
+    """
+
+    root: dict[str, Value] | list[Value] | str | int | float | bool | 
datetime.datetime | datetime.date | None
+
+    model_config = ConfigDict(
+        json_schema_extra={
+            "not": {"type": "object", "not": {"patternProperties": {r"^\$": 
False}}},
+            "description": "A literal value.",
+        }
+    )
+
+    @model_validator(mode="before")
+    @classmethod
+    def _reject_reserved_keys(cls, v: Any) -> Any:
+        if isinstance(v, dict):
+            if (res := next((k for k in v if isinstance(k, str) and 
k.startswith("$")), None)) is not None:
+                raise ValueError(
+                    f"unexpected key {res!r}: '$'-prefixed keys are reserved "
+                    f"markers; wrap a literal '$' key in $const"
+                )
+        return v
+
+    @classmethod
+    def __get_pydantic_json_schema__(
+        cls, core_schema: CoreSchema, handler: GetJsonSchemaHandler
+    ) -> JsonSchemaValue:
+        # Pydantic generates a pretty messy schema from this. Clean it up since
+        # essentially we accept "whatever the format can implicitly accept".
+        schema = handler(core_schema)
+        schema["oneOf"] = [
+            *(b for b in schema.pop("anyOf") if b.get("type") in ("object", 
"array")),
+            {"type": "boolean"},
+            {"type": "null"},
+            {"type": "number"},
+            {"type": "string"},
+        ]
+        return schema
+
+
+Value = Annotated[
+    Annotated[XComRef, Tag("xcom")]
+    | Annotated[TemplateRef, Tag("template")]
+    | Annotated[ConstRef, Tag("const")]
+    | Annotated[Literal, Tag("literal")],
+    Discriminator(_value_discriminator),
+]
+
+Literal.model_rebuild()
+
+
+class Task(BaseModel):
+    """
+    A ready-made operator (``uses``) or task with custom code (``run``).
+
+    At most one of ``uses`` or ``run`` may carry a body; ``null`` or omission
+    means no body. A task with neither will be turned by the importer into an
+    ``EmptyOperator``. Unknown keys pass through untyped as operator arguments.
+    """
+
+    id_: str = Field(alias="id", min_length=1)
+    needs: list[str] = Field(default_factory=list)
+    extends: list[str] = Field(default_factory=list)
+    with_: dict[str, Value] = Field(default_factory=dict, alias="with")
+    uses: str | None = Field(default=None, min_length=1)
+    run: dict[str, Value] | None = None
+
+    model_config = ConfigDict(
+        extra="allow",
+        json_schema_extra={"description": "A ready-made operator (uses) or 
task with custom code (run)."},
+    )
+
+    @model_validator(mode="before")
+    @classmethod
+    def _check_task(cls, data: Any) -> Any:
+        if isinstance(data, dict):
+            for k in TASK_ASSET_IO_KEYS:
+                if k in data:
+                    raise ValueError(f"{k!r} not yet implemented")
+            for k in TASK_CALLBACK_KEYS:
+                if k in data:
+                    raise ValueError(f"{k!r} (callback) not yet implemented")
+            if sum(1 for k in ("uses", "run") if data.get(k) is not None) > 1:
+                raise ValueError(f"task {data.get('id')!r} may not set both 
'uses' and 'run'")
+        return data
+
+    @classmethod
+    def __get_pydantic_json_schema__(
+        cls, core_schema: CoreSchema, handler: GetJsonSchemaHandler
+    ) -> JsonSchemaValue:
+        schema = handler(core_schema)
+        value_schema = schema["properties"]["with"]["additionalProperties"]
+        schema["properties"]["uses"] = {"type": ["string", "null"], 
"minLength": 1}
+        schema["properties"]["run"] = {"type": ["object", "null"], 
"additionalProperties": value_schema}
+        schema["properties"]["extends"] = {
+            "oneOf": [{"type": "string"}, {"type": "array", "items": {"type": 
"string"}}]
+        }
+        schema["not"] = {
+            "allOf": [
+                {"required": ["uses"], "properties": {"uses": {"type": 
"string"}}},
+                {"required": ["run"], "properties": {"run": {"type": 
"object"}}},
+            ]
+        }
+        return schema
+
+
+class TaskTemplate(BaseModel):
+    """
+    A reusable, partial task fragment merged into any task that ``extends`` it.
+
+    This holds arbitrary keys as-is. All validation is done on the merged task.
+    """
+
+    model_config = ConfigDict(
+        extra="allow",
+        json_schema_extra={
+            "description": (
+                "A reusable, partial task fragment merged into any task that "
+                "extends it.\n\nThis holds arbitrary keys as-is. All "
+                "validation is done on the merged task."
+            ),
+        },
+    )
+
+
+class TimetableSchedule(BaseModel):
+    """A constructed timetable."""
+
+    uses: str = Field(min_length=1)
+    with_: dict[str, Any] = Field(default_factory=dict, alias="with")
+
+    model_config = ConfigDict(extra="forbid")
+
+
+Schedule = str | None | TimetableSchedule
+
+
+def _merge_task(base: dict, over: dict) -> dict:
+    """
+    Shallow merge *over* onto *base*.
+
+    Note that ``with`` and ``run`` need to merge one level deep. All other
+    fields simply replace.
+    """
+    out = base.copy()
+    for k, v in over.items():
+        if k in ("with", "run") and isinstance(v, dict) and 
isinstance(out.get(k), dict):
+            merged = dict(out[k])
+            merged.update(v)
+            out[k] = merged
+        else:
+            out[k] = v
+    return out
+
+
+def _validate_extends(value: Any, *, where: str) -> list[str]:
+    """
+    Return *value* as the list of template names it must be, or raise a clear 
``ValueError``.
+
+    A bare string is accepted as shorthand for a single-element list.
+    """
+    if value is None:
+        return []
+    if isinstance(value, str):
+        return [value]
+    if not isinstance(value, list) or not all(isinstance(name, str) for name 
in value):
+        raise ValueError(f"{where}: 'extends' must be a list of template 
names, got {value!r}")
+    return value
+
+
+def _expand_template(name: str, templates: dict[str, Any], _seen: tuple = ()) 
-> dict:
+    if name in _seen:
+        raise ValueError(f"template cycle via {name!r}")
+    try:
+        spec = templates[name]
+    except KeyError:
+        raise ValueError(f"unknown template {name!r}") from None
+    if not isinstance(spec, dict):
+        raise ValueError(f"template {name!r} must be a mapping")
+    acc: dict = {}
+    for parent in _validate_extends(spec.get("extends"), where=f"template 
{name!r}"):
+        acc = _merge_task(acc, _expand_template(parent, templates, _seen + 
(name,)))
+    own = {k: v for k, v in spec.items() if k != "extends"}
+    return _merge_task(acc, own)
+
+
+def _iter_xcom_task_ids(value: Any):
+    """Yield task ids referenced by any XComRef inside a resolved value."""
+    if isinstance(value, XComRef):
+        yield value.target if isinstance(value.target, str) else 
value.target.task
+    elif isinstance(value, Literal):
+        yield from _iter_xcom_task_ids(value.root)
+    elif isinstance(value, dict):
+        for v in value.values():
+            yield from _iter_xcom_task_ids(v)
+    elif isinstance(value, list):
+        for v in value:
+            yield from _iter_xcom_task_ids(v)
+
+
+class DagDocument(BaseModel):
+    """
+    A Dag represented by one YAML document.
+
+    Extra top-level keys are Dag-level arguments.
+    """
+
+    schema_: str = Field(alias="$schema")
+    dag_id: str = Field(min_length=1)
+    schedule: Schedule = None
+    templates: dict[str, TaskTemplate] = Field(default_factory=dict)
+    tasks: list[Task] = Field(default_factory=list)
+
+    model_config = ConfigDict(extra="allow")
+
+    @classmethod
+    def __get_pydantic_json_schema__(
+        cls, core_schema: CoreSchema, handler: GetJsonSchemaHandler
+    ) -> JsonSchemaValue:
+        schema = handler(core_schema)
+        _as_exclusive_union(schema["properties"]["schedule"])
+        return schema
+
+    @model_validator(mode="before")
+    @classmethod
+    def _reject_deferred(cls, data: Any) -> Any:
+        if isinstance(data, dict):
+            if "default_args" in data:
+                raise ValueError("default_args is not supported; use 
'templates' with 'extends' instead")
+            for k in DAG_CALLBACK_KEYS:
+                if k in data:
+                    raise ValueError(f"Dag-level {k!r} not yet implemented")
+        return data
+
+    @model_validator(mode="before")
+    @classmethod
+    def _resolve_extends(cls, data: Any) -> Any:
+        """Merge in each task's ``extends`` templates before task 
validation."""
+        if not isinstance(data, dict):
+            return data
+        templates = data.get("templates")
+        if templates is None:
+            templates = {}
+        tasks = data.get("tasks")
+        # Only merge when the shapes are as expected. A malformed input is left
+        # untouched, so normal field validation reports it as a 
ValidationError.
+        if not isinstance(templates, dict) or not isinstance(tasks, list):
+            return data
+        resolved = []
+        for task in tasks:
+            if not isinstance(task, dict) or "extends" not in task:
+                resolved.append(task)
+                continue
+            if not (extends := _validate_extends(task["extends"], where=f"task 
{task.get('id')!r}")):
+                resolved.append(task)
+                continue
+            merged: dict = {}
+            for name in extends:
+                merged = _merge_task(merged, _expand_template(name, templates))
+            resolved.append(_merge_task(merged, {k: v for k, v in task.items() 
if k != "extends"}))
+        return {**data, "tasks": resolved}
+
+    @model_validator(mode="after")
+    def _check_refs(self):
+        """Check task ID uniqueness and ensure dependencies are internal."""
+        task_id_counts = collections.Counter(t.id_ for t in self.tasks)
+        if duplicates := sorted(i for i, n in task_id_counts.items() if n > 1):
+            raise ValueError(f"duplicate task id(s): {duplicates}")
+        validated_task_ids = set(task_id_counts)
+        for t in self.tasks:
+            refs = set(t.needs)
+            for v in itertools.chain(t.with_.values(), (t.run or {}).values()):
+                refs.update(_iter_xcom_task_ids(v))
+            if t.id_ in refs:
+                raise ValueError(f"task {t.id_!r} depends on itself")
+            if missing := (refs - validated_task_ids):
+                raise ValueError(f"task {t.id_!r} references unknown task(s): 
{sorted(missing)}")
+        return self
diff --git a/task-sdk/src/airflow/sdk/importers/yaml_importer/parser.py 
b/task-sdk/src/airflow/sdk/importers/yaml_importer/parser.py
new file mode 100644
index 00000000000..13b9fe30c2a
--- /dev/null
+++ b/task-sdk/src/airflow/sdk/importers/yaml_importer/parser.py
@@ -0,0 +1,102 @@
+# 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.
+
+"""Parse a YAML/JSON Dag file into validated :class:`~.models.DagDocument` 
objects."""
+
+from __future__ import annotations
+
+import collections.abc
+from typing import TYPE_CHECKING, Any
+
+import yaml
+from pydantic import ValidationError
+
+from airflow.sdk.importers.yaml_importer import migrator
+from airflow.sdk.importers.yaml_importer.models import DagDocument
+
+if TYPE_CHECKING:
+    from collections.abc import Hashable, Iterator
+    from typing import IO
+
+    from yaml.nodes import MappingNode
+
+
+class _UniqueKeySafeLoader(yaml.SafeLoader):
+    """
+    A ``SafeLoader`` that rejects duplicate mapping keys.
+
+    PyYAML keeps the last value for a repeated key, so a second ``tasks:`` 
block
+    or a second ``with:`` or ``needs:`` on a task would silently drop the 
first.
+    This can be a footgun for a hand-edited format, so we raise a
+    ``ConstructorError`` instead of silently shadowing.
+    """
+
+    def construct_mapping(self, node: MappingNode, deep: bool = False) -> 
dict[Hashable, Any]:
+        seen: set[Hashable] = set()
+        for key_node, _ in node.value:
+            # Let the superclass expand merge keys (<<). It's a valid use case
+            # for the mapping to explicitly override a key supplied by a merge.
+            if key_node.tag == "tag:yaml.org,2002:merge":
+                continue
+            key = self.construct_object(key_node, deep=deep)
+            if not isinstance(key, collections.abc.Hashable):
+                continue
+            if key not in seen:
+                seen.add(key)
+                continue
+            raise yaml.constructor.ConstructorError(
+                "while constructing a mapping",
+                node.start_mark,
+                f"found duplicate key {key!r}",
+                key_node.start_mark,
+            )
+        return super().construct_mapping(node, deep=deep)
+
+
+class YamlDagParseError(ValueError):
+    """A document could not be parsed or did not conform to the format."""
+
+
+def _resolve_and_migrate(raw: dict[str, Any], *, source: str) -> dict[str, 
Any]:
+    """Resolve the ``$schema`` version and migrate to the head shape."""
+    if not isinstance(raw, collections.abc.Mapping):
+        raise YamlDagParseError(f"{source}: a Dag document must be a mapping, 
got {type(raw).__name__}")
+    if "$schema" not in raw:
+        raise YamlDagParseError(f"{source}: missing required key '$schema'")
+    schema = raw["$schema"]
+    if not isinstance(schema, str) or not schema:
+        raise YamlDagParseError(f"{source}: '$schema' must be a non-empty 
string, got {schema!r}")
+    try:
+        return migrator.get_migrator().resolve_and_migrate(raw)
+    except ValueError as exc:  # unresolvable $schema version; bundle-config 
errors are not ValueError
+        raise YamlDagParseError(f"{source}: {exc}") from exc
+
+
+def parse_documents(stream: str | IO[str], *, source: str = "<string>") -> 
Iterator[DagDocument]:
+    """Parse each document of *stream* into a :class:`DagDocument`."""
+    try:
+        for pos, raw in enumerate(yaml.load_all(stream, _UniqueKeySafeLoader)):
+            if raw is None:  # empty document between --- separators
+                continue
+            where = source if pos == 0 else f"{source}[doc {pos}]"
+            migrated = _resolve_and_migrate(raw, source=where)
+            try:
+                yield DagDocument.model_validate(migrated)
+            except ValidationError as exc:
+                raise YamlDagParseError(f"{where}: {exc}") from exc
+    except yaml.YAMLError as exc:  # a syntax error surfaced while streaming a 
document
+        raise YamlDagParseError(f"{source}: invalid YAML: {exc}") from exc
diff --git a/task-sdk/src/airflow/sdk/importers/yaml_importer/schema.json 
b/task-sdk/src/airflow/sdk/importers/yaml_importer/schema.json
new file mode 100644
index 00000000000..953c61b6ccc
--- /dev/null
+++ b/task-sdk/src/airflow/sdk/importers/yaml_importer/schema.json
@@ -0,0 +1,365 @@
+{
+  "$defs": {
+    "ConstRef": {
+      "additionalProperties": false,
+      "description": "Force a literal; the value is taken verbatim.",
+      "properties": {
+        "$const": {
+          "title": "$Const"
+        }
+      },
+      "required": [
+        "$const"
+      ],
+      "title": "ConstRef",
+      "type": "object"
+    },
+    "Literal": {
+      "description": "A literal value.",
+      "not": {
+        "not": {
+          "patternProperties": {
+            "^\\$": false
+          }
+        },
+        "type": "object"
+      },
+      "oneOf": [
+        {
+          "additionalProperties": {
+            "oneOf": [
+              {
+                "$ref": "#/$defs/XComRef"
+              },
+              {
+                "$ref": "#/$defs/TemplateRef"
+              },
+              {
+                "$ref": "#/$defs/ConstRef"
+              },
+              {
+                "$ref": "#/$defs/Literal"
+              }
+            ]
+          },
+          "type": "object"
+        },
+        {
+          "items": {
+            "oneOf": [
+              {
+                "$ref": "#/$defs/XComRef"
+              },
+              {
+                "$ref": "#/$defs/TemplateRef"
+              },
+              {
+                "$ref": "#/$defs/ConstRef"
+              },
+              {
+                "$ref": "#/$defs/Literal"
+              }
+            ]
+          },
+          "type": "array"
+        },
+        {
+          "type": "boolean"
+        },
+        {
+          "type": "null"
+        },
+        {
+          "type": "number"
+        },
+        {
+          "type": "string"
+        }
+      ],
+      "title": "Literal"
+    },
+    "Task": {
+      "additionalProperties": true,
+      "description": "A ready-made operator (uses) or task with custom code 
(run).",
+      "not": {
+        "allOf": [
+          {
+            "properties": {
+              "uses": {
+                "type": "string"
+              }
+            },
+            "required": [
+              "uses"
+            ]
+          },
+          {
+            "properties": {
+              "run": {
+                "type": "object"
+              }
+            },
+            "required": [
+              "run"
+            ]
+          }
+        ]
+      },
+      "properties": {
+        "id": {
+          "minLength": 1,
+          "title": "Id",
+          "type": "string"
+        },
+        "needs": {
+          "items": {
+            "type": "string"
+          },
+          "title": "Needs",
+          "type": "array"
+        },
+        "extends": {
+          "oneOf": [
+            {
+              "type": "string"
+            },
+            {
+              "items": {
+                "type": "string"
+              },
+              "type": "array"
+            }
+          ]
+        },
+        "with": {
+          "additionalProperties": {
+            "oneOf": [
+              {
+                "$ref": "#/$defs/XComRef"
+              },
+              {
+                "$ref": "#/$defs/TemplateRef"
+              },
+              {
+                "$ref": "#/$defs/ConstRef"
+              },
+              {
+                "$ref": "#/$defs/Literal"
+              }
+            ]
+          },
+          "title": "With",
+          "type": "object"
+        },
+        "uses": {
+          "minLength": 1,
+          "type": [
+            "string",
+            "null"
+          ]
+        },
+        "run": {
+          "additionalProperties": {
+            "oneOf": [
+              {
+                "$ref": "#/$defs/XComRef"
+              },
+              {
+                "$ref": "#/$defs/TemplateRef"
+              },
+              {
+                "$ref": "#/$defs/ConstRef"
+              },
+              {
+                "$ref": "#/$defs/Literal"
+              }
+            ]
+          },
+          "type": [
+            "object",
+            "null"
+          ]
+        }
+      },
+      "required": [
+        "id"
+      ],
+      "title": "Task",
+      "type": "object"
+    },
+    "TaskTemplate": {
+      "additionalProperties": true,
+      "description": "A reusable, partial task fragment merged into any task 
that extends it.\n\nThis holds arbitrary keys as-is. All validation is done on 
the merged task.",
+      "properties": {},
+      "title": "TaskTemplate",
+      "type": "object"
+    },
+    "TemplateRef": {
+      "additionalProperties": false,
+      "description": "A Jinja template.",
+      "oneOf": [
+        {
+          "required": [
+            "$t"
+          ]
+        },
+        {
+          "required": [
+            "$template"
+          ]
+        }
+      ],
+      "properties": {
+        "$t": {
+          "title": "$T",
+          "type": "string"
+        },
+        "$template": {
+          "title": "$T",
+          "type": "string"
+        }
+      },
+      "title": "TemplateRef",
+      "type": "object"
+    },
+    "TimetableSchedule": {
+      "additionalProperties": false,
+      "description": "A constructed timetable.",
+      "properties": {
+        "uses": {
+          "minLength": 1,
+          "title": "Uses",
+          "type": "string"
+        },
+        "with": {
+          "additionalProperties": true,
+          "title": "With",
+          "type": "object"
+        }
+      },
+      "required": [
+        "uses"
+      ],
+      "title": "TimetableSchedule",
+      "type": "object"
+    },
+    "XComRef": {
+      "additionalProperties": false,
+      "description": "An upstream task's XCom output.",
+      "oneOf": [
+        {
+          "required": [
+            "$x"
+          ]
+        },
+        {
+          "required": [
+            "$xcom"
+          ]
+        }
+      ],
+      "properties": {
+        "$x": {
+          "oneOf": [
+            {
+              "type": "string"
+            },
+            {
+              "$ref": "#/$defs/XComTarget"
+            }
+          ],
+          "title": "$X"
+        },
+        "$xcom": {
+          "oneOf": [
+            {
+              "type": "string"
+            },
+            {
+              "$ref": "#/$defs/XComTarget"
+            }
+          ],
+          "title": "$X"
+        }
+      },
+      "title": "XComRef",
+      "type": "object"
+    },
+    "XComTarget": {
+      "additionalProperties": false,
+      "description": "The object form of an XCom reference.",
+      "properties": {
+        "task": {
+          "title": "Task",
+          "type": "string"
+        },
+        "key": {
+          "default": null,
+          "oneOf": [
+            {
+              "type": "string"
+            },
+            {
+              "type": "null"
+            }
+          ],
+          "title": "Key"
+        }
+      },
+      "required": [
+        "task"
+      ],
+      "title": "XComTarget",
+      "type": "object"
+    }
+  },
+  "additionalProperties": true,
+  "description": "A Dag represented by one YAML document.\n\nExtra top-level 
keys are Dag-level arguments.",
+  "properties": {
+    "$schema": {
+      "title": "$Schema",
+      "type": "string"
+    },
+    "dag_id": {
+      "minLength": 1,
+      "title": "Dag Id",
+      "type": "string"
+    },
+    "schedule": {
+      "default": null,
+      "oneOf": [
+        {
+          "type": "string"
+        },
+        {
+          "$ref": "#/$defs/TimetableSchedule"
+        },
+        {
+          "type": "null"
+        }
+      ],
+      "title": "Schedule"
+    },
+    "templates": {
+      "additionalProperties": {
+        "$ref": "#/$defs/TaskTemplate"
+      },
+      "title": "Templates",
+      "type": "object"
+    },
+    "tasks": {
+      "items": {
+        "$ref": "#/$defs/Task"
+      },
+      "title": "Tasks",
+      "type": "array"
+    }
+  },
+  "required": [
+    "$schema",
+    "dag_id"
+  ],
+  "title": "Airflow YAML Dag (2026-10-30)",
+  "type": "object",
+  "$schema": "https://json-schema.org/draft/2020-12/schema";
+}
diff --git 
a/task-sdk/src/airflow/sdk/importers/yaml_importer/versions/__init__.py 
b/task-sdk/src/airflow/sdk/importers/yaml_importer/versions/__init__.py
new file mode 100644
index 00000000000..d5259fcf4fc
--- /dev/null
+++ b/task-sdk/src/airflow/sdk/importers/yaml_importer/versions/__init__.py
@@ -0,0 +1,38 @@
+# 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.
+"""
+Cadwyn ``VersionBundle`` for the YAML Dag format keyed by ``$schema``.
+
+The ``cadwyn`` import is deferred, so importing this package does not pull in
+FastAPI/Starlette. Versions are newest-first.
+"""
+
+from __future__ import annotations
+
+import functools
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+    from cadwyn import VersionBundle
+
+
[email protected]
+def get_bundle() -> VersionBundle:
+    """Build the format's ``VersionBundle`` lazily (newest-to-oldest)."""
+    from cadwyn import HeadVersion, Version, VersionBundle
+
+    return VersionBundle(HeadVersion(), Version("2026-10-30"))
diff --git a/task-sdk/tests/task_sdk/importers/yaml_importer/__init__.py 
b/task-sdk/tests/task_sdk/importers/yaml_importer/__init__.py
new file mode 100644
index 00000000000..13a83393a91
--- /dev/null
+++ b/task-sdk/tests/task_sdk/importers/yaml_importer/__init__.py
@@ -0,0 +1,16 @@
+# 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.
diff --git a/task-sdk/tests/task_sdk/importers/yaml_importer/example_dag.yaml 
b/task-sdk/tests/task_sdk/importers/yaml_importer/example_dag.yaml
new file mode 100644
index 00000000000..fdd25fa71a6
--- /dev/null
+++ b/task-sdk/tests/task_sdk/importers/yaml_importer/example_dag.yaml
@@ -0,0 +1,69 @@
+# 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.
+
+# Fixture: a realistic two-document YAML Dag for the parser integration test.
+---
+$schema: https://airflow.apache.org/schemas/dag/2026-10-30.json
+dag_id: retail_daily_sales
+schedule: "0 3 * * *"
+start_date: "2025-01-01"
+catchup: false
+tags: [retail, etl]
+params:
+  target_schema: public
+templates:
+  retryable:
+    retries: 2
+    retry_delay: "5m"
+  redshift_load:
+    extends: [retryable]
+    uses: 
airflow.providers.amazon.aws.transfers.s3_to_redshift.S3ToRedshiftOperator
+    with:
+      schema: {$t: "{{ params.target_schema }}"}
+      redshift_conn_id: redshift_default
+tasks:
+  - id: wait_for_export
+    extends: [retryable]
+    uses: airflow.providers.standard.sensors.time_delta.TimeDeltaSensor
+    with: {delta: "1h"}
+  - id: load_sales
+    extends: [redshift_load]
+    needs: [wait_for_export]
+    with:
+      table: sales
+      s3_key: {$t: "sales/{{ ds }}.csv"}
+---
+$schema: https://airflow.apache.org/schemas/dag/2026-10-30.json
+dag_id: events_pipeline
+schedule: "@hourly"
+start_date: "2025-01-01"
+catchup: false
+tasks:
+  - id: extract
+    run: {}
+    queue: extract-workers
+  - id: transform
+    run:
+      region: "eu"
+      events: {$x: extract}
+    queue: jvm-workers
+  - id: load
+    uses: 
airflow.providers.amazon.aws.transfers.s3_to_redshift.S3ToRedshiftOperator
+    needs: [transform]
+    with:
+      table: events
+      s3_key: {$t: "events/{{ ts_nodash }}.json"}
diff --git a/task-sdk/tests/task_sdk/importers/yaml_importer/test_migrator.py 
b/task-sdk/tests/task_sdk/importers/yaml_importer/test_migrator.py
new file mode 100644
index 00000000000..130c33183bb
--- /dev/null
+++ b/task-sdk/tests/task_sdk/importers/yaml_importer/test_migrator.py
@@ -0,0 +1,188 @@
+# 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 pytest
+from cadwyn import (
+    HeadVersion,
+    Version,
+    VersionBundle,
+    VersionChange,
+    convert_request_to_next_version_for,
+    schema,
+)
+
+from airflow.sdk.importers.yaml_importer import migrator
+from airflow.sdk.importers.yaml_importer.models import DagDocument, Task
+
+HEAD_SCHEMA = migrator.schema_from_version("2026-10-30")
+
+
+class _RenameOwnerAttr(VersionChange):
+    """
+    Test-only fake version change.
+
+    This changes an attribute from ``owner_old`` to ``owner``.
+    """
+
+    description = __doc__
+    instructions_to_migrate_to_previous_version = ()
+
+    @convert_request_to_next_version_for(DagDocument)  # type: ignore[arg-type]
+    def _move(request):
+        if "owner_old" in request.body:
+            request.body["owner"] = request.body.pop("owner_old")
+
+
+_SYNTH_BUNDLE = VersionBundle(HeadVersion(), Version("2027-06-01", 
_RenameOwnerAttr), Version("2026-10-30"))
+
+
+def _migrate(m, date, **extra):
+    body = {"$schema": migrator.schema_from_version(date), "dag_id": "d", 
"tasks": [], **extra}
+    return m.resolve_and_migrate(body)
+
+
[email protected](
+    ("schema", "expected"),
+    [
+        pytest.param(migrator.schema_from_version("2026-10-30"), "2026-10-30", 
id="canonical-url"),
+        pytest.param("2026-10-30", None, id="bare-date"),
+        pytest.param("garbage-2026-10-30.json", None, id="date-in-junk"),
+        pytest.param("https://mirror.example/schemas/dag/2026-10-30.json";, 
None, id="wrong-host"),
+        pytest.param("", None, id="empty"),
+    ],
+)
+def test_version_from_schema_requires_canonical_url(schema, expected):
+    assert migrator.version_from_schema(schema) == expected
+
+
+def test_exact_version_pin_migrates_from_that_version():
+    # Observed through the converter: it runs only when the pinned (exact) 
version is older than head.
+    m = migrator.DagDocumentMigrator(_SYNTH_BUNDLE)
+    assert _migrate(m, "2026-10-30", owner_old="t")["owner"] == "t"  # exact 
older version -> migrates
+    # head-pinned: resolved source == head, so no converter runs
+    at_head = _migrate(m, "2027-06-01", owner_old="t")
+    assert at_head.get("owner_old") == "t"
+    assert "owner" not in at_head
+
+
[email protected](
+    "schema_value",
+    [
+        pytest.param(migrator.schema_from_version("2099-01-01"), 
id="newer-than-head"),
+        pytest.param(migrator.schema_from_version("2020-01-01"), 
id="older-than-oldest"),
+        pytest.param(migrator.schema_from_version("2027-03-01"), 
id="between-versions"),
+        pytest.param("not-a-version", id="junk"),
+        pytest.param("http://airflow.apache.org/schemas/dag/2026-10-30.json";, 
id="wrong-scheme"),
+        pytest.param("2026-10-30", id="bare-date"),
+    ],
+)
+def test_unknown_is_rejected(schema_value):
+    m = migrator.DagDocumentMigrator(_SYNTH_BUNDLE)
+    with pytest.raises(ValueError, match=r"not valid|is not a canonical schema 
URL"):
+        m.resolve_and_migrate({"$schema": schema_value, "dag_id": "d", 
"tasks": []})
+
+
+def test_migrator_rejects_converter_for_non_dagdocument_model():
+    class _ConvertTask(VersionChange):
+        "test-only: a converter keyed on a nested model the migrator does not 
drive."
+
+        description = __doc__
+        instructions_to_migrate_to_previous_version = ()
+
+        @convert_request_to_next_version_for(Task)  # type: ignore[arg-type]
+        def _noop(request):
+            pass
+
+    bundle = VersionBundle(HeadVersion(), Version("2027-06-01", _ConvertTask), 
Version("2026-10-30"))
+    with pytest.raises(RuntimeError, match=r"_ConvertTask:.*only 
whole-document DagDocument converters"):
+        migrator.DagDocumentMigrator(bundle)
+
+
+def test_migrator_rejects_non_request_instructions():
+    class _RenameViaSchema(VersionChange):
+        "test-only: a schema instruction the migrator does not apply."
+
+        description = __doc__
+        instructions_to_migrate_to_previous_version = (
+            
schema(DagDocument).field("schedule").had(name="schedule_interval"),
+        )
+
+    bundle = VersionBundle(HeadVersion(), Version("2027-06-01", 
_RenameViaSchema), Version("2026-10-30"))
+    with pytest.raises(RuntimeError, match="_RenameViaSchema: unsupported 
cadwyn instructions"):
+        migrator.DagDocumentMigrator(bundle)
+
+
+def test_known_version_does_not_warn(recwarn):
+    m = migrator.DagDocumentMigrator(_SYNTH_BUNDLE)
+    _migrate(m, "2026-10-30")
+    _migrate(m, "2027-06-01")
+    assert not recwarn.list, "exact known versions must not warn"
+
+
+def test_migrate_is_noop_at_head_for_the_real_bundle():
+    body = {"$schema": HEAD_SCHEMA, "dag_id": "d", "tasks": []}
+    assert migrator.get_migrator().resolve_and_migrate(body) is body  # one 
version -> no copy
+
+
+class _AppendV2(VersionChange):
+    "test-only: appends a marker so a run is observable and non-idempotent."
+
+    description = __doc__
+    instructions_to_migrate_to_previous_version = ()
+
+    @convert_request_to_next_version_for(DagDocument)  # type: ignore[arg-type]
+    def _move(request):
+        request.body["trail"] = request.body.get("trail", "") + "+v2"
+
+
+class _AppendV3(VersionChange):
+    "test-only: appends a marker so a run is observable and non-idempotent."
+
+    description = __doc__
+    instructions_to_migrate_to_previous_version = ()
+
+    @convert_request_to_next_version_for(DagDocument)  # type: ignore[arg-type]
+    def _move(request):
+        request.body["trail"] = request.body.get("trail", "") + "+v3"
+
+
+_THREE_VERSIONS = VersionBundle(
+    HeadVersion(),
+    Version("2027-06-01", _AppendV3),
+    Version("2027-01-01", _AppendV2),
+    Version("2026-10-30"),
+)
+
+
+def test_migration_from_middle_version_skips_its_own_converter():
+    # Pinning the middle version applies only the newer converter; the pinned
+    # version's own converter is skipped.
+    m = migrator.DagDocumentMigrator(_THREE_VERSIONS)
+    body = {"$schema": migrator.schema_from_version("2027-01-01"), "dag_id": 
"d", "tasks": []}
+    out = m.resolve_and_migrate(body)
+    assert out["trail"] == "+v3"
+    assert "trail" not in body  # the input body is not mutated
+
+
+def test_migration_from_oldest_runs_every_newer_converter():
+    m = migrator.DagDocumentMigrator(_THREE_VERSIONS)
+    out = m.resolve_and_migrate(
+        {"$schema": migrator.schema_from_version("2026-10-30"), "dag_id": "d", 
"tasks": []}
+    )
+    assert out["trail"] == "+v2+v3"  # oldest -> both converters, 
oldest-to-newest
diff --git a/task-sdk/tests/task_sdk/importers/yaml_importer/test_parser.py 
b/task-sdk/tests/task_sdk/importers/yaml_importer/test_parser.py
new file mode 100644
index 00000000000..22a69df2c75
--- /dev/null
+++ b/task-sdk/tests/task_sdk/importers/yaml_importer/test_parser.py
@@ -0,0 +1,576 @@
+# 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
+import io
+import json
+import pathlib
+import textwrap
+
+import jsonschema
+import pytest
+import yaml
+
+from airflow.sdk.importers import yaml_importer
+from airflow.sdk.importers.yaml_importer import YamlDagParseError, 
parse_documents
+from airflow.sdk.importers.yaml_importer.models import (
+    ConstRef,
+    DagDocument,
+    Literal,
+    Task,
+    TemplateRef,
+    TimetableSchedule,
+    XComRef,
+    XComTarget,
+)
+
+SCHEMA_PATH = pathlib.Path(yaml_importer.__file__).with_name("schema.json")
+
+HEAD = "2026-10-30"
+HEAD_SCHEMA = f"https://airflow.apache.org/schemas/dag/{HEAD}.json";
+
+
+def _one(body: str):
+    return next(iter(parse_documents(f"$schema: {HEAD_SCHEMA}\ndag_id: 
d\n{textwrap.dedent(body)}")))
+
+
+def _task(task_yaml: str):
+    return _one("tasks:\n" + textwrap.indent(textwrap.dedent(task_yaml), "  
")).tasks[0]
+
+
+def test_literal_by_default():
+    t = _task("- {id: t, run: {region: 'eu', n: 5, flag: true}}")
+    assert isinstance(t.run["region"], Literal)
+    assert t.run["region"].root == "eu"
+    assert t.run["n"].root == 5
+    assert t.run["flag"].root is True
+
+
+def test_date_and_datetime_literals():
+    # Unquoted YAML dates/datetimes are valid literal values; a quoted one 
stays a string.
+    t = _task("- {id: t, run: {d: 2026-01-01, dt: 2026-01-01T12:30:00, s: 
'2026-01-01'}}")
+    assert t.run["d"].root == datetime.date(2026, 1, 1)
+    assert t.run["dt"].root == datetime.datetime(2026, 1, 1, 12, 30)
+    assert t.run["s"].root == "2026-01-01"
+
+
+def test_xcom_short_long_and_keyed():
+    doc = _one(
+        "tasks:\n  - {id: up, run: {}}\n  - {id: t, run: {a: {$x: up}, b: 
{$xcom: up}, c: {$x: {task: up, key: rows}}}}"
+    )
+    t = {x.id_: x for x in doc.tasks}["t"]
+    assert isinstance(t.run["a"], XComRef)
+    assert t.run["a"].target == "up"
+    assert isinstance(t.run["b"], XComRef)
+    assert t.run["b"].target == "up"
+    assert isinstance(t.run["c"].target, XComTarget)
+    assert t.run["c"].target.key == "rows"
+
+
+def test_template_marker():
+    t = _task("- {id: t, uses: X, with: {k: {$t: 'v-{{ ds }}'}, k2: 
{$template: 'x'}}}")
+    assert isinstance(t.with_["k"], TemplateRef)
+    assert "{{ ds }}" in t.with_["k"].source
+    assert isinstance(t.with_["k2"], TemplateRef)
+
+
+def test_const_is_verbatim_not_recursed():
+    t = _task("- {id: t, run: {a: {$const: {$x: not-a-ref}}}}")
+    assert isinstance(t.run["a"], ConstRef)
+    assert t.run["a"].value == {"$x": "not-a-ref"}  # inner marker NOT 
interpreted
+
+
[email protected](
+    "value",
+    [
+        pytest.param("{$x: up, extra: 1}", id="marker-key-plus-extra"),
+        pytest.param("{$xcomm: up}", id="unknown-marker-typo"),
+        pytest.param("{$tempalte: x}", id="unknown-template-typo"),  # 
codespell:ignore tempalte
+        pytest.param("{cfg: {$weird: 1}}", id="dollar-key-nested-in-literal"),
+    ],
+)
+def test_reserved_dollar_keys_rejected(value):
+    # A '$'-prefixed key that is not a recognised sole-key marker is rejected, 
not silently literal.
+    with pytest.raises(YamlDagParseError, match="reserved"):
+        _task(f"- {{id: t, run: {{a: {value}}}}}")
+
+
+def test_nested_marker_inside_literal_resolved():
+    t = _task("- {id: t, uses: X, with: {cfg: {url: {$t: '{{ ds }}'}, n: 1}}}")
+    inner = t.with_["cfg"].root
+    assert isinstance(inner["url"], TemplateRef)
+    assert inner["n"].root == 1
+
+
+def test_operator_discrimination():
+    op = _task("- {id: t, uses: a.b.C, with: {x: 1}}")
+    assert isinstance(op, Task)
+    assert op.uses == "a.b.C"
+    assert op.run is None
+
+
+def test_code_discrimination():
+    code = _task("- {id: t, run: {}}")
+    assert isinstance(code, Task)
+    assert code.run == {}
+    assert code.uses is None
+
+
+def test_no_body_is_allowed():
+    t = _task("- {id: t}")
+    assert t.uses is None
+    assert t.run is None
+
+
+def test_both_bodies_is_error():
+    with pytest.raises(YamlDagParseError, match="may not set both 'uses' and 
'run'"):
+        _task("- {id: t, uses: X, run: {}}")
+
+
[email protected](
+    "task",
+    [
+        pytest.param("{id: t, uses: null}", id="uses-null"),
+        pytest.param("{id: t, run: null}", id="run-null"),
+        pytest.param("{id: t, uses: null, run: null}", id="both-null"),
+        pytest.param("{id: t, uses: X, run: null}", id="uses-with-run-null"),
+    ],
+)
+def test_null_body_is_allowed(task):
+    # An explicit null body means "no body" (like omission), so a generator 
may emit it.
+    t = _task(f"- {task}")
+    assert t.run is None
+
+
+def test_empty_uses_is_rejected():
+    # An empty string is not a valid import path; null is the way to say "no 
body".
+    with pytest.raises(YamlDagParseError, match="at least 1 character"):
+        _task('- {id: t, uses: ""}')
+
+
[email protected](
+    "doc",
+    [
+        pytest.param("dag_id: ''\ntasks: []", id="dag-id"),
+        pytest.param("dag_id: d\ntasks:\n  - {id: '', run: {}}", id="task-id"),
+        pytest.param("dag_id: d\nschedule: {uses: '', with: {}}\ntasks: []", 
id="timetable-uses"),
+    ],
+)
+def test_empty_identifiers_rejected(doc):
+    # Identifiers must be non-empty here; Airflow's validate_key enforces the 
full
+    # charset/length rules later, when the importer builds the Dag.
+    with pytest.raises(YamlDagParseError, match="at least 1 character"):
+        list(parse_documents(f"$schema: {HEAD_SCHEMA}\n{doc}"))
+
+
+def test_python_field_names_not_accepted_as_aliases():
+    with pytest.raises(YamlDagParseError):
+        _one("tasks:\n  - {id_: t, run: {}}")  # 'id_' is not 'id' -> id 
missing
+    t = _task("- {id: t, uses: X, with_: {k: 1}}")
+    assert t.with_ == {}  # 'with_' did not populate the 'with' field
+    assert t.model_extra.get("with_") == {"k": 1}  # it is just a pass-through 
extra
+
+
+def test_default_args_rejected():
+    with pytest.raises(YamlDagParseError, match="default_args"):
+        _one("default_args: {retries: 1}\ntasks: []")
+
+
+def test_callbacks_rejected():
+    with pytest.raises(YamlDagParseError, match="callback"):
+        _one("on_failure_callback: cb\ntasks: []")
+    with pytest.raises(YamlDagParseError, match="callback"):
+        _task("- {id: t, run: {}, on_success_callback: cb}")
+
+
+def test_inlets_outlets_rejected():
+    with pytest.raises(YamlDagParseError, match="not yet implemented"):
+        _task("- {id: t, run: {}, outlets: [a]}")
+
+
+def test_extends_merges_shallow_task_wins():
+    doc = _one(
+        """
+        templates:
+          retryable: {retries: 2, retry_delay: '5m'}
+          load:
+            extends: [retryable]
+            uses: a.b.S3ToRedshiftOperator
+            with: {schema: public, conn_id: warehouse}
+        tasks:
+          - id: t
+            extends: [load]
+            with: {table: sales, conn_id: override}
+        """
+    )
+    t = doc.tasks[0]
+    assert isinstance(t, Task)
+    assert t.uses.endswith("S3ToRedshiftOperator")
+    assert t.model_extra["retries"] == 2  # from retryable
+    assert isinstance(t.with_["schema"], Literal)  # inherited
+    assert t.with_["table"].root == "sales"  # own
+    assert t.with_["conn_id"].root == "override"  # one-level with-merge, task 
wins
+
+
+def test_unknown_template_errors():
+    with pytest.raises(YamlDagParseError, match="unknown template"):
+        _one("tasks:\n  - {id: t, extends: [ghost], run: {}}")
+
+
+def test_template_cycle_errors():
+    with pytest.raises(YamlDagParseError, match="cycle"):
+        _one("templates: {a: {extends: [b]}, b: {extends: [a]}}\ntasks: [{id: 
t, extends: [a], run: {}}]")
+
+
[email protected](
+    "body",
+    [
+        pytest.param("tasks:\n  - {id: t, run: {}, extends: [[base]]}", 
id="task-extends-nested-list"),
+        pytest.param("tasks:\n  - {id: t, run: {}, extends: [{base: 1}]}", 
id="task-extends-dict-element"),
+        pytest.param("tasks:\n  - {id: t, run: {}, extends: 1}", 
id="task-extends-non-string"),
+    ],
+)
+def test_task_extends_must_be_a_name_or_list_of_names(body):
+    # A non-string entry (or a non-string/non-list value) is a parse error, 
not a TypeError.
+    with pytest.raises(YamlDagParseError, match="extends"):
+        _one(body)
+
+
+def test_template_extends_must_be_a_name_or_list_of_names():
+    with pytest.raises(YamlDagParseError, match="extends"):
+        _one(
+            "templates: {base: {retries: 1}, child: {extends: 
[[base]]}}\ntasks: [{id: t, extends: [child], run: {}}]"
+        )
+
+
+def test_extends_accepts_a_bare_string():
+    # `extends: base` is shorthand for `extends: [base]`, at both task and 
template level.
+    doc = _one(
+        "templates:\n  gp: {retries: 3}\n  base: {extends: gp}\ntasks:\n  - 
{id: t, extends: base, uses: X}"
+    )
+    assert doc.tasks[0].model_extra["retries"] == 3  # merged gp -> base -> 
task
+
+
+def test_unknown_needs_rejected():
+    with pytest.raises(YamlDagParseError, match="unknown task"):
+        _one("tasks:\n  - {id: t, run: {}, needs: [ghost]}")
+
+
[email protected](
+    "task",
+    [
+        pytest.param("{id: t, run: {e: {$x: ghost}}}", id="run-top-level"),
+        pytest.param("{id: t, run: {e: [{$x: ghost}]}}", id="run-in-list"),
+        pytest.param("{id: t, run: {e: {nested: {$x: ghost}}}}", 
id="run-nested-dict"),
+        pytest.param("{id: t, uses: X, with: {a: {$x: ghost}}}", 
id="with-on-operator"),
+    ],
+)
+def test_unknown_xcom_target_rejected(task):
+    # XCom refs must be found at any depth inside with/run (the list and 
nested-dict branches too).
+    with pytest.raises(YamlDagParseError, match="unknown task"):
+        _one(f"tasks:\n  - {task}")
+
+
+def test_duplicate_task_ids_rejected():
+    with pytest.raises(YamlDagParseError, match="duplicate task id"):
+        _one("tasks:\n  - {id: a, run: {}}\n  - {id: a, uses: x.Y}")
+
+
[email protected](
+    "body",
+    [
+        pytest.param("tasks:\n  - {id: a, run: {}, needs: [a]}", 
id="self-needs"),
+        pytest.param("tasks:\n  - {id: a, run: {x: {$x: a}}}", id="self-xcom"),
+    ],
+)
+def test_self_dependency_rejected(body):
+    with pytest.raises(YamlDagParseError, match="depends on itself"):
+        _one(body)
+
+
+def test_schedule_forms():
+    assert _one("schedule: '0 3 * * *'\ntasks: []").schedule == "0 3 * * *"
+    assert _one("schedule: null\ntasks: []").schedule is None
+    tt = _one("schedule: {uses: a.b.MyTimetable, with: {n: 1}}\ntasks: 
[]").schedule
+    assert isinstance(tt, TimetableSchedule)
+    assert tt.uses.endswith("MyTimetable")
+
+
+def test_extra_top_level_keys_pass_through():
+    # Unknown top-level keys are Dag-level arguments: accepted and surfaced 
via pydantic extras.
+    doc = _one("catchup: false\nmax_active_runs: 3\ntags: [etl]\ntasks: []")
+    assert doc.model_extra == {"catchup": False, "max_active_runs": 3, "tags": 
["etl"]}
+
+
+def test_schema_required():
+    with pytest.raises(YamlDagParseError, match=r"\$schema"):
+        list(parse_documents("dag_id: d\ntasks: []"))
+
+
[email protected](
+    "schema_value",
+    [
+        pytest.param("null", id="null"),
+        pytest.param('""', id="empty-string"),
+        pytest.param("false", id="false"),
+        pytest.param("123", id="int"),
+        pytest.param("[a, b]", id="list"),
+    ],
+)
+def test_schema_must_be_a_non_empty_string(schema_value):
+    # A present-but-falsy or wrong-typed $schema is reported as such, not as 
"missing".
+    with pytest.raises(YamlDagParseError, match="must be a non-empty string"):
+        list(parse_documents(f"$schema: {schema_value}\ndag_id: d\ntasks: []"))
+
+
[email protected](
+    "body",
+    [
+        pytest.param("tasks: []\ntasks: []", id="dup-tasks-top-level"),
+        pytest.param("tasks:\n  - {id: a, uses: X, with: {x: 1}, with: {y: 
2}}", id="dup-with-on-task"),
+        pytest.param("tasks:\n  - {id: a, run: {}, needs: [b], needs: [c]}", 
id="dup-needs-on-task"),
+    ],
+)
+def test_duplicate_keys_rejected(body):
+    # PyYAML keeps the last value for a repeated key; a hand-edited format 
should reject it instead
+    # of silently dropping the first. The loader raises a ConstructorError (a 
YAMLError).
+    with pytest.raises(YamlDagParseError, match="duplicate key"):
+        _one(body)
+
+
+def test_yaml_merge_keys_supported():
+    # ``<<`` merge keys resolve via flatten_mapping; the duplicate-key loader 
must skip the merge
+    # tag rather than choke on it. Explicit keys still win over merged ones, 
and genuine duplicate
+    # explicit keys are still rejected (covered by 
test_duplicate_keys_rejected).
+    t = _task(
+        """
+        - id: a
+          run:
+            <<: &defaults {retries: 2, region: eu}
+            region: us
+        """
+    )
+    assert t.run["retries"].root == 2  # pulled in from the merge source
+    assert t.run["region"].root == "us"  # explicit key overrides the merged 
value
+
+
[email protected](
+    "date",
+    [
+        pytest.param("2099-01-01", id="newer-than-head"),
+        pytest.param("2000-01-01", id="older-than-oldest"),
+    ],
+)
+def test_unknown_schema_version_is_rejected(date):
+    url = f"https://airflow.apache.org/schemas/dag/{date}.json";
+    with pytest.raises(YamlDagParseError, match="not valid"):
+        list(parse_documents(f"$schema: {url}\ndag_id: d\ntasks: []"))
+
+
[email protected](
+    "schema_value",
+    [
+        pytest.param("nonsense", id="junk"),
+        pytest.param("http://airflow.apache.org/schemas/dag/2026-10-30.json";, 
id="wrong-scheme"),
+        pytest.param("https://airflow.apache.org/schemas/dag/2026-10-30";, 
id="missing-json"),
+        pytest.param("'2026-10-30'", id="quoted-date"),
+    ],
+)
+def test_non_canonical_schema_url_is_rejected(schema_value):
+    # A non-canonical $schema names the value written and the expected form, 
not "version None".
+    with pytest.raises(YamlDagParseError, match="is not a canonical schema 
URL"):
+        list(parse_documents(f"$schema: {schema_value}\ndag_id: d\ntasks: []"))
+
+
+def test_accepts_a_file_like_stream():
+    stream = io.StringIO(f"$schema: {HEAD_SCHEMA}\ndag_id: d\ntasks: [{{id: t, 
run: {{}}}}]")
+    docs = list(parse_documents(stream))
+    assert [d.dag_id for d in docs] == ["d"]
+
+
+def test_parse_is_lazy_errors_surface_on_iteration():
+    # Building the iterator does not parse; the error only surfaces when 
consumed.
+    gen = parse_documents("dag_id: d\ntasks: []")  # missing $schema
+    with pytest.raises(YamlDagParseError, match=r"\$schema"):
+        next(iter(gen))
+
+
+def test_multiple_documents():
+    docs = parse_documents(
+        f"$schema: {HEAD_SCHEMA}\ndag_id: a\ntasks: []\n---\n$schema: 
{HEAD_SCHEMA}\ndag_id: b\ntasks: []\n"
+    )
+    assert [d.dag_id for d in docs] == ["a", "b"]
+
+
+def test_non_mapping_document_rejected():
+    with pytest.raises(YamlDagParseError, match="must be a mapping"):
+        list(parse_documents("- a\n- b"))
+
+
+def test_invalid_yaml_rejected():
+    with pytest.raises(YamlDagParseError, match="invalid YAML"):
+        list(parse_documents("key: [unclosed"))
+
+
+def test_error_label_points_at_offending_document():
+    # A failure in a later document of a multi-doc stream is labelled with its 
position.
+    good = f"$schema: {HEAD_SCHEMA}\ndag_id: a\ntasks: []"
+    bad = "dag_id: b\ntasks: []"  # missing $schema -> fails, and it is doc 
index 1
+    with pytest.raises(YamlDagParseError, match=r"\[doc 1\]"):
+        list(parse_documents(f"{good}\n---\n{bad}"))
+
+
+def test_model_json_schema_describes_markers():
+    # The models are the schema source of truth (the prek dump script 
decorates + snapshots
+    # them). Assert the generated schema shape here; the snapshot itself is 
enforced by the
+    # generate-yaml-importer-schema-snapshot prek hook.
+    sch = DagDocument.model_json_schema(by_alias=True)
+    assert set(sch["properties"]) >= {"$schema", "dag_id", "schedule", 
"tasks", "templates"}
+    assert {"XComRef", "TemplateRef", "ConstRef", "Task"} <= set(sch["$defs"])
+
+
+def test_parses_example_fixture():
+    fixture = pathlib.Path(__file__).parent / "example_dag.yaml"
+    with fixture.open(encoding="utf-8") as fh:
+        docs = list(parse_documents(fh, source=str(fixture)))
+    assert [d.dag_id for d in docs] == ["retail_daily_sales", 
"events_pipeline"]
+
+    d1 = {t.id_: t for t in docs[0].tasks}
+    load = d1["load_sales"]
+    assert isinstance(load, Task)
+    assert load.uses.endswith("S3ToRedshiftOperator")
+    assert load.model_extra["retries"] == 2  # via redshift_load -> retryable
+    assert isinstance(load.with_["schema"], TemplateRef)  # inherited $t
+    assert isinstance(load.with_["s3_key"], TemplateRef)  # own $t
+    assert load.needs == ["wait_for_export"]
+    assert docs[0].model_extra["catchup"] is False
+
+    d2 = {t.id_: t for t in docs[1].tasks}
+    assert isinstance(d2["extract"], Task)
+    assert d2["extract"].run == {}
+    assert d2["extract"].queue == "extract-workers"
+    assert isinstance(d2["transform"].run["events"], XComRef)  # {$x: extract} 
edge
+    assert d2["load"].needs == ["transform"]
+
+
+def test_example_fixture_conforms_to_published_schema():
+    # Separately validate the fixture against the JSON Schema. Not all Pydantic
+    # features survive the export.
+    validator = 
jsonschema.Draft202012Validator(json.loads(SCHEMA_PATH.read_text()))
+    fixture = pathlib.Path(__file__).parent / "example_dag.yaml"
+    documents = [d for d in yaml.safe_load_all(fixture.read_text()) if d]
+    assert documents
+    for document in documents:
+        assert list(validator.iter_errors(document)) == []
+
+
[email protected](
+    "bad_task",
+    [
+        pytest.param({"id": "t", "uses": "a.b.C", "run": {}}, 
id="both-bodies"),
+        pytest.param({"id": "t", "uses": ""}, id="empty-uses"),
+    ],
+)
+def test_published_schema_rejects_invalid_tasks(bad_task):
+    validator = 
jsonschema.Draft202012Validator(json.loads(SCHEMA_PATH.read_text()))
+    document = {"$schema": HEAD_SCHEMA, "dag_id": "d", "tasks": [bad_task]}
+    assert list(validator.iter_errors(document))
+
+
[email protected](
+    "task",
+    [
+        pytest.param({"id": "t"}, id="no-body"),
+        pytest.param({"id": "t", "uses": None}, id="uses-null"),
+        pytest.param({"id": "t", "run": None}, id="run-null"),
+        pytest.param({"id": "t", "uses": "a.b.C", "run": None}, 
id="uses-with-run-null"),
+    ],
+)
+def test_published_schema_accepts_null_and_empty_bodies(task):
+    validator = 
jsonschema.Draft202012Validator(json.loads(SCHEMA_PATH.read_text()))
+    document = {"$schema": HEAD_SCHEMA, "dag_id": "d", "tasks": [task]}
+    assert list(validator.iter_errors(document)) == []
+
+
[email protected](
+    "body",
+    [
+        pytest.param(
+            "tasks:\n  - {id: up, run: {}}\n  - {id: t, run: {a: {$x: up}}}",
+            id="xcom-short",
+        ),
+        pytest.param(
+            "tasks:\n  - {id: up, run: {}}\n  - {id: t, run: {a: {$xcom: 
up}}}",
+            id="xcom-long",
+        ),
+        pytest.param(
+            "tasks:\n  - {id: up, run: {}}\n  - {id: t, run: {a: {$x: {task: 
up, key: rows}}}}",
+            id="xcom-keyed",
+        ),
+        pytest.param(
+            "tasks:\n  - {id: t, run: {a: {$const: {$x: literal}}}}",
+            id="const-around-dollar",
+        ),
+        pytest.param(
+            "tasks:\n  - {id: t, uses: X, with: {k: {$t: 'v-{{ ds }}'}}}",
+            id="template",
+        ),
+        pytest.param(
+            "templates: {base: {run: {r: 1}}}\ntasks:\n  - {id: t, extends: 
base}",
+            id="extends-bare",
+        ),
+        pytest.param(
+            "tasks:\n  - {id: t, run: {a: {}}}",
+            id="empty-dict-value",
+        ),
+        pytest.param(
+            "tasks:\n  - {id: t, run: {s: x, n: 5, f: 1.5, b: true, z: null}}",
+            id="scalars",
+        ),
+    ],
+)
+def test_valid_shapes_round_trip(body):
+    # Each valid value-grammar shape must be accepted by BOTH the parser and 
the published
+    # JSON Schema -- the two descriptions of the format must not drift apart.
+    text = f"$schema: {HEAD_SCHEMA}\ndag_id: d\n{textwrap.dedent(body)}"
+    assert list(parse_documents(text))  # the parser accepts it
+    validator = 
jsonschema.Draft202012Validator(json.loads(SCHEMA_PATH.read_text()))
+    for document in (d for d in yaml.safe_load_all(text) if d):
+        assert list(validator.iter_errors(document)) == []  # the published 
schema agrees
+
+
[email protected](
+    "value",
+    [
+        pytest.param("{$x: up, extra: 1}", id="marker-plus-extra"),
+        pytest.param("{$xcomm: up}", id="unknown-marker-typo"),
+        pytest.param("{$tempalte: x}", id="unknown-template-typo"),  # 
codespell:ignore tempalte
+        pytest.param("{cfg: {$weird: 1}}", id="dollar-key-nested"),
+        pytest.param("{$x: up, $xcom: up}", id="two-marker-keys"),
+        pytest.param("{$x: {task: up, nope: 1}}", id="xcom-target-extra-key"),
+    ],
+)
+def test_invalid_shapes_rejected_by_both(value):
+    # Hand-written schema guards (Literal '$'-key 'not', XComTarget and markers
+    # additionalProperties: false) must reject the same shapes as the parser.
+    text = f"$schema: {HEAD_SCHEMA}\ndag_id: d\ntasks:\n  - {{id: t, run: {{a: 
{value}}}}}"
+    with pytest.raises(YamlDagParseError):
+        list(parse_documents(text))  # the parser rejects it
+    validator = 
jsonschema.Draft202012Validator(json.loads(SCHEMA_PATH.read_text()))
+    assert list(validator.iter_errors(yaml.safe_load(text)))  # the published 
schema agrees

Reply via email to