This is an automated email from the ASF dual-hosted git repository.
vatsrahul1001 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 72568207e57 Implement AIP-85 YAML importer based on the parser (#74586)
72568207e57 is described below
commit 72568207e57d76847eff22b09308648c3e7102c5
Author: Tzu-ping Chung <[email protected]>
AuthorDate: Tue Oct 13 13:59:39 2026 +0800
Implement AIP-85 YAML importer based on the parser (#74586)
* Add missing PyYAML dependency to Task SDK
* Implement file-loading syntax $f
* Implement the YAML importer shell
* Fix double-rendering
* Protect file read using the same logic as Python templates
* Re-export to reduce import levels
* Correctly import in an import context
* Refactor DagBuilder interface
* Stricter top-level and with argument checks
* Tighten template field patching
* Tighten literal handling in run
* Consolidate import-time errors from a YAML file
* Reject duplicate dag_id in a file
* Tighten might_contain_dag heuristic
* Coerce YAML input temporal values to datetimes
* Rely on stdlib graphlib for ordering
* Move unnecessary local import up
* Tighten tests
* Try to smartly convert datetimes
* Tighten
* Fix leaked rst in schema
* Clean up in-test comments
* Improve temporal coersion
* Harden
* Fix zip import function arg name
---
task-sdk/pyproject.toml | 5 +-
task-sdk/src/airflow/sdk/importers/base.py | 31 +-
.../sdk/importers/yaml_importer/__init__.py | 9 +-
.../airflow/sdk/importers/yaml_importer/builder.py | 455 ++++++++++++++++
.../sdk/importers/yaml_importer/importer.py | 169 ++++++
.../sdk/importers/yaml_importer/migrator.py | 3 +-
.../airflow/sdk/importers/yaml_importer/models.py | 84 ++-
.../sdk/importers/yaml_importer/schema.json | 62 ++-
task-sdk/src/airflow/sdk/importers/zip_importer.py | 13 +
.../importers/yaml_importer/test_importer.py | 605 +++++++++++++++++++++
.../importers/yaml_importer/test_parser.py | 10 +
11 files changed, 1425 insertions(+), 21 deletions(-)
diff --git a/task-sdk/pyproject.toml b/task-sdk/pyproject.toml
index 0ce16d4b713..9a4c9d44ea9 100644
--- a/task-sdk/pyproject.toml
+++ b/task-sdk/pyproject.toml
@@ -54,14 +54,15 @@ dependencies = [
"babel>=2.17.0",
"cadwyn>=6.1.1",
"fsspec>=2023.10.0",
+ "greenback>=1.2.1",
"httpx>=0.27.0",
"jinja2>=3.1.5",
"methodtools>=0.4.7",
"msgspec>=0.19.0",
- "python-dateutil>=2.7.0",
"psutil>=6.1.0",
+ "python-dateutil>=2.7.0",
+ "pyyaml>=6.0.3",
"structlog>=26.1.0",
- "greenback>=1.2.1",
"tenacity>=8.3.0",
# Start of shared timezones dependencies
"pendulum>=3.1.0",
diff --git a/task-sdk/src/airflow/sdk/importers/base.py
b/task-sdk/src/airflow/sdk/importers/base.py
index e594e15ad24..390c28bfc2f 100644
--- a/task-sdk/src/airflow/sdk/importers/base.py
+++ b/task-sdk/src/airflow/sdk/importers/base.py
@@ -34,11 +34,17 @@ from airflow.sdk.execution_time.coordinator import
get_coordinator_manager
if TYPE_CHECKING:
from collections.abc import Generator, Iterable, Iterator
- from typing import Self
+ from typing import Protocol, Self
from airflow.dag_processing.bundles.base import BaseDagBundle # noqa:
SDK002
from airflow.sdk import DAG
+ class _SiblingResource(Protocol):
+ """A readable handle to a resource, e.g. a :class:`pathlib.Path` or
:class:`zipfile.Path`."""
+
+ def read_bytes(self) -> bytes: ...
+
+
log = logging.getLogger(__name__)
DefT = TypeVar("DefT", bound="DagDefinition")
@@ -64,6 +70,22 @@ class DagDefinition(ABC):
"""Read and return the content of the resource as a string."""
return self.read_bytes().decode(encoding)
+ def sibling(self, relpath: str) -> _SiblingResource:
+ """
+ Return a readable handle to a resource stored next to this definition.
+
+ The handle supports ``read_bytes()``. Resolution is relative to the
+ directory of the definition's own file and raises if *relpath* escapes
+ it. This is close to a Python Dag's default Jinja search path, but
+ stricter: a path escaping via a symlink is rejected (Jinja's
+ ``FileSystemLoader`` would allow it), and a Dag-level
+ ``template_searchpath`` is not consulted.
+
+ The base cannot locate siblings; a definition type that can hold them
+ overrides this.
+ """
+ raise NotImplementedError(f"{type(self).__name__} cannot resolve
sibling resources")
+
@abstractmethod
def as_file(self) -> contextlib.AbstractContextManager[Path]:
"""
@@ -133,6 +155,13 @@ class FilesystemDagDefinition(FileDagDefinition):
def read_bytes(self) -> bytes:
return self.path.read_bytes()
+ def sibling(self, relpath: str) -> Path:
+ base = self.path.parent.resolve()
+ target = base.joinpath(relpath).resolve()
+ if not target.is_relative_to(base):
+ raise ValueError(f"{relpath!r} resolves outside the Dag file's
directory {str(base)!r}")
+ return target
+
@contextlib.contextmanager
def as_file(self) -> Generator[Path, None, None]:
yield self.path
diff --git a/task-sdk/src/airflow/sdk/importers/yaml_importer/__init__.py
b/task-sdk/src/airflow/sdk/importers/yaml_importer/__init__.py
index 2f229838802..a08ed337b70 100644
--- a/task-sdk/src/airflow/sdk/importers/yaml_importer/__init__.py
+++ b/task-sdk/src/airflow/sdk/importers/yaml_importer/__init__.py
@@ -19,14 +19,15 @@
from __future__ import annotations
+from airflow.sdk.importers.yaml_importer.builder import YamlDagBuildError
+from airflow.sdk.importers.yaml_importer.importer import YamlDagImporter
from airflow.sdk.importers.yaml_importer.models import DagDocument
-from airflow.sdk.importers.yaml_importer.parser import (
- YamlDagParseError,
- parse_documents,
-)
+from airflow.sdk.importers.yaml_importer.parser import YamlDagParseError,
parse_documents
__all__ = [
"DagDocument",
+ "YamlDagBuildError",
+ "YamlDagImporter",
"YamlDagParseError",
"parse_documents",
]
diff --git a/task-sdk/src/airflow/sdk/importers/yaml_importer/builder.py
b/task-sdk/src/airflow/sdk/importers/yaml_importer/builder.py
new file mode 100644
index 00000000000..02d49c6a8ff
--- /dev/null
+++ b/task-sdk/src/airflow/sdk/importers/yaml_importer/builder.py
@@ -0,0 +1,455 @@
+# 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.
+
+"""
+Build a validated :class:`~.models.DagDocument` into a
:class:`~airflow.sdk.DAG`.
+
+The parser produces a validated document; this turns it into real DAG +
operator
+objects. Ready-made operators are instantiated from their ``uses`` import path;
+tasks with ``run`` are built into stub operators and handled by AIP-108 task
+handlers at execution time.
+
+Templating is explicit, realized importer-side (a la AIP-80). The format is
+literal by default; only values the author marked render as templates. Value
+grammar markers are only accepted on fields declared in ``template_fields``.
+
+Tasks are built in topological order so a task referenced by an upstream's XCom
+already exists when the downstream operator is constructed.
+"""
+
+from __future__ import annotations
+
+import datetime
+import functools
+import graphlib
+import inspect
+import json
+import keyword
+import linecache
+import re
+from typing import TYPE_CHECKING, Any, NamedTuple, cast
+
+import attrs
+
+from airflow.sdk import DAG, BaseOperator, XComArg, literal, task as
task_decorators
+from airflow.sdk._shared.module_loading import import_string
+from airflow.sdk.definitions.context import KNOWN_CONTEXT_KEYS
+from airflow.sdk.importers.yaml_importer.models import (
+ CONST_KEY,
+ FILE_KEYS,
+ TEMPLATE_KEYS,
+ XCOM_KEYS,
+ ConstRef,
+ FileRef,
+ Literal,
+ TemplateRef,
+ TimetableSchedule,
+ XComRef,
+)
+
+if TYPE_CHECKING:
+ from collections.abc import Callable
+
+ from airflow.dag_processing.bundles.base import BaseDagBundle # noqa:
SDK002
+ from airflow.sdk.importers.base import DagDefinition
+ from airflow.sdk.importers.yaml_importer.models import DagDocument, Task,
Value
+
+
+class YamlDagBuildError(ValueError):
+ """A validated document could not be built into a DAG."""
+
+
+_BASE_TASK_ARGS =
frozenset(BaseOperator.__init__._BaseOperatorMeta__param_names) # type:
ignore[attr-defined]
+_UNSUPPORTED_TASK_ARGS = frozenset({"task_id", "dag", "task_group",
"default_args"})
+_MARKER_KEYS = frozenset({*XCOM_KEYS, *TEMPLATE_KEYS, *FILE_KEYS, CONST_KEY})
+
+
[email protected](kw_only=True)
+class DagBuilder:
+ """
+ Build a :class:`DagDocument` into a :class:`DAG`.
+
+ This holds the state a single build shares. The state belongs to one build
+ and is discarded with the instance.
+ """
+
+ _definition: DagDefinition
+ _bundle: BaseDagBundle
+ _operators: dict[str, BaseOperator] = attrs.field(factory=dict, init=False)
+ _stub_callables: dict[tuple[str, ...], Callable[..., None]] =
attrs.field(factory=dict, init=False)
+
+ def build(self, doc: DagDocument) -> DAG:
+ tasks = {t.id_: t for t in doc.tasks}
+ _reject_reserved_extra_keys(doc.model_extra or {})
+ extras = _coerce_temporal(doc.model_extra or {}, DAG)
+ with DAG(dag_id=doc.dag_id, schedule=_build_schedule(doc.schedule),
**extras) as dag:
+ for task_id in _topological_order(doc.tasks):
+ self._operators[task_id] = self._build_operator(tasks[task_id])
+
+ for task in doc.tasks:
+ for upstream in sorted(task.get_dependency_ids()):
+ self._operators[upstream] >> self._operators[task.id_]
+ dag.bundle_name = self._bundle.name
+ dag.fileloc = repr(self._definition)
+ dag.relative_fileloc =
self._definition.get_relative_loc(self._bundle.path)
+ return dag
+
+ def _build_stub_task(self, task: Task, args: dict[str, Value]) ->
BaseOperator:
+ """
+ Build a stub from a "run:" task.
+
+ The stub is fed a callable whose parameters are the run argument names,
+ so serialization can build and later validate (AIP-108) the handler's
+ argument spec.
+
+ The "real" task body should be a registered SDK task handler resolved
at
+ run time; the run mapping binds as that handler's arguments. Only a
+ top-level ``$x`` (an upstream return value) crosses the binding; every
+ other argument must be a JSON-serializable literal.
+ """
+ if task.with_:
+ raise YamlDagBuildError(
+ f"task {task.id_!r}: a run: task takes its inputs under
'run:', not 'with:'"
+ )
+ run_kwargs: dict[str, Any] = {}
+ for name, value in args.items():
+ if not name.isidentifier() or keyword.iskeyword(name):
+ raise YamlDagBuildError(f"task {task.id_!r}: run argument
{name!r} is not a valid identifier")
+ if name in KNOWN_CONTEXT_KEYS:
+ raise YamlDagBuildError(f"task {task.id_!r}: run argument
cannot be named {name!r}")
+ _reject_unbindable_run_marker(task.id_, value)
+ if not isinstance(lowered := self._lower(value, protect=False),
XComArg):
+ try:
+ json.dumps(lowered, allow_nan=False)
+ except (TypeError, ValueError):
+ raise YamlDagBuildError(
+ f"task {task.id_!r}: run argument {name!r} must be
JSON-serializable, "
+ f"not a {type(lowered).__name__}"
+ ) from None
+ run_kwargs[name] = lowered
+ decorator = task_decorators.stub(
+ task_id=task.id_, **_coerce_temporal(task.model_extra or {},
BaseOperator)
+ )
+ return
decorator(self._stub_callable(tuple(run_kwargs)))(**run_kwargs).operator
+
+ def _build_operator(self, task: Task) -> BaseOperator:
+ """Instantiate *task* as an operator."""
+ if reserved := sorted(
+ k for k in (*task.with_, *(task.model_extra or {})) if k in
_UNSUPPORTED_TASK_ARGS
+ ):
+ raise YamlDagBuildError(
+ f"task {task.id_!r}: {reserved} cannot be set on a task; the
task id comes from 'id', and "
+ "dag/task_group/default_args are managed by the importer"
+ )
+ if misplaced := sorted(k for k in task.with_ if k in _BASE_TASK_ARGS):
+ raise YamlDagBuildError(
+ f"task {task.id_!r}: {misplaced} are task-level arguments; set
them at task level, not under 'with'"
+ )
+ if unknown := sorted(k for k in (task.model_extra or {}) if k not in
_BASE_TASK_ARGS):
+ raise YamlDagBuildError(
+ f"task {task.id_!r}: {unknown} are not task-level
(BaseOperator) arguments; "
+ "operator-specific arguments belong under 'with'"
+ )
+ _reject_reserved_extra_keys(task.model_extra or {})
+
+ if (args := task.run) is not None:
+ return self._build_stub_task(task, args)
+
+ op_cls: Any
+ if task.uses is None: # uses: null (or omitted) becomes EmptyOperator.
+ from airflow.providers.standard.operators.empty import
EmptyOperator as op_cls # noqa: SDK002
+ else:
+ op_cls = import_string(task.uses)
+
+ template_fields = set(getattr(op_cls, "template_fields", ()))
+ temporal = _temporal_fields(op_cls)
+ kwargs: dict[str, Any] = {}
+ render_fields: list[str] = []
+ off_template: list[str] = []
+ for name, value in task.with_.items():
+ needs_render, has_marker = _field_markers(value)
+ lowered = self._lower(value, protect=needs_render)
+ if name in temporal and not has_marker: # a literal date/ISO
string for a datetime arg
+ lowered = _to_datetime(lowered)
+ kwargs[name] = lowered
+ if has_marker and name not in template_fields:
+ off_template.append(name)
+ if needs_render:
+ render_fields.append(name)
+ if off_template:
+ raise YamlDagBuildError(
+ f"task {task.id_!r}: value grammar markers are only allowed on
templated fields, not {off_template}"
+ )
+ if task.model_extra: # Merge in task-level arguments (raw, never
templated).
+ kwargs.update(_coerce_temporal(task.model_extra, BaseOperator))
+
+ return _restrict_template_fields(op_cls(task_id=task.id_, **kwargs),
task.with_, render_fields)
+
+ def _lower(self, value: Any, *, protect: bool) -> Any:
+ """
+ Lower a resolved value-grammar node into an operator argument.
+
+ :param protect: This should be set when the enclosing field renders to
+ avoid double-rendering. :class:`LiteralValue` is used to wrap
+ rendered values so they stay literal.
+ """
+ if isinstance(value, XComRef):
+ return self._xcom_arg(value) # resolves at render time; never
wrapped
+ if isinstance(value, TemplateRef):
+ # $t over a string renders that string; $t over $f is a template
file (read, then render).
+ if isinstance(value.source, FileRef):
+ # A constructed FileRef has a str path (its validator rejects
a templated filename).
+ return self._read_file(cast("str", value.source.path))
+ return value.source
+ if isinstance(value, FileRef):
+ return self._protect(self._read_file(cast("str", value.path)),
protect)
+ if isinstance(value, ConstRef):
+ return self._protect(value.value, protect)
+ if isinstance(value, Literal):
+ return self._lower(value.root, protect=protect)
+ if isinstance(value, dict):
+ return {k: self._lower(v, protect=protect) for k, v in
value.items()}
+ if isinstance(value, list):
+ return [self._lower(v, protect=protect) for v in value]
+ return self._protect(value, protect)
+
+ @staticmethod
+ def _protect(value: Any, protect: bool) -> Any:
+ return literal(value) if protect else value
+
+ def _xcom_arg(self, value: XComRef) -> XComArg:
+ """Resolve an :class:`XComRef` against the already-built operators."""
+ target = value.target
+ if isinstance(target, str):
+ return XComArg(self._operators[target])
+ if target.key is None:
+ return XComArg(self._operators[target.task])
+ return XComArg(self._operators[target.task], key=target.key)
+
+ def _read_file(self, rel_path: str) -> str:
+ """Read a ``$f`` at build time, relative to the document."""
+ try:
+ return
self._definition.sibling(rel_path).read_bytes().decode("utf-8")
+ except (OSError, ValueError) as exc:
+ raise YamlDagBuildError(f"cannot read $f {rel_path!r}: {exc}")
from exc
+
+ def _stub_callable(self, param_names: tuple[str, ...]) -> Callable[...,
None]:
+ """
+ Build a placeholder callable to feed to ``@task.stub``.
+
+ The stub needs a real callable with a meaningful signature; core
+ serialization reads it to build the TaskFlow arg-binding spec a
lang-SDK
+ handler is validated against (AIP-108), and ``@task.stub`` reads the
+ callable's source to check the body is empty.
+
+ The callable declares exactly the run arguments as a fixed, ordered
+ parameter list with an empty body, and its source is registered with
+ :mod:`linecache` so :func:`inspect.getsource` can read it back.
+ """
+ if (cached := self._stub_callables.get(param_names)) is not None:
+ return cached
+ source = f"def _stub({', '.join(param_names)}):\n ...\n"
+ filename = f"<yaml-dag stub ({', '.join(param_names)})>"
+ linecache.cache[filename] = (len(source), None,
source.splitlines(keepends=True), filename)
+ namespace: dict[str, Any] = {}
+ exec(compile(source, filename, "exec"), namespace)
+ self._stub_callables[param_names] = stub = namespace["_stub"]
+ return stub
+
+
+def _restrict_template_fields(
+ op: BaseOperator, with_: dict[str, Any], render_fields: list[str]
+) -> BaseOperator:
+ """
+ Make *op* render literal-by-default, per instance.
+
+ Only fields the author set in ``with:`` without a marker are dropped from
+ ``template_fields``; fields the operator templates by default or marked as
+ templates (e.g. uses ``$t``) are kept, so an operator's own defaults (e.g.
+ ``ExternalTaskMarker.logical_date``) still render.
+
+ ``template_ext`` is emptied: a file is read only when asked for explicitly
+ (``$f``), never by a rendered value's suffix.
+ """
+ op.template_fields = tuple(f for f in op.template_fields if f not in with_
or f in render_fields)
+ op.template_ext = ()
+ return op
+
+
+def _reject_unbindable_run_marker(task_id: str, value: Any, *, top_level: bool
= True) -> None:
+ """
+ Reject value-grammar a task handler's argument binding cannot carry.
+
+ The binding carries a JSON literal or a single top-level ``$x`` (an
upstream
+ task's return value). So a template (``$t``) or a keyed ``$x`` anywhere,
or a
+ plain ``$x`` nested inside a container instead of passed as its own
argument,
+ is an error. ``$f`` and ``$const`` lower to literals and are fine.
+ """
+ if isinstance(value, TemplateRef):
+ raise YamlDagBuildError(
+ f"task {task_id!r}: a template ($t) reference is not supported in
a run: task; the task "
+ "handler binding carries literal values and XCom return values,
not rendered templates"
+ )
+ if isinstance(value, XComRef):
+ target = value.target
+ if not isinstance(target, str) and target.key is not None:
+ raise YamlDagBuildError(
+ f"task {task_id!r}: a keyed XCom ($x with 'key') is not
supported in a run: task; "
+ "only a task's return value crosses the handler boundary"
+ )
+ if not top_level:
+ raise YamlDagBuildError(
+ f"task {task_id!r}: an XCom ($x) nested inside a run argument
is not supported; pass "
+ "the upstream output as its own top-level run argument"
+ )
+ return
+ if isinstance(value, Literal):
+ _reject_unbindable_run_marker(task_id, value.root, top_level=top_level)
+ elif isinstance(value, dict):
+ for item in value.values():
+ _reject_unbindable_run_marker(task_id, item, top_level=False)
+ elif isinstance(value, list):
+ for item in value:
+ _reject_unbindable_run_marker(task_id, item, top_level=False)
+
+
+def _reject_reserved_extra_keys(extras: Any) -> None:
+ """
+ Reject a marker key ($x/$t/$f/$const) nested in Dag- or task-level
arguments.
+
+ These arguments pass straight to ``DAG`` or the operator, never through the
+ value grammar, so a marker written there (e.g. ``params: {p: {$f: x}}``)
+ would be taken as a literal dict rather than flagged. Only marker keys are
+ rejected, so a genuine ``$`` key such as a JSON Schema ``$ref`` in a param
+ default is kept.
+ """
+ if isinstance(extras, dict):
+ for key, val in extras.items():
+ if key in _MARKER_KEYS:
+ raise YamlDagBuildError(f"marker key {key!r} may only be used
in 'with:' or 'run:'")
+ _reject_reserved_extra_keys(val)
+ elif isinstance(extras, list):
+ for item in extras:
+ _reject_reserved_extra_keys(item)
+
+
+def _coerce_temporal(mapping: dict[str, Any], target: type) -> dict[str, Any]:
+ """
+ Promote YAML-native dates and ISO date/datetime strings to datetimes.
+
+ Some fields on a Dag or operator are datetimes, but YAML has distinct date
+ and date-time formats, so a bare ``2024-01-01`` or a quoted ISO string
would
+ otherwise reach the datetime field unconverted. A date becomes midnight; an
+ ISO string is parsed (which also helps JSON compatibility).
+
+ Only values bound to a *target* argument annotated as ``datetime`` are
+ coerced. A non-temporal argument is left untouched, so a string that merely
+ looks like a date keeps its type.
+ """
+ temporal = _temporal_fields(target)
+ return {key: _to_datetime(value) if key in temporal else value for key,
value in mapping.items()}
+
+
+def _to_datetime(value: Any) -> Any:
+ if isinstance(value, datetime.datetime):
+ return value
+ if isinstance(value, datetime.date):
+ return datetime.datetime.combine(value, datetime.time.min)
+ if isinstance(value, str):
+ try:
+ return datetime.datetime.fromisoformat(value)
+ except ValueError:
+ return value
+ return value
+
+
+# Airflow annotates arguments with either stdlib datetime or pendulum DateTime.
+# Matched by name (see _temporal_fields) rather than resolved to avoid having
+# to evaluate dynamically.
+_TEMPORAL_TYPE_NAMES = frozenset({"datetime", "DateTime"})
+_TYPE_NAME_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_.]*")
+
+
[email protected]
+def _temporal_fields(target: type) -> frozenset[str]:
+ """
+ Names of *target*'s constructor arguments annotated as a datetime type.
+
+ The annotation text is matched against the datetime type names Airflow
uses;
+ it is never resolved, so one referring to a ``TYPE_CHECKING``-only name
does
+ not need importing. The constructor signature is read -- not class
+ attributes -- so attrs field aliases and operator-specific arguments are
+ covered. This is not perfect (a datetime type reached under another alias
is
+ missed); we'll fix edge cases as we go.
+ """
+ try:
+ params = inspect.signature(target).parameters
+ except (TypeError, ValueError):
+ return frozenset()
+ return frozenset(name for name, p in params.items() if
_is_temporal_annotation(str(p.annotation)))
+
+
+def _is_temporal_annotation(annotation: str) -> bool:
+ """Return True if *annotation* names a datetime type, ignoring any module
prefix."""
+ return any(ref.rsplit(".", 1)[-1] in _TEMPORAL_TYPE_NAMES for ref in
_TYPE_NAME_RE.findall(annotation))
+
+
+def _build_schedule(schedule: Any) -> Any:
+ """Build *schedule* into a schedule construct for :class:`DAG`."""
+ if isinstance(schedule, TimetableSchedule):
+ return import_string(schedule.uses)(**schedule.with_)
+ return schedule
+
+
+class _FieldMarkers(NamedTuple):
+ """Flags describing a value-grammar node's templating needs."""
+
+ needs_render: bool # carries $t or $x anywhere -- the field must render
+ has_marker: bool # carries $x/$t/$f -- only allowed on a templated field
+
+
+def _field_markers(value: Any) -> _FieldMarkers:
+ """
+ Scan a value-grammar node for its :class:`_FieldMarkers`.
+
+ ``$const`` is a force-literal escape, not a marker.
+ """
+ if isinstance(value, (XComRef, TemplateRef)):
+ return _FieldMarkers(needs_render=True, has_marker=True)
+ if isinstance(value, FileRef):
+ return _FieldMarkers(needs_render=False, has_marker=True)
+ if isinstance(value, ConstRef):
+ return _FieldMarkers(needs_render=False, has_marker=False)
+ if isinstance(value, Literal):
+ return _field_markers(value.root)
+ if isinstance(value, (dict, list)):
+ children = value.values() if isinstance(value, dict) else value
+ flags = [_field_markers(v) for v in children]
+ return _FieldMarkers(
+ needs_render=any(f.needs_render for f in flags),
+ has_marker=any(f.has_marker for f in flags),
+ )
+ return _FieldMarkers(needs_render=False, has_marker=False)
+
+
+def _topological_order(tasks: list[Task]) -> list[str]:
+ """Return task ids ordered so every task follows the tasks it depends
on."""
+ graph = {task.id_: task.get_dependency_ids() for task in tasks}
+ try:
+ return list(graphlib.TopologicalSorter(graph).static_order())
+ except graphlib.CycleError as exc:
+ raise YamlDagBuildError(f"dependency cycle among tasks:
{exc.args[1]}") from exc
diff --git a/task-sdk/src/airflow/sdk/importers/yaml_importer/importer.py
b/task-sdk/src/airflow/sdk/importers/yaml_importer/importer.py
new file mode 100644
index 00000000000..080e0fc97af
--- /dev/null
+++ b/task-sdk/src/airflow/sdk/importers/yaml_importer/importer.py
@@ -0,0 +1,169 @@
+# 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.
+
+"""
+AIP-85 YAML Dag importer.
+
+This discovers ``.yaml`` and ``.yml`` Dag files in a bundle, and builds the Dag
+objects they describe. It is not turned on by default; enable with::
+
+ [dag_processor]
+ dag_importer_configs = [{"classpath":
"airflow.sdk.importers.yaml_importer.YamlDagImporter"}]
+"""
+
+from __future__ import annotations
+
+import logging
+import traceback
+from typing import TYPE_CHECKING
+
+from airflow.sdk.importers.base import (
+ AbstractDagImporter,
+ DagImportError,
+ DagImportResult,
+ DagSourceCode,
+ FileDagDefinition,
+ _normalize_extensions,
+ find_file_dag_definitions,
+ get_file_suffix,
+)
+from airflow.sdk.importers.yaml_importer.builder import DagBuilder,
YamlDagBuildError
+from airflow.sdk.importers.yaml_importer.migrator import DAG_SCHEMA_PREFIX
+from airflow.sdk.importers.yaml_importer.parser import YamlDagParseError,
parse_documents
+
+if TYPE_CHECKING:
+ from collections.abc import Iterator
+ from pathlib import Path
+
+ from airflow.dag_processing.bundles.base import BaseDagBundle # noqa:
SDK002
+ from airflow.sdk import DAG
+ from airflow.sdk.importers.base import DagDefinition
+
+log = logging.getLogger(__name__)
+
+# The sniff matches the canonical Dag ``$schema`` URL, not a bare ``$schema``,
so a JSON-Schema
+# file (or any other YAML declaring a $schema) in the bundle is not queued and
then stuck as a
+# parse error. Reuses the migrator's public prefix to stay in sync.
+_DAG_SCHEMA_PREFIX = DAG_SCHEMA_PREFIX.encode()
+
+
+class YamlDagImporter(AbstractDagImporter[FileDagDefinition]):
+ """Importer for YAML DAG sources. Opt-in via ``dag_importer_configs`` (not
a default)."""
+
+ supported_extensions = [".yaml", ".yml"]
+
+ def __init__(self, extensions: list[str] | None = None) -> None:
+ if extensions is not None:
+ self.supported_extensions = _normalize_extensions(extensions)
+
+ def can_handle(self, definition: DagDefinition | str | Path) -> bool:
+ suffix = get_file_suffix(definition)
+ return suffix in self.supported_extensions if suffix else False
+
+ def list_dag_definitions(
+ self,
+ bundle: BaseDagBundle,
+ *,
+ safe_mode: bool = True,
+ ) -> Iterator[FileDagDefinition | DagImportError]:
+ for definition in find_file_dag_definitions(bundle.path,
self.supported_extensions):
+ if self.might_contain_dag(definition, safe_mode):
+ yield definition
+ else:
+ log.debug("Skipping %r: no '$schema' marker found", definition)
+
+ def import_definition(
+ self,
+ definition: FileDagDefinition,
+ bundle: BaseDagBundle,
+ ) -> DagImportResult:
+ result = DagImportResult(definition=definition)
+ ref = repr(definition)
+ try:
+ source = definition.read_text(encoding="utf-8")
+ except Exception as exc:
+ result.errors.append(
+ DagImportError(
+ source_reference=ref,
+ message=str(exc),
+ error_type="import",
+ stacktrace=traceback.format_exc(),
+ )
+ )
+ return result
+
+ # A parse failure fails the whole file (like a Python module that won't
+ # import). All documents' failures are reported together.
+ try:
+ documents = list(parse_documents(source, source=ref))
+ except YamlDagParseError as exc:
+ result.errors.append(DagImportError(source_reference=ref,
message=str(exc), error_type="parse"))
+ return result
+
+ dags: dict[str, DAG] = {}
+ build_errors: list[Exception] = []
+ with definition.import_context():
+ for document in documents:
+ try:
+ dag = DagBuilder(definition=definition,
bundle=bundle).build(document)
+ if dag.dag_id in dags:
+ raise YamlDagBuildError(f"duplicate dag_id
{dag.dag_id!r} across documents")
+ except Exception as exc:
+ exc.add_note(f"dag_id={document.dag_id!r}")
+ build_errors.append(exc)
+ else:
+ dags[dag.dag_id] = dag
+
+ if build_errors:
+ summary = f"{len(build_errors)} of {len(documents)} document(s)
failed to build"
+ detail = "\n".join(f"{err.__notes__[-1]}: {err}" for err in
build_errors)
+ stacktrace = "\n".join(
+ "".join(traceback.format_exception(type(err), err,
err.__traceback__)) for err in build_errors
+ )
+ result.errors.append(
+ DagImportError(
+ source_reference=ref,
+ message=f"{summary}:\n{detail}",
+ error_type="import",
+ stacktrace=stacktrace,
+ )
+ )
+ return result
+
+ result.dags.extend(dags.values())
+ for dag in dags.values():
+ log.debug("Built Dag %s from %r", dag.dag_id, definition)
+ return result
+
+ def get_source_code(self, definition: DagDefinition, dag_id: str | None =
None) -> DagSourceCode:
+ return
DagSourceCode(source_code=definition.read_text(encoding="utf-8"),
language="yaml")
+
+ def might_contain_dag(self, definition: DagDefinition, safe_mode: bool) ->
bool:
+ """
+ Cheap sniff: the file declares a ``$schema`` set to the canonical Dag
URL.
+
+ Both are required so a file that merely mentions the URL (without a
``$schema`` declaration)
+ is skipped. The sniff stays lenient past that -- a malformed Dag still
reaches the parser and
+ gets a clear error rather than silently disappearing. Keep the file if
the read fails.
+ """
+ if not safe_mode:
+ return True
+ try:
+ data = definition.read_bytes()
+ except OSError:
+ return True
+ return b"$schema" in data and _DAG_SCHEMA_PREFIX in data
diff --git a/task-sdk/src/airflow/sdk/importers/yaml_importer/migrator.py
b/task-sdk/src/airflow/sdk/importers/yaml_importer/migrator.py
index 9a27c07e4b9..90f6ebed807 100644
--- a/task-sdk/src/airflow/sdk/importers/yaml_importer/migrator.py
+++ b/task-sdk/src/airflow/sdk/importers/yaml_importer/migrator.py
@@ -44,9 +44,10 @@ 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")
+DAG_SCHEMA_PREFIX = _SCHEMA_URL.partition("{date}")[0]
+
def schema_from_version(date: str) -> str:
"""Build the canonical ``$schema`` URL for a published version *date*."""
diff --git a/task-sdk/src/airflow/sdk/importers/yaml_importer/models.py
b/task-sdk/src/airflow/sdk/importers/yaml_importer/models.py
index 94fdd50f852..35628dfed71 100644
--- a/task-sdk/src/airflow/sdk/importers/yaml_importer/models.py
+++ b/task-sdk/src/airflow/sdk/importers/yaml_importer/models.py
@@ -50,6 +50,7 @@ if TYPE_CHECKING:
XCOM_KEYS = ("$x", "$xcom")
TEMPLATE_KEYS = ("$t", "$template")
+FILE_KEYS = ("$f", "$file")
CONST_KEY = "$const"
# Keys deferred to a later edition...
@@ -106,13 +107,26 @@ class _MarkerRef(BaseModel):
_marker_keys: ClassVar[tuple[str, ...]]
+ @classmethod
+ def _process_value_schema(cls, field_schema: JsonSchemaValue) ->
JsonSchemaValue:
+ """
+ Post-process published schema for each marker key.
+
+ The default publishes the field's schema as a disjoint union.
+ :class:`FileRef` overrides it to reject a templated filename in the
+ schema too.
+ """
+ return _as_exclusive_union(field_schema)
+
@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["properties"] = {
+ key: cls._process_value_schema(next(iter(schema.get("properties",
{}).values())))
+ 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)
@@ -132,11 +146,54 @@ class XComRef(_MarkerRef):
class TemplateRef(_MarkerRef):
- """A Jinja template."""
+ """
+ A Jinja template, rendered when the task runs.
+
+ The template is a Jinja string, or a file reference (``$f``) to load a
+ template file.
+ """
+
+ model_config = ConfigDict(
+ extra="forbid",
+ json_schema_extra={
+ "description": (
+ "A Jinja template, rendered when the task runs. This can be an
"
+ "inline string, or a file reference ($f) to load a template
file."
+ ),
+ },
+ )
_marker_keys = TEMPLATE_KEYS
- source: str = Field(serialization_alias="$t",
validation_alias=AliasChoices(*TEMPLATE_KEYS))
+ source: str | FileRef = Field(serialization_alias="$t",
validation_alias=AliasChoices(*TEMPLATE_KEYS))
+
+
+class FileRef(_MarkerRef):
+ """
+ A file input (AIP-80 FileTemplate).
+
+ The path is a static string. A templated filename (a ``$f`` wrapping a
+ ``$t``) is reserved for a future release and rejected for now.
+ """
+
+ model_config = ConfigDict(
+ extra="forbid",
+ json_schema_extra={"description": "A file input."},
+ )
+
+ _marker_keys = FILE_KEYS
+
+ path: str | TemplateRef = Field(serialization_alias="$f",
validation_alias=AliasChoices(*FILE_KEYS))
+
+ @classmethod
+ def _process_value_schema(cls, field_schema: JsonSchemaValue) ->
JsonSchemaValue:
+ return {"type": "string"}
+
+ @model_validator(mode="after")
+ def _reject_templated_filename(self) -> FileRef:
+ if isinstance(self.path, TemplateRef):
+ raise ValueError("templated filename ($f wrapping $t) is not
supported yet")
+ return self
class ConstRef(_MarkerRef):
@@ -163,12 +220,16 @@ def _value_discriminator(v: Any) -> str:
return "xcom"
if key in TEMPLATE_KEYS:
return "template"
+ if key in FILE_KEYS:
+ return "file"
if key == CONST_KEY:
return "const"
if isinstance(v, XComRef):
return "xcom"
if isinstance(v, TemplateRef):
return "template"
+ if isinstance(v, FileRef):
+ return "file"
if isinstance(v, ConstRef):
return "const"
return "literal"
@@ -222,11 +283,14 @@ class Literal(RootModel):
Value = Annotated[
Annotated[XComRef, Tag("xcom")]
| Annotated[TemplateRef, Tag("template")]
+ | Annotated[FileRef, Tag("file")]
| Annotated[ConstRef, Tag("const")]
| Annotated[Literal, Tag("literal")],
Discriminator(_value_discriminator),
]
+TemplateRef.model_rebuild()
+FileRef.model_rebuild()
Literal.model_rebuild()
@@ -265,6 +329,13 @@ class Task(BaseModel):
raise ValueError(f"task {data.get('id')!r} may not set both
'uses' and 'run'")
return data
+ def get_dependency_ids(self) -> set[str]:
+ """Ids of the tasks this task depends on: explicit ``needs`` plus any
XCom references."""
+ ids = set(self.needs)
+ for value in itertools.chain(self.with_.values(), (self.run or
{}).values()):
+ ids.update(_iter_xcom_task_ids(value))
+ return ids
+
@classmethod
def __get_pydantic_json_schema__(
cls, core_schema: CoreSchema, handler: GetJsonSchemaHandler
@@ -449,10 +520,7 @@ class DagDocument(BaseModel):
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:
+ if t.id_ in (refs := t.get_dependency_ids()):
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)}")
diff --git a/task-sdk/src/airflow/sdk/importers/yaml_importer/schema.json
b/task-sdk/src/airflow/sdk/importers/yaml_importer/schema.json
index 953c61b6ccc..67686d202ae 100644
--- a/task-sdk/src/airflow/sdk/importers/yaml_importer/schema.json
+++ b/task-sdk/src/airflow/sdk/importers/yaml_importer/schema.json
@@ -14,6 +14,32 @@
"title": "ConstRef",
"type": "object"
},
+ "FileRef": {
+ "additionalProperties": false,
+ "description": "A file input.",
+ "oneOf": [
+ {
+ "required": [
+ "$f"
+ ]
+ },
+ {
+ "required": [
+ "$file"
+ ]
+ }
+ ],
+ "properties": {
+ "$f": {
+ "type": "string"
+ },
+ "$file": {
+ "type": "string"
+ }
+ },
+ "title": "FileRef",
+ "type": "object"
+ },
"Literal": {
"description": "A literal value.",
"not": {
@@ -34,6 +60,9 @@
{
"$ref": "#/$defs/TemplateRef"
},
+ {
+ "$ref": "#/$defs/FileRef"
+ },
{
"$ref": "#/$defs/ConstRef"
},
@@ -53,6 +82,9 @@
{
"$ref": "#/$defs/TemplateRef"
},
+ {
+ "$ref": "#/$defs/FileRef"
+ },
{
"$ref": "#/$defs/ConstRef"
},
@@ -140,6 +172,9 @@
{
"$ref": "#/$defs/TemplateRef"
},
+ {
+ "$ref": "#/$defs/FileRef"
+ },
{
"$ref": "#/$defs/ConstRef"
},
@@ -167,6 +202,9 @@
{
"$ref": "#/$defs/TemplateRef"
},
+ {
+ "$ref": "#/$defs/FileRef"
+ },
{
"$ref": "#/$defs/ConstRef"
},
@@ -196,7 +234,7 @@
},
"TemplateRef": {
"additionalProperties": false,
- "description": "A Jinja template.",
+ "description": "A Jinja template, rendered when the task runs. This can
be an inline string, or a file reference ($f) to load a template file.",
"oneOf": [
{
"required": [
@@ -211,12 +249,26 @@
],
"properties": {
"$t": {
- "title": "$T",
- "type": "string"
+ "oneOf": [
+ {
+ "type": "string"
+ },
+ {
+ "$ref": "#/$defs/FileRef"
+ }
+ ],
+ "title": "$T"
},
"$template": {
- "title": "$T",
- "type": "string"
+ "oneOf": [
+ {
+ "type": "string"
+ },
+ {
+ "$ref": "#/$defs/FileRef"
+ }
+ ],
+ "title": "$T"
}
},
"title": "TemplateRef",
diff --git a/task-sdk/src/airflow/sdk/importers/zip_importer.py
b/task-sdk/src/airflow/sdk/importers/zip_importer.py
index 1b7c53ad104..ff40089f750 100644
--- a/task-sdk/src/airflow/sdk/importers/zip_importer.py
+++ b/task-sdk/src/airflow/sdk/importers/zip_importer.py
@@ -21,6 +21,7 @@ from __future__ import annotations
import contextlib
import logging
import os
+import posixpath
import sys
import tempfile
import threading
@@ -101,6 +102,18 @@ class ZipMemberDagDefinition(FileDagDefinition):
self._content = z.read(self.file_path)
return self._content
+ def sibling(self, relpath: str) -> zipfile.Path:
+ base_dir = posixpath.dirname(self.file_path)
+ member = posixpath.normpath(posixpath.join(base_dir, relpath))
+ within = (
+ (member == base_dir or member.startswith(f"{base_dir}/"))
+ if base_dir
+ else not (member == ".." or member.startswith("../"))
+ )
+ if posixpath.isabs(member) or not within:
+ raise ValueError(f"{relpath!r} resolves outside the Dag file's
directory")
+ return zipfile.Path(self.zip_path, member)
+
@contextlib.contextmanager
def as_file(self) -> Generator[Path, None, None]:
# Keep the member's file name: importers may derive identity from it,
such as a Dag id.
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
new file mode 100644
index 00000000000..84b7c53511a
--- /dev/null
+++ b/task-sdk/tests/task_sdk/importers/yaml_importer/test_importer.py
@@ -0,0 +1,605 @@
+# 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 inspect
+import textwrap
+import zipfile
+from types import SimpleNamespace
+
+import pytest
+
+from airflow.sdk import BaseOperator, XComArg
+from airflow.sdk.definitions._internal.templater import LiteralValue
+from airflow.sdk.definitions.timetables.simple import OnceTimetable
+from airflow.sdk.importers import ZipMemberDagDefinition
+from airflow.sdk.importers.base import FilesystemDagDefinition
+from airflow.sdk.importers.yaml_importer.importer import YamlDagImporter
+
+HEAD_SCHEMA = "https://airflow.apache.org/schemas/dag/2026-10-30.json"
+
+
+class _RecordingOperator(BaseOperator):
+ """A trivial operator with one templated field (``payload``) and one plain
field (``note``)."""
+
+ template_fields = ("payload",)
+ template_ext = (".sql",) # non-empty so the importer emptying it to () is
observable, not vacuous
+
+ def __init__(self, *, payload=None, note=None, **kwargs):
+ super().__init__(**kwargs)
+ self.payload = payload
+ self.note = note
+
+ def execute(self, context):
+ return self.payload
+
+
+OP = f"{__name__}.{_RecordingOperator.__name__}"
+
+
+class _DefaultTemplateOperator(BaseOperator):
+ """Templates ``preset`` by default (set in __init__, not by the author)
plus author-set ``payload``."""
+
+ template_fields = ("payload", "preset")
+
+ def __init__(self, *, payload=None, preset="{{ ds }}", **kwargs):
+ super().__init__(**kwargs)
+ self.payload = payload
+ self.preset = preset
+
+ def execute(self, context):
+ return self.payload
+
+
+OP_DEFAULT_TEMPLATE = f"{__name__}.{_DefaultTemplateOperator.__name__}"
+
+
+class _TemporalOperator(BaseOperator):
+ """An operator with a datetime-typed argument (``cutoff``)."""
+
+ def __init__(self, *, cutoff: datetime.datetime | None = None, **kwargs):
+ super().__init__(**kwargs)
+ self.cutoff = cutoff
+
+
+OP_TEMPORAL = f"{__name__}.{_TemporalOperator.__name__}"
+
+
[email protected]
+def mock_bundle(tmp_path):
+ bundle_dir = tmp_path / "bundle"
+ bundle_dir.mkdir(parents=True, exist_ok=True)
+ return SimpleNamespace(name="test_bundle", path=bundle_dir)
+
+
+def _def(bundle, name: str, body: str) -> FilesystemDagDefinition:
+ path = bundle.path / name
+ path.write_text(textwrap.dedent(body))
+ return FilesystemDagDefinition(path=path)
+
+
+def _doc(tasks: str, *, dag_id: str = "d", extra: str = "") -> str:
+ return f"$schema: {HEAD_SCHEMA}\ndag_id:
{dag_id}\n{extra}tasks:\n{textwrap.indent(textwrap.dedent(tasks), ' ')}"
+
+
+def test_can_handle():
+ importer = YamlDagImporter()
+ assert importer.can_handle("dag.yaml")
+ assert importer.can_handle("dag.yml")
+ assert not importer.can_handle("dag.py")
+ assert not importer.can_handle("dag.json")
+
+
+def test_get_source_code(mock_bundle):
+ definition = _def(mock_bundle, "d.yaml", f"$schema: {HEAD_SCHEMA}\ndag_id:
d\ntasks: []\n")
+ src = YamlDagImporter().get_source_code(definition)
+ assert src.language == "yaml"
+ assert "$schema" in src.source_code
+
+
+def test_might_contain_dag(mock_bundle):
+ importer = YamlDagImporter()
+ has = _def(mock_bundle, "has.yaml", f"$schema: {HEAD_SCHEMA}\ndag_id:
d\ntasks: []\n")
+ nope = _def(mock_bundle, "nope.yaml", "just: config\n")
+ # A $schema that is not the canonical Dag URL (e.g. a JSON-Schema file)
must not be picked up.
+ other = _def(mock_bundle, "schema.yaml", "$schema:
https://json-schema.org/draft/2020-12/schema\n")
+ # The canonical URL merely mentioned, without a $schema declaration, must
not be picked up either.
+ mention = _def(mock_bundle, "mention.yaml", f"note: see {HEAD_SCHEMA}\n")
+ assert importer.might_contain_dag(has, safe_mode=True)
+ assert not importer.might_contain_dag(nope, safe_mode=True)
+ assert not importer.might_contain_dag(other, safe_mode=True)
+ assert not importer.might_contain_dag(mention, safe_mode=True)
+ assert importer.might_contain_dag(nope, safe_mode=False) # sniff skipped
+
+
+def test_list_dag_definitions_skips_non_dag_yaml(mock_bundle):
+ _def(mock_bundle, "dag.yaml", f"$schema: {HEAD_SCHEMA}\ndag_id: d\ntasks:
[]\n")
+ _def(mock_bundle, "config.yaml", "just: config\n") # no $schema -> skipped
+ _def(mock_bundle, "notes.txt", "hello") # wrong extension
+ names = {d.path.name for d in
YamlDagImporter().list_dag_definitions(mock_bundle)}
+ assert names == {"dag.yaml"}
+
+
+def test_import_builds_operator_dag(mock_bundle):
+ definition = _def(
+ mock_bundle,
+ "dag.yaml",
+ _doc(
+ f"""
+ - {{id: a, uses: {OP}, with: {{payload: hi}}}}
+ - {{id: b, uses: {OP}, with: {{payload: {{$x: a}}}}}}
+ """,
+ dag_id="pipeline",
+ extra="catchup: false\n",
+ ),
+ )
+ result = YamlDagImporter().import_definition(definition,
bundle=mock_bundle)
+ assert result.success, result.errors
+ (dag,) = result.dags
+ assert dag.dag_id == "pipeline"
+ assert set(dag.task_ids) == {"a", "b"}
+ assert dag.catchup is False # Dag-level attribute passed through
+ assert dag.bundle_name == "test_bundle"
+ a, b = dag.get_task("a"), dag.get_task("b")
+ assert a.payload == "hi" # literal
+ assert isinstance(b.payload, XComArg) # $x -> XComArg
+ assert b.upstream_task_ids == {"a"} # XCom wired the dependency
+ assert "payload" in b.template_fields # $x opted the field into rendering
+ assert "payload" not in a.template_fields # a's unmarked literal was
dropped from rendering
+
+
+def test_import_wires_needs(mock_bundle):
+ definition = _def(
+ mock_bundle,
+ "dag.yaml",
+ _doc(f"""
+ - {{id: a, uses: {OP}, with: {{payload: x}}}}
+ - {{id: b, uses: {OP}, with: {{payload: y}}, needs: [a]}}
+ """),
+ )
+ result = YamlDagImporter().import_definition(definition,
bundle=mock_bundle)
+ assert result.success, result.errors
+ assert result.dags[0].get_task("b").upstream_task_ids == {"a"}
+
+
+def test_import_bodyless_task_becomes_empty_operator(mock_bundle):
+ definition = _def(mock_bundle, "dag.yaml", _doc("- {id: a}"))
+ result = YamlDagImporter().import_definition(definition,
bundle=mock_bundle)
+ assert result.success, result.errors
+ assert type(result.dags[0].get_task("a")).__name__ == "EmptyOperator"
+
+
+def test_import_bodyless_task_with_args_errors(mock_bundle):
+ definition = _def(mock_bundle, "dag.yaml", _doc("- {id: a, with: {x: 1}}"))
+ result = YamlDagImporter().import_definition(definition,
bundle=mock_bundle)
+ assert not result.dags
+ assert any(
+ "Invalid arguments were passed to EmptyOperator" in e.message and
"'x'" in e.message
+ for e in result.errors
+ )
+
+
+def test_import_schedule_cron(mock_bundle):
+ definition = _def(
+ mock_bundle,
+ "dag.yaml",
+ _doc(f"- {{id: a, uses: {OP}, with: {{payload: x}}}}",
extra="schedule: '0 3 * * *'\n"),
+ )
+ result = YamlDagImporter().import_definition(definition,
bundle=mock_bundle)
+ assert result.success, result.errors
+ assert result.dags[0].schedule == "0 3 * * *"
+
+
+def test_import_schedule_timetable(mock_bundle):
+ definition = _def(
+ mock_bundle,
+ "dag.yaml",
+ _doc(
+ f"- {{id: a, uses: {OP}, with: {{payload: x}}}}",
+ extra="schedule: {uses:
airflow.sdk.definitions.timetables.simple.OnceTimetable}\n",
+ ),
+ )
+ result = YamlDagImporter().import_definition(definition,
bundle=mock_bundle)
+ assert result.success, result.errors
+ assert isinstance(result.dags[0].timetable, OnceTimetable)
+
+
+def test_import_keyed_xcom_on_uses_task(mock_bundle):
+ definition = _def(
+ mock_bundle,
+ "dag.yaml",
+ _doc(f"""
+ - {{id: a, uses: {OP}, with: {{payload: x}}}}
+ - {{id: b, uses: {OP}, with: {{payload: {{$x: {{task: a, key:
out}}}}}}}}
+ """),
+ )
+ result = YamlDagImporter().import_definition(definition,
bundle=mock_bundle)
+ assert result.success, result.errors
+ b = result.dags[0].get_task("b")
+ assert isinstance(b.payload, XComArg)
+ assert b.payload.key == "out"
+ assert b.upstream_task_ids == {"a"}
+
+
[email protected](
+ ("value", "hour"),
+ [("2024-01-01", 0), ("'2024-01-01'", 0), ("2024-01-01T03:30:00", 3)], #
YAML date, ISO string, datetime
+)
+def test_import_coerces_temporal_start_date(mock_bundle, value, hour):
+ definition = _def(
+ mock_bundle,
+ "dag.yaml",
+ _doc(f"- {{id: a, uses: {OP}, with: {{payload: x}}}}",
extra=f"start_date: {value}\n"),
+ )
+ result = YamlDagImporter().import_definition(definition,
bundle=mock_bundle)
+ assert result.success, result.errors
+ start = result.dags[0].start_date
+ assert isinstance(start, datetime.datetime) # promoted from date/string
+ assert start.tzinfo is not None # localized by DAG()
+ assert (start.year, start.month, start.day, start.hour) == (2024, 1, 1,
hour)
+
+
+def test_import_does_not_coerce_nontemporal_arg(mock_bundle):
+ definition = _def(
+ mock_bundle,
+ "dag.yaml",
+ _doc(f"- {{id: a, uses: {OP}, owner: '2024-01-01', with: {{payload:
x}}}}"),
+ )
+ result = YamlDagImporter().import_definition(definition,
bundle=mock_bundle)
+ assert result.success, result.errors
+ assert result.dags[0].get_task("a").owner == "2024-01-01"
+
+
+def test_import_coerces_temporal_with_arg(mock_bundle):
+ definition = _def(
+ mock_bundle,
+ "dag.yaml",
+ _doc(f"- {{id: a, uses: {OP_TEMPORAL}, with: {{cutoff:
2024-01-01}}}}"),
+ )
+ result = YamlDagImporter().import_definition(definition,
bundle=mock_bundle)
+ assert result.success, result.errors
+ cutoff = result.dags[0].get_task("a").cutoff
+ assert isinstance(cutoff, datetime.datetime)
+ assert (cutoff.year, cutoff.month, cutoff.day) == (2024, 1, 1)
+
+
+def test_import_code_task_builds_stub_operator(mock_bundle):
+ definition = _def(
+ mock_bundle,
+ "dag.yaml",
+ _doc(f"""
+ - {{id: a, uses: {OP}, with: {{payload: hi}}}}
+ - {{id: b, run: {{value: {{$x: a}}, label: done}}, queue:
handlers}}
+ """),
+ )
+ result = YamlDagImporter().import_definition(definition,
bundle=mock_bundle)
+ assert result.success, result.errors
+ b = result.dags[0].get_task("b")
+ assert type(b).__name__ == "_StubOperator"
+ assert getattr(b, "is_stub", False) is True # the real stub operator
Airflow serializes
+ assert b.queue == "handlers" # task-level attribute passed through
+ assert isinstance(b.op_kwargs["value"], XComArg) # $x -> XComArg, bound
as a handler argument
+ assert b.op_kwargs["label"] == "done" # literal bound verbatim (run args
are never rendered)
+ assert b.upstream_task_ids == {"a"} # $x wired the dependency
+ # The stub is fed a callable whose ordered parameters are the run argument
names, so core
+ # serialization can build the handler arg-binding spec (AIP-108) from a
real signature.
+ assert list(inspect.signature(b.python_callable).parameters) == ["value",
"label"]
+
+
+def test_import_mixed_container_keeps_literal_siblings_literal(mock_bundle):
+ (mock_bundle.path / "note.txt").write_text("hello")
+ definition = _def(
+ mock_bundle,
+ "dag.yaml",
+ _doc(f"""
+ - {{id: up, uses: {OP}, with: {{payload: x}}}}
+ - {{id: t, uses: {OP}, with: {{payload: {{a: {{$x: up}}, b: '{{{{
not a template }}}}', c: {{$const: 7}}, d: {{$f: note.txt}}}}}}}}
+ """),
+ )
+ result = YamlDagImporter().import_definition(definition,
bundle=mock_bundle)
+ assert result.success, result.errors
+ payload = result.dags[0].get_task("t").payload
+ assert isinstance(payload["a"], XComArg) # $x still resolves
+ # the container renders ($x present), so every literal sibling -- plain,
$const, $f -- stays wrapped
+ assert isinstance(payload["b"], LiteralValue)
+ assert payload["b"].value == "{{ not a template }}"
+ assert isinstance(payload["c"], LiteralValue)
+ assert payload["c"].value == 7
+ assert isinstance(payload["d"], LiteralValue)
+ assert payload["d"].value == "hello"
+
+
+def test_import_code_task_rejects_non_identifier_arg(mock_bundle):
+ definition = _def(mock_bundle, "dag.yaml", _doc("- {id: a, run:
{'bad-name': 1}}"))
+ result = YamlDagImporter().import_definition(definition,
bundle=mock_bundle)
+ assert not result.dags
+ assert any("not a valid identifier" in e.message for e in result.errors)
+
+
+def test_import_run_rejects_template(mock_bundle):
+ definition = _def(mock_bundle, "dag.yaml", _doc("- {id: a, run: {day: {$t:
'{{ ds }}'}}}"))
+ result = YamlDagImporter().import_definition(definition,
bundle=mock_bundle)
+ assert not result.dags
+ assert any("template ($t)" in e.message for e in result.errors)
+
+
+def test_import_run_rejects_keyed_xcom(mock_bundle):
+ definition = _def(
+ mock_bundle,
+ "dag.yaml",
+ _doc("""
+ - {id: a, run: {}}
+ - {id: b, run: {v: {$x: {task: a, key: k}}}}
+ """),
+ )
+ result = YamlDagImporter().import_definition(definition,
bundle=mock_bundle)
+ assert not result.dags
+ assert any("keyed XCom" in e.message for e in result.errors)
+
+
+def test_import_run_task_rejects_with(mock_bundle):
+ definition = _def(mock_bundle, "dag.yaml", _doc("- {id: t, run: {n: 1},
with: {region: eu}}"))
+ result = YamlDagImporter().import_definition(definition,
bundle=mock_bundle)
+ assert not result.dags
+ assert any("takes its inputs under 'run:'" in e.message for e in
result.errors)
+
+
+def test_import_run_rejects_nested_xcom(mock_bundle):
+ definition = _def(
+ mock_bundle,
+ "dag.yaml",
+ _doc(f"""
+ - {{id: a, uses: {OP}, with: {{payload: x}}}}
+ - {{id: t, run: {{items: [{{$x: a}}]}}}}
+ """),
+ )
+ result = YamlDagImporter().import_definition(definition,
bundle=mock_bundle)
+ assert not result.dags
+ assert any("nested inside a run argument" in e.message for e in
result.errors)
+
+
+def test_import_run_rejects_non_json_literal(mock_bundle):
+ definition = _def(mock_bundle, "dag.yaml", _doc("- {id: t, run: {day:
2024-01-01}}"))
+ result = YamlDagImporter().import_definition(definition,
bundle=mock_bundle)
+ assert not result.dags
+ assert any("must be JSON-serializable" in e.message for e in result.errors)
+
+
+def test_import_run_rejects_context_key_arg(mock_bundle):
+ definition = _def(mock_bundle, "dag.yaml", _doc("- {id: t, run: {ds: 1}}"))
+ result = YamlDagImporter().import_definition(definition,
bundle=mock_bundle)
+ assert not result.dags
+ assert any("cannot be named" in e.message for e in result.errors)
+
+
+def test_import_rejects_marker_key_in_extras(mock_bundle):
+ # Dag-level extras bypass the value grammar; a marker nested there is a
typo, not a literal dict.
+ definition = _def(
+ mock_bundle,
+ "dag.yaml",
+ _doc(f"- {{id: a, uses: {OP}, with: {{payload: x}}}}", extra="params:
{p: {$f: README.md}}\n"),
+ )
+ result = YamlDagImporter().import_definition(definition,
bundle=mock_bundle)
+ assert not result.dags
+ assert any("marker key '$f'" in e.message for e in result.errors)
+
+
[email protected]("marker", ["{$x: a}", "{$t: 'v'}", "{$f: q.sql}"])
+def test_import_marker_on_non_template_field_errors(mock_bundle, marker):
+ (mock_bundle.path / "q.sql").write_text("SELECT 1") # exists, so this is
not a missing-file error
+ definition = _def(
+ mock_bundle,
+ "dag.yaml",
+ _doc(f"""
+ - {{id: a, uses: {OP}, with: {{payload: hi}}}}
+ - {{id: b, uses: {OP}, with: {{payload: ok, note: {marker}}}}}
+ """),
+ )
+ result = YamlDagImporter().import_definition(definition,
bundle=mock_bundle)
+ assert not result.dags
+ assert any("only allowed on templated fields" in e.message for e in
result.errors)
+
+
+def test_import_base_operator_arg_in_with_errors(mock_bundle):
+ definition = _def(
+ mock_bundle, "dag.yaml", _doc(f"- {{id: a, uses: {OP}, with:
{{payload: x, retries: 2}}}}")
+ )
+ result = YamlDagImporter().import_definition(definition,
bundle=mock_bundle)
+ assert not result.dags
+ assert any("'retries'" in e.message and "not under 'with'" in e.message
for e in result.errors)
+
+
[email protected](
+ "body", ["- {{id: a, uses: {op}, with: {{payload: x}}, note: 1}}", "-
{{id: a, run: {{}}, note: 1}}"]
+)
+def test_import_operator_arg_at_task_level_errors(mock_bundle, body):
+ definition = _def(mock_bundle, "dag.yaml", _doc(body.format(op=OP)))
+ result = YamlDagImporter().import_definition(definition,
bundle=mock_bundle)
+ assert not result.dags
+ assert any("not task-level" in e.message and "'note'" in e.message for e
in result.errors)
+
+
+def test_import_literal_by_default(mock_bundle):
+ definition = _def(
+ mock_bundle, "dag.yaml", _doc(f"- {{id: a, uses: {OP}, with:
{{payload: '{{{{ ds }}}}'}}}}")
+ )
+ result = YamlDagImporter().import_definition(definition,
bundle=mock_bundle)
+ assert result.success, result.errors
+ a = result.dags[0].get_task("a")
+ assert a.payload == "{{ ds }}" # kept literal
+ assert tuple(a.template_fields) == () # not rendered
+ assert tuple(a.template_ext) == ()
+
+
+def test_import_template_marker_sets_template_fields(mock_bundle):
+ definition = _def(
+ mock_bundle, "dag.yaml", _doc(f"- {{id: a, uses: {OP}, with:
{{payload: {{$t: '{{{{ ds }}}}'}}}}}}")
+ )
+ result = YamlDagImporter().import_definition(definition,
bundle=mock_bundle)
+ assert result.success, result.errors
+ a = result.dags[0].get_task("a")
+ assert a.payload == "{{ ds }}"
+ assert set(a.template_fields) == {"payload"} # $t opts the field into
rendering
+
+
+def test_import_keeps_operator_default_template_fields(mock_bundle):
+ definition = _def(
+ mock_bundle, "dag.yaml", _doc(f"- {{id: a, uses:
{OP_DEFAULT_TEMPLATE}, with: {{payload: hi}}}}")
+ )
+ result = YamlDagImporter().import_definition(definition,
bundle=mock_bundle)
+ assert result.success, result.errors
+ a = result.dags[0].get_task("a")
+ assert set(a.template_fields) == {"preset"} # operator default kept;
author's unmarked payload dropped
+ assert a.payload == "hi" # author-set unmarked field no longer renders
+
+
+def test_import_file_ref_reads_verbatim(mock_bundle):
+ (mock_bundle.path / "query.sql").write_text("SELECT 1")
+ definition = _def(
+ mock_bundle, "dag.yaml", _doc(f"- {{id: a, uses: {OP}, with:
{{payload: {{$f: query.sql}}}}}}")
+ )
+ result = YamlDagImporter().import_definition(definition,
bundle=mock_bundle)
+ assert result.success, result.errors
+ a = result.dags[0].get_task("a")
+ assert a.payload == "SELECT 1" # file contents, read at build time
+ assert tuple(a.template_fields) == () # $f is verbatim, not a template
+
+
[email protected]("bad_path", ["../../etc/passwd", "/etc/passwd"])
+def test_import_file_ref_outside_dag_dir_is_rejected(mock_bundle, bad_path):
+ definition = _def(
+ mock_bundle, "dag.yaml", _doc(f"- {{id: a, uses: {OP}, with:
{{payload: {{$f: '{bad_path}'}}}}}}")
+ )
+ result = YamlDagImporter().import_definition(definition,
bundle=mock_bundle)
+ assert not result.dags
+ assert any("resolves outside" in e.message for e in result.errors)
+
+
+_ZIP_OP_SOURCE = """
+from airflow.sdk import BaseOperator
+
+
+class ZipOp(BaseOperator):
+ template_fields = ("payload",)
+
+ def __init__(self, *, payload=None, **kwargs):
+ super().__init__(**kwargs)
+ self.payload = payload
+
+ def execute(self, context):
+ return self.payload
+"""
+
+
+def test_import_yaml_inside_zip_bundle(tmp_path):
+ zip_path = tmp_path / "bundle.zip"
+ with zipfile.ZipFile(zip_path, "w") as z:
+ z.writestr("_ziptest_ops.py", _ZIP_OP_SOURCE)
+ z.writestr("q.sql", "SELECT 42")
+ z.writestr(
+ "dag.yaml",
+ _doc("- {id: a, uses: _ziptest_ops.ZipOp, with: {payload: {$f:
q.sql}}}", dag_id="z"),
+ )
+ definition = ZipMemberDagDefinition(zip_path=zip_path,
file_path="dag.yaml")
+ bundle = SimpleNamespace(name="zbundle", path=tmp_path)
+ result = YamlDagImporter().import_definition(definition, bundle=bundle)
+ assert result.success, result.errors
+ a = result.dags[0].get_task("a")
+ assert type(a).__name__ == "ZipOp" # imported from a module inside the
zip (import_context)
+ assert a.payload == "SELECT 42" # $f read a sibling member in the same
archive
+
+
+def test_import_template_file_reads_then_templates(mock_bundle):
+ (mock_bundle.path / "query.sql").write_text("SELECT {{ ds }}")
+ definition = _def(
+ mock_bundle,
+ "dag.yaml",
+ _doc(f"- {{id: a, uses: {OP}, with: {{payload: {{$t: {{$f:
query.sql}}}}}}}}"),
+ )
+ result = YamlDagImporter().import_definition(definition,
bundle=mock_bundle)
+ assert result.success, result.errors
+ a = result.dags[0].get_task("a")
+ assert a.payload == "SELECT {{ ds }}" # file contents, to be rendered at
run time
+ assert set(a.template_fields) == {"payload"} # $t over $f is a template
file
+
+
+def test_import_templated_filename_is_reserved(mock_bundle):
+ definition = _def(
+ mock_bundle,
+ "dag.yaml",
+ _doc(f"- {{id: a, uses: {OP}, with: {{payload: {{$f: {{$t: '{{{{ ds
}}}}.sql'}}}}}}}}"),
+ )
+ result = YamlDagImporter().import_definition(definition,
bundle=mock_bundle)
+ assert not result.dags
+ assert result.errors
+ assert result.errors[0].error_type == "parse"
+
+
+def test_import_parse_error(mock_bundle):
+ definition = _def(mock_bundle, "dag.yaml", "dag_id: d\ntasks: []\n") #
missing $schema
+ result = YamlDagImporter().import_definition(definition,
bundle=mock_bundle)
+ assert not result.dags
+ assert result.errors
+ assert result.errors[0].error_type == "parse"
+
+
+def test_import_unknown_operator_errors(mock_bundle):
+ definition = _def(mock_bundle, "dag.yaml", _doc("- {id: a, uses:
does.not.Exist, with: {}}"))
+ result = YamlDagImporter().import_definition(definition,
bundle=mock_bundle)
+ assert not result.dags
+ assert any("No module named" in e.message for e in result.errors)
+
+
+def test_import_one_bad_document_fails_whole_file(mock_bundle):
+ good = _doc(f"- {{id: a, uses: {OP}, with: {{payload: x}}}}",
dag_id="good")
+ bad = _doc("- {id: a, uses: does.not.Exist, with: {}}", dag_id="bad")
+ definition = _def(mock_bundle, "dag.yaml", f"{good}\n---\n{bad}")
+ result = YamlDagImporter().import_definition(definition,
bundle=mock_bundle)
+ assert not result.dags
+ assert len(result.errors) == 1
+ err = result.errors[0]
+ assert "dag_id=" not in err.source_reference # keyed by the file, not the
document
+ assert "dag_id='bad'" in err.message # the failing document is named, not
mashed
+
+
+def test_import_duplicate_dag_id_across_documents_errors(mock_bundle):
+ one = _doc(f"- {{id: a, uses: {OP}, with: {{payload: x}}}}", dag_id="dup")
+ two = _doc(f"- {{id: b, uses: {OP}, with: {{payload: y}}}}", dag_id="dup")
+ definition = _def(mock_bundle, "dag.yaml", f"{one}\n---\n{two}")
+ result = YamlDagImporter().import_definition(definition,
bundle=mock_bundle)
+ assert not result.dags
+ assert any("duplicate dag_id 'dup'" in e.message for e in result.errors)
+
+
+def test_import_dependency_cycle_errors(mock_bundle):
+ definition = _def(
+ mock_bundle,
+ "dag.yaml",
+ _doc(f"""
+ - {{id: a, uses: {OP}, with: {{payload: x}}, needs: [b]}}
+ - {{id: b, uses: {OP}, with: {{payload: y}}, needs: [a]}}
+ - {{id: c, uses: {OP}, with: {{payload: z}}, needs: [a]}}
+ """),
+ )
+ result = YamlDagImporter().import_definition(definition,
bundle=mock_bundle)
+ assert not result.dags
+ (error,) = [e for e in result.errors if "cycle" in e.message]
+ assert "'a'" in error.message
+ assert "'b'" in error.message
+ assert "'c'" not in error.message
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
index 22a69df2c75..91b0dd33f17 100644
--- a/task-sdk/tests/task_sdk/importers/yaml_importer/test_parser.py
+++ b/task-sdk/tests/task_sdk/importers/yaml_importer/test_parser.py
@@ -531,6 +531,14 @@ def
test_published_schema_accepts_null_and_empty_bodies(task):
"tasks:\n - {id: t, uses: X, with: {k: {$t: 'v-{{ ds }}'}}}",
id="template",
),
+ pytest.param(
+ "tasks:\n - {id: t, uses: X, with: {k: {$f:
'reports/daily.sql'}}}",
+ id="file",
+ ),
+ pytest.param(
+ "tasks:\n - {id: t, uses: X, with: {k: {$t: {$f:
'reports/daily.sql'}}}}",
+ id="template-file",
+ ),
pytest.param(
"templates: {base: {run: {r: 1}}}\ntasks:\n - {id: t, extends:
base}",
id="extends-bare",
@@ -564,6 +572,8 @@ def test_valid_shapes_round_trip(body):
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"),
+ pytest.param("{$f: {$t: 'q-{{ ds }}.sql'}}",
id="templated-filename-reserved"),
+ pytest.param("{$t: {$f: {$t: x}}}",
id="templated-filename-nested-reserved"),
],
)
def test_invalid_shapes_rejected_by_both(value):