This is an automated email from the ASF dual-hosted git repository.
jason810496 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 7b240274732 Extract shared bundle metadata logic from coordinators
(#69296)
7b240274732 is described below
commit 7b24027473222259fd9e0ae1a8d8c6aee72ff53f
Author: Guan-Ming Chiu <[email protected]>
AuthorDate: Sat Jul 4 09:43:15 2026 +0800
Extract shared bundle metadata logic from coordinators (#69296)
---
.../airflow/sdk/coordinators/_bundle_metadata.py | 82 ++++++++++++++++++++++
.../sdk/coordinators/executable/coordinator.py | 62 +++-------------
.../airflow/sdk/coordinators/java/coordinator.py | 20 +-----
.../airflow/sdk/coordinators/node/coordinator.py | 56 ++++-----------
.../coordinators/executable/test_coordinator.py | 4 +-
5 files changed, 110 insertions(+), 114 deletions(-)
diff --git a/task-sdk/src/airflow/sdk/coordinators/_bundle_metadata.py
b/task-sdk/src/airflow/sdk/coordinators/_bundle_metadata.py
new file mode 100644
index 00000000000..e95ffe52396
--- /dev/null
+++ b/task-sdk/src/airflow/sdk/coordinators/_bundle_metadata.py
@@ -0,0 +1,82 @@
+#
+# 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.
+"""Bundle-metadata helpers shared by the subprocess coordinators."""
+
+from __future__ import annotations
+
+import os
+import pathlib
+from typing import Any
+
+import attrs
+import yaml
+
+from airflow.sdk.execution_time.schema import get_schema_version_migrator
+
+
+def convert_roots(
+ value: None | os.PathLike[str] | pathlib.Path | list[os.PathLike[str] |
pathlib.Path],
+) -> list[pathlib.Path]:
+ """Normalize a coordinator's root-directories kwarg into a list of
expanded paths."""
+ if value is None:
+ return []
+ if isinstance(value, (str, os.PathLike, pathlib.Path)):
+ return [pathlib.Path(value).expanduser()]
+ return [pathlib.Path(v).expanduser() for v in value]
+
+
+def validate_schema_version(instance, _, value) -> str:
+ """Attrs validator resolving a bundle's supervisor schema version to a
known one."""
+ return get_schema_version_migrator().resolve_version(str(value))
+
+
[email protected]
+class ResolvedBundle:
+ """A located bundle whose supervisor schema version has been resolved."""
+
+ path: pathlib.Path
+ schema_version: str = attrs.field(validator=validate_schema_version)
+
+
+def parse_metadata_mapping(content: str | bytes, *, source: str) -> dict[str,
Any]:
+ """
+ Parse *content* as the ``airflow-metadata.yaml`` mapping.
+
+ Raises ``ValueError`` on undecodable, unparsable, or non-mapping content;
+ *source* names the metadata's origin in the error message.
+ """
+ try:
+ data = yaml.safe_load(content.decode("utf-8") if isinstance(content,
bytes) else content)
+ except (UnicodeDecodeError, yaml.YAMLError) as exc:
+ raise ValueError(f"cannot parse {source}: {exc}") from exc
+
+ if not isinstance(data, dict):
+ raise ValueError(f"{source} must contain a mapping")
+ return data
+
+
+def extract_supervisor_schema_version(metadata: dict[str, Any]) -> str:
+ """Return ``sdk.supervisor_schema_version`` from bundle metadata, raising
``ValueError`` if absent."""
+ sdk = metadata.get("sdk")
+ if not isinstance(sdk, dict):
+ raise ValueError("missing sdk metadata mapping")
+
+ value = sdk.get("supervisor_schema_version")
+ if not isinstance(value, str) or not value:
+ raise ValueError("missing or invalid sdk.supervisor_schema_version")
+ return value
diff --git a/task-sdk/src/airflow/sdk/coordinators/executable/coordinator.py
b/task-sdk/src/airflow/sdk/coordinators/executable/coordinator.py
index 2275628d0b1..70ba5490450 100644
--- a/task-sdk/src/airflow/sdk/coordinators/executable/coordinator.py
+++ b/task-sdk/src/airflow/sdk/coordinators/executable/coordinator.py
@@ -29,10 +29,14 @@ from typing import TYPE_CHECKING, Any, BinaryIO
import attrs
import structlog
-import yaml
+from airflow.sdk.coordinators._bundle_metadata import (
+ ResolvedBundle,
+ convert_roots,
+ extract_supervisor_schema_version,
+ parse_metadata_mapping,
+)
from airflow.sdk.coordinators._subprocess import SubprocessCoordinator
-from airflow.sdk.execution_time.schema import get_schema_version_migrator
if TYPE_CHECKING:
from collections.abc import Iterable, Iterator, Sequence
@@ -236,21 +240,11 @@ def _read_bundle_metadata(path: pathlib.Path) ->
dict[str, Any] | None:
return None
try:
- data = yaml.safe_load(metadata_bytes.decode("utf-8"))
- except (UnicodeDecodeError, yaml.YAMLError) as exc:
+ return parse_metadata_mapping(metadata_bytes, source="bundle metadata")
+ except ValueError as exc:
log.debug("Cannot decode bundle metadata; skipping", path=str(path),
error=str(exc))
return None
- if not isinstance(data, dict):
- log.debug(
- "Bundle metadata is not a mapping; skipping",
- path=str(path),
- type=type(data).__name__,
- )
- return None
-
- return data
-
def _dag_ids(metadata: dict[str, Any]) -> set[str]:
dags = metadata.get("dags")
@@ -260,18 +254,6 @@ def _dag_ids(metadata: dict[str, Any]) -> set[str]:
return set(dags.keys())
-def _supervisor_schema_version(metadata: dict[str, Any]) -> str | None:
- sdk = metadata.get("sdk")
- if not isinstance(sdk, dict):
- return None
-
- value = sdk.get("supervisor_schema_version")
- if not isinstance(value, str) or not value:
- return None
-
- return value
-
-
def _find_executables(items: Iterable[pathlib.Path]) -> Iterator[pathlib.Path]:
"""
Yield executable regular files under *items*, descending into directories.
@@ -308,15 +290,8 @@ def _walk_executables(
yield item
-def _validate_schema_version(instance, _, value) -> str:
- return get_schema_version_migrator().resolve_version(str(value))
-
-
@attrs.define
-class _Bundle:
- path: pathlib.Path
- schema_version: str = attrs.field(validator=_validate_schema_version)
-
+class _Bundle(ResolvedBundle):
@classmethod
def find(cls, executables_root: Sequence[pathlib.Path], dag_id: str) ->
Self:
log.debug("Finding executable bundles recursively",
roots=executables_root)
@@ -328,12 +303,7 @@ class _Bundle:
continue
try:
- if (schema_version := _supervisor_schema_version(metadata)) is
None:
- reason = "missing or invalid sdk.supervisor_schema_version"
- log.debug("Bundle metadata rejected; skipping",
path=str(p), error=reason)
- rejected.append((p.resolve(), reason))
- continue
- return cls(path=p.resolve(), schema_version=schema_version)
+ return cls(path=p.resolve(),
schema_version=extract_supervisor_schema_version(metadata))
except (TypeError, ValueError) as exc:
log.debug("Bundle metadata rejected; skipping", path=str(p),
error=str(exc))
rejected.append((p.resolve(), str(exc)))
@@ -352,16 +322,6 @@ class _Bundle:
raise FileNotFoundError(tp.format(dag_id, resolved_paths, details))
-def _convert_executables_root(
- value: None | os.PathLike[str] | pathlib.Path | list[os.PathLike[str] |
pathlib.Path],
-) -> list[pathlib.Path]:
- if value is None:
- return []
- if isinstance(value, (str, os.PathLike, pathlib.Path)):
- return [pathlib.Path(value).expanduser()]
- return [pathlib.Path(v).expanduser() for v in value]
-
-
@attrs.define(kw_only=True)
class ExecutableCoordinator(SubprocessCoordinator):
"""
@@ -385,7 +345,7 @@ class ExecutableCoordinator(SubprocessCoordinator):
"""
executables_root: list[pathlib.Path] = attrs.field(
- converter=_convert_executables_root,
+ converter=convert_roots,
validator=attrs.validators.min_len(1),
)
diff --git a/task-sdk/src/airflow/sdk/coordinators/java/coordinator.py
b/task-sdk/src/airflow/sdk/coordinators/java/coordinator.py
index da35d0f37b8..0ebe089162b 100644
--- a/task-sdk/src/airflow/sdk/coordinators/java/coordinator.py
+++ b/task-sdk/src/airflow/sdk/coordinators/java/coordinator.py
@@ -29,8 +29,8 @@ from typing import TYPE_CHECKING
import attrs
import structlog
+from airflow.sdk.coordinators._bundle_metadata import convert_roots,
validate_schema_version
from airflow.sdk.coordinators._subprocess import SubprocessCoordinator
-from airflow.sdk.execution_time.schema import get_schema_version_migrator
if TYPE_CHECKING:
from collections.abc import Iterable, Iterator, Sequence
@@ -109,14 +109,10 @@ class _JarMetadata:
return None
-def _validate_schema_version(instance, _, value) -> str:
- return get_schema_version_migrator().resolve_version(str(value))
-
-
@attrs.define
class _JarInfo:
main_class: str
- schema_version: str = attrs.field(validator=_validate_schema_version)
+ schema_version: str = attrs.field(validator=validate_schema_version)
@attrs.define
class _Progress:
@@ -156,16 +152,6 @@ class _JarInfo:
raise FileNotFoundError(tp.format(main_class,
os.pathsep.join(os.fspath(p.resolve()) for p in roots)))
-def _convert_jars_root(
- value: None | os.PathLike[str] | pathlib.Path | list[os.PathLike[str] |
pathlib.Path],
-) -> list[pathlib.Path]:
- if value is None:
- return []
- if isinstance(value, (str, os.PathLike, pathlib.Path)):
- return [pathlib.Path(value).expanduser()]
- return [pathlib.Path(v).expanduser() for v in value]
-
-
@attrs.define(kw_only=True)
class JavaCoordinator(SubprocessCoordinator):
"""
@@ -213,7 +199,7 @@ class JavaCoordinator(SubprocessCoordinator):
java_executable: str = "java"
jvm_args: list[str] = attrs.field(factory=list)
jars_root: list[pathlib.Path] = attrs.field(
- converter=_convert_jars_root,
+ converter=convert_roots,
validator=attrs.validators.min_len(1),
)
main_class: str = ""
diff --git a/task-sdk/src/airflow/sdk/coordinators/node/coordinator.py
b/task-sdk/src/airflow/sdk/coordinators/node/coordinator.py
index c1b23615b89..41b07710f8f 100644
--- a/task-sdk/src/airflow/sdk/coordinators/node/coordinator.py
+++ b/task-sdk/src/airflow/sdk/coordinators/node/coordinator.py
@@ -25,10 +25,14 @@ from typing import TYPE_CHECKING, Any
import attrs
import structlog
-import yaml
+from airflow.sdk.coordinators._bundle_metadata import (
+ ResolvedBundle,
+ convert_roots,
+ extract_supervisor_schema_version,
+ parse_metadata_mapping,
+)
from airflow.sdk.coordinators._subprocess import SubprocessCoordinator
-from airflow.sdk.execution_time.schema import get_schema_version_migrator
if TYPE_CHECKING:
from collections.abc import Sequence
@@ -43,45 +47,19 @@ BUNDLE_FILENAME = "bundle.mjs"
METADATA_FILENAME = "airflow-metadata.yaml"
-def _validate_schema_version(instance, _, value) -> str:
- return get_schema_version_migrator().resolve_version(str(value))
-
-
[email protected]
-class _NodeBundle:
- path: pathlib.Path
- schema_version: str = attrs.field(validator=_validate_schema_version)
-
-
def _read_bundle_metadata(metadata_path: pathlib.Path) -> dict[str, Any]:
if not metadata_path.is_file():
raise ValueError(f"missing {METADATA_FILENAME}")
try:
- with metadata_path.open(encoding="utf-8") as metadata_file:
- data = yaml.safe_load(metadata_file)
+ content = metadata_path.read_text(encoding="utf-8")
except OSError as exc:
raise ValueError(f"cannot read {METADATA_FILENAME}: {exc}") from exc
- except yaml.YAMLError as exc:
- raise ValueError(f"cannot parse {METADATA_FILENAME}: {exc}") from exc
-
- if not isinstance(data, dict):
- raise ValueError(f"{METADATA_FILENAME} must contain a mapping")
- return data
+ return parse_metadata_mapping(content, source=METADATA_FILENAME)
-def _supervisor_schema_version(metadata: dict[str, Any]) -> str:
- sdk = metadata.get("sdk")
- if not isinstance(sdk, dict):
- raise ValueError("missing sdk metadata mapping")
- value = sdk.get("supervisor_schema_version")
- if not isinstance(value, str) or not value:
- raise ValueError("missing or invalid sdk.supervisor_schema_version")
- return value
-
-
-def _find_bundle(bundles_root: Sequence[pathlib.Path]) -> _NodeBundle:
+def _find_bundle(bundles_root: Sequence[pathlib.Path]) -> ResolvedBundle:
"""
Locate the ``.mjs`` entry point in *bundles_root*.
@@ -101,9 +79,9 @@ def _find_bundle(bundles_root: Sequence[pathlib.Path]) ->
_NodeBundle:
try:
metadata = _read_bundle_metadata(root / METADATA_FILENAME)
log.debug("Selected TypeScript bundle", path=candidate, root=root)
- return _NodeBundle(
+ return ResolvedBundle(
path=candidate,
- schema_version=_supervisor_schema_version(metadata),
+ schema_version=extract_supervisor_schema_version(metadata),
)
except (TypeError, ValueError) as exc:
log.debug(
@@ -123,16 +101,6 @@ def _find_bundle(bundles_root: Sequence[pathlib.Path]) ->
_NodeBundle:
raise FileNotFoundError(f"Cannot find {BUNDLE_FILENAME} in {searched}")
-def _convert_bundles_root(
- value: None | os.PathLike[str] | pathlib.Path | list[os.PathLike[str] |
pathlib.Path],
-) -> list[pathlib.Path]:
- if value is None:
- return []
- if isinstance(value, (str, os.PathLike, pathlib.Path)):
- return [pathlib.Path(value).expanduser()]
- return [pathlib.Path(v).expanduser() for v in value]
-
-
@attrs.define(kw_only=True)
class NodeCoordinator(SubprocessCoordinator):
"""
@@ -163,7 +131,7 @@ class NodeCoordinator(SubprocessCoordinator):
node_executable: str = "node"
bundles_root: list[pathlib.Path] = attrs.field(
- converter=_convert_bundles_root,
+ converter=convert_roots,
validator=attrs.validators.min_len(1),
)
diff --git
a/task-sdk/tests/task_sdk/coordinators/executable/test_coordinator.py
b/task-sdk/tests/task_sdk/coordinators/executable/test_coordinator.py
index ec426a09ed7..5963e5f4892 100644
--- a/task-sdk/tests/task_sdk/coordinators/executable/test_coordinator.py
+++ b/task-sdk/tests/task_sdk/coordinators/executable/test_coordinator.py
@@ -354,9 +354,9 @@ class TestBundleFind:
_Bundle.find([tmp_path], "tutorial_dag")
mock_log.debug.assert_any_call(
- "Bundle metadata is not a mapping; skipping",
+ "Cannot decode bundle metadata; skipping",
path=str(bundle_path),
- type=mock.ANY,
+ error=mock.ANY,
)