This is an automated email from the ASF dual-hosted git repository.
potiuk 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 008b60f97d3 Fix version detection for airflow-ctl and task-sdk in docs
script (#63917)
008b60f97d3 is described below
commit 008b60f97d398272fef6224df26f82d8b4f4ea0b
Author: Jarek Potiuk <[email protected]>
AuthorDate: Thu Mar 19 10:12:42 2026 +0100
Fix version detection for airflow-ctl and task-sdk in docs script (#63917)
The store_stable_versions.py script failed to determine the version for
apache-airflow-ctl (and would also fail for task-sdk) because it looked
for a static `version = "..."` in pyproject.toml, but both packages use
dynamic versioning via `__version__` in their __init__.py files.
Read __version__ directly from the source files where hatch defines them
instead of trying to parse pyproject.toml. Add comprehensive tests.
---
scripts/ci/docs/__init__.py | 16 ++
scripts/ci/docs/store_stable_versions.py | 42 +--
scripts/tests/ci/docs/__init__.py | 16 ++
.../tests/ci/docs/test_store_stable_versions.py | 281 +++++++++++++++++++++
4 files changed, 327 insertions(+), 28 deletions(-)
diff --git a/scripts/ci/docs/__init__.py b/scripts/ci/docs/__init__.py
new file mode 100644
index 00000000000..13a83393a91
--- /dev/null
+++ b/scripts/ci/docs/__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/scripts/ci/docs/store_stable_versions.py
b/scripts/ci/docs/store_stable_versions.py
index 6579d614dfe..91fb8247761 100755
--- a/scripts/ci/docs/store_stable_versions.py
+++ b/scripts/ci/docs/store_stable_versions.py
@@ -73,18 +73,6 @@ def get_version_from_provider_yaml(provider_yaml_path: Path)
-> str | None:
return None
-def get_version_from_pyproject_toml(pyproject_path: Path) -> str | None:
- """Get version from pyproject.toml file."""
- if not pyproject_path.exists():
- return None
-
- content = pyproject_path.read_text()
- match = re.search(r'^version\s*=\s*["\']([^"\']+)["\']', content,
re.MULTILINE)
- if match:
- return match.group(1)
- return None
-
-
def get_helm_chart_version(chart_yaml_path: Path) -> str | None:
"""Get version from Chart.yaml file."""
if not chart_yaml_path.exists():
@@ -97,30 +85,28 @@ def get_helm_chart_version(chart_yaml_path: Path) -> str |
None:
return None
+def get_version_from_init_py(init_py_path: Path) -> str | None:
+ """Get version from __version__ in an __init__.py file."""
+ if not init_py_path.exists():
+ return None
+
+ content = init_py_path.read_text()
+ match = re.search(r'^__version__\s*=\s*["\']([^"\']+)["\']', content,
re.MULTILINE)
+ if match:
+ return match.group(1)
+ return None
+
+
def get_package_version(package_name: str, airflow_root: Path) -> str | None:
"""Get version for a package based on its type and metadata location."""
if package_name == "apache-airflow":
return get_airflow_version(airflow_root)
if package_name == "apache-airflow-ctl":
- # Try provider.yaml first
- provider_yaml = airflow_root / "airflow-ctl" / "src" / "airflow_ctl" /
"provider.yaml"
- version = get_version_from_provider_yaml(provider_yaml)
- if version:
- return version
- # Fallback to pyproject.toml
- pyproject = airflow_root / "airflow-ctl" / "pyproject.toml"
- return get_version_from_pyproject_toml(pyproject)
+ return get_version_from_init_py(airflow_root / "airflow-ctl" / "src" /
"airflowctl" / "__init__.py")
if package_name == "task-sdk":
- # Try provider.yaml first
- provider_yaml = airflow_root / "task-sdk" / "src" / "task_sdk" /
"provider.yaml"
- version = get_version_from_provider_yaml(provider_yaml)
- if version:
- return version
- # Fallback to pyproject.toml
- pyproject = airflow_root / "task-sdk" / "pyproject.toml"
- return get_version_from_pyproject_toml(pyproject)
+ return get_version_from_init_py(airflow_root / "task-sdk" / "src" /
"airflow" / "sdk" / "__init__.py")
if package_name == "helm-chart":
chart_yaml = airflow_root / "chart" / "Chart.yaml"
diff --git a/scripts/tests/ci/docs/__init__.py
b/scripts/tests/ci/docs/__init__.py
new file mode 100644
index 00000000000..13a83393a91
--- /dev/null
+++ b/scripts/tests/ci/docs/__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/scripts/tests/ci/docs/test_store_stable_versions.py
b/scripts/tests/ci/docs/test_store_stable_versions.py
new file mode 100644
index 00000000000..ef02e2e5787
--- /dev/null
+++ b/scripts/tests/ci/docs/test_store_stable_versions.py
@@ -0,0 +1,281 @@
+# 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 yaml
+from ci.docs.store_stable_versions import (
+ get_airflow_version,
+ get_helm_chart_version,
+ get_package_version,
+ get_version_from_init_py,
+ get_version_from_provider_yaml,
+ main,
+)
+
+
+class TestGetAirflowVersion:
+ def test_airflow_3x_location(self, tmp_path):
+ init_file = tmp_path / "airflow-core" / "src" / "airflow" /
"__init__.py"
+ init_file.parent.mkdir(parents=True)
+ init_file.write_text('__version__ = "3.2.0"\n')
+
+ assert get_airflow_version(tmp_path) == "3.2.0"
+
+ def test_airflow_2x_fallback(self, tmp_path):
+ init_file = tmp_path / "airflow" / "__init__.py"
+ init_file.parent.mkdir(parents=True)
+ init_file.write_text('__version__ = "2.10.1"\n')
+
+ assert get_airflow_version(tmp_path) == "2.10.1"
+
+ def test_prefers_3x_over_2x(self, tmp_path):
+ init_3x = tmp_path / "airflow-core" / "src" / "airflow" / "__init__.py"
+ init_3x.parent.mkdir(parents=True)
+ init_3x.write_text('__version__ = "3.0.0"\n')
+
+ init_2x = tmp_path / "airflow" / "__init__.py"
+ init_2x.parent.mkdir(parents=True)
+ init_2x.write_text('__version__ = "2.0.0"\n')
+
+ assert get_airflow_version(tmp_path) == "3.0.0"
+
+ def test_missing_file(self, tmp_path):
+ assert get_airflow_version(tmp_path) is None
+
+ def test_no_version_in_file(self, tmp_path):
+ init_file = tmp_path / "airflow-core" / "src" / "airflow" /
"__init__.py"
+ init_file.parent.mkdir(parents=True)
+ init_file.write_text("# no version here\n")
+
+ assert get_airflow_version(tmp_path) is None
+
+
+class TestGetVersionFromProviderYaml:
+ def test_reads_first_version(self, tmp_path):
+ provider_yaml = tmp_path / "provider.yaml"
+ provider_yaml.write_text(yaml.dump({"versions": ["2.3.0", "2.2.0",
"2.1.0"]}))
+
+ assert get_version_from_provider_yaml(provider_yaml) == "2.3.0"
+
+ def test_missing_file(self, tmp_path):
+ assert get_version_from_provider_yaml(tmp_path / "provider.yaml") is
None
+
+ def test_empty_versions_list(self, tmp_path):
+ provider_yaml = tmp_path / "provider.yaml"
+ provider_yaml.write_text(yaml.dump({"versions": []}))
+
+ assert get_version_from_provider_yaml(provider_yaml) is None
+
+ def test_no_versions_key(self, tmp_path):
+ provider_yaml = tmp_path / "provider.yaml"
+ provider_yaml.write_text(yaml.dump({"name": "some-provider"}))
+
+ assert get_version_from_provider_yaml(provider_yaml) is None
+
+ def test_numeric_version_converted_to_string(self, tmp_path):
+ provider_yaml = tmp_path / "provider.yaml"
+ # YAML will parse "1.0" as float 1.0
+ provider_yaml.write_text("versions:\n - 1.0\n")
+
+ assert get_version_from_provider_yaml(provider_yaml) == "1.0"
+
+
+class TestGetHelmChartVersion:
+ def test_reads_version(self, tmp_path):
+ chart_yaml = tmp_path / "Chart.yaml"
+ chart_yaml.write_text("version: 1.16.0\nappVersion: 3.2.0\n")
+
+ assert get_helm_chart_version(chart_yaml) == "1.16.0"
+
+ def test_missing_file(self, tmp_path):
+ assert get_helm_chart_version(tmp_path / "Chart.yaml") is None
+
+ def test_no_version_field(self, tmp_path):
+ chart_yaml = tmp_path / "Chart.yaml"
+ chart_yaml.write_text("name: airflow\n")
+
+ assert get_helm_chart_version(chart_yaml) is None
+
+
+class TestGetVersionFromInitPy:
+ def test_reads_version_double_quotes(self, tmp_path):
+ init_py = tmp_path / "__init__.py"
+ init_py.write_text('__version__ = "0.1.3"\n')
+
+ assert get_version_from_init_py(init_py) == "0.1.3"
+
+ def test_reads_version_single_quotes(self, tmp_path):
+ init_py = tmp_path / "__init__.py"
+ init_py.write_text("__version__ = '1.2.0'\n")
+
+ assert get_version_from_init_py(init_py) == "1.2.0"
+
+ def test_missing_file(self, tmp_path):
+ assert get_version_from_init_py(tmp_path / "__init__.py") is None
+
+ def test_no_version_in_file(self, tmp_path):
+ init_py = tmp_path / "__init__.py"
+ init_py.write_text("# just a comment\n")
+
+ assert get_version_from_init_py(init_py) is None
+
+ def test_version_with_extra_content(self, tmp_path):
+ init_py = tmp_path / "__init__.py"
+ init_py.write_text('"""Module docstring."""\n\n__version__ =
"1.0.0"\n\nsome_var = 42\n')
+
+ assert get_version_from_init_py(init_py) == "1.0.0"
+
+
+class TestGetPackageVersion:
+ def test_apache_airflow(self, tmp_path):
+ init_file = tmp_path / "airflow-core" / "src" / "airflow" /
"__init__.py"
+ init_file.parent.mkdir(parents=True)
+ init_file.write_text('__version__ = "3.2.0"\n')
+
+ assert get_package_version("apache-airflow", tmp_path) == "3.2.0"
+
+ def test_apache_airflow_ctl(self, tmp_path):
+ init_file = tmp_path / "airflow-ctl" / "src" / "airflowctl" /
"__init__.py"
+ init_file.parent.mkdir(parents=True)
+ init_file.write_text('__version__ = "0.1.3"\n')
+
+ assert get_package_version("apache-airflow-ctl", tmp_path) == "0.1.3"
+
+ def test_task_sdk(self, tmp_path):
+ init_file = tmp_path / "task-sdk" / "src" / "airflow" / "sdk" /
"__init__.py"
+ init_file.parent.mkdir(parents=True)
+ init_file.write_text('__version__ = "1.2.0"\n')
+
+ assert get_package_version("task-sdk", tmp_path) == "1.2.0"
+
+ def test_helm_chart(self, tmp_path):
+ chart_yaml = tmp_path / "chart" / "Chart.yaml"
+ chart_yaml.parent.mkdir(parents=True)
+ chart_yaml.write_text("version: 1.16.0\n")
+
+ assert get_package_version("helm-chart", tmp_path) == "1.16.0"
+
+ def test_provider_3x_location(self, tmp_path):
+ provider_yaml = tmp_path / "providers" / "google" / "provider.yaml"
+ provider_yaml.parent.mkdir(parents=True)
+ provider_yaml.write_text(yaml.dump({"versions": ["10.5.0"]}))
+
+ assert get_package_version("apache-airflow-providers-google",
tmp_path) == "10.5.0"
+
+ def test_provider_2x_fallback(self, tmp_path):
+ provider_yaml = tmp_path / "airflow" / "providers" / "amazon" /
"provider.yaml"
+ provider_yaml.parent.mkdir(parents=True)
+ provider_yaml.write_text(yaml.dump({"versions": ["9.1.0"]}))
+
+ assert get_package_version("apache-airflow-providers-amazon",
tmp_path) == "9.1.0"
+
+ def test_provider_with_hyphen_in_name(self, tmp_path):
+ provider_yaml = tmp_path / "providers" / "apache" / "hive" /
"provider.yaml"
+ provider_yaml.parent.mkdir(parents=True)
+ provider_yaml.write_text(yaml.dump({"versions": ["8.2.0"]}))
+
+ assert get_package_version("apache-airflow-providers-apache-hive",
tmp_path) == "8.2.0"
+
+ def test_unknown_package(self, tmp_path):
+ assert get_package_version("unknown-package", tmp_path) is None
+
+ def test_missing_ctl_init_py(self, tmp_path):
+ assert get_package_version("apache-airflow-ctl", tmp_path) is None
+
+ def test_missing_task_sdk_init_py(self, tmp_path):
+ assert get_package_version("task-sdk", tmp_path) is None
+
+
+class TestMain:
+ def _create_fake_airflow_root(self, tmp_path):
+ """Create a minimal fake airflow root with version files."""
+ airflow_root = tmp_path / "airflow_root"
+ # apache-airflow version
+ init_file = airflow_root / "airflow-core" / "src" / "airflow" /
"__init__.py"
+ init_file.parent.mkdir(parents=True)
+ init_file.write_text('__version__ = "3.2.0"\n')
+ # airflow-ctl version
+ ctl_init = airflow_root / "airflow-ctl" / "src" / "airflowctl" /
"__init__.py"
+ ctl_init.parent.mkdir(parents=True)
+ ctl_init.write_text('__version__ = "0.1.3"\n')
+ return airflow_root
+
+ def _create_docs_build_dir(self, tmp_path, packages):
+ """Create a fake docs build dir with stable subdirs for given package
names."""
+ docs_dir = tmp_path / "docs_build"
+ for pkg in packages:
+ stable_dir = docs_dir / pkg / "stable"
+ stable_dir.mkdir(parents=True)
+ (stable_dir / "index.html").write_text("<html></html>")
+ return docs_dir
+
+ def test_creates_stable_txt(self, tmp_path, monkeypatch):
+ airflow_root = self._create_fake_airflow_root(tmp_path)
+ docs_dir = self._create_docs_build_dir(tmp_path, ["apache-airflow",
"apache-airflow-ctl"])
+
+ monkeypatch.setenv("DOCS_BUILD_DIR", str(docs_dir))
+ monkeypatch.setenv("AIRFLOW_ROOT", str(airflow_root))
+
+ result = main()
+
+ assert result == 0
+ assert (docs_dir / "apache-airflow" / "stable.txt").read_text() ==
"3.2.0\n"
+ assert (docs_dir / "apache-airflow-ctl" / "stable.txt").read_text() ==
"0.1.3\n"
+
+ def test_creates_versioned_directory(self, tmp_path, monkeypatch):
+ airflow_root = self._create_fake_airflow_root(tmp_path)
+ docs_dir = self._create_docs_build_dir(tmp_path, ["apache-airflow"])
+
+ monkeypatch.setenv("DOCS_BUILD_DIR", str(docs_dir))
+ monkeypatch.setenv("AIRFLOW_ROOT", str(airflow_root))
+
+ main()
+
+ version_dir = docs_dir / "apache-airflow" / "3.2.0"
+ assert version_dir.is_dir()
+ assert (version_dir / "index.html").exists()
+
+ def test_skips_non_versioned_packages(self, tmp_path, monkeypatch):
+ airflow_root = self._create_fake_airflow_root(tmp_path)
+ docs_dir = self._create_docs_build_dir(tmp_path,
["apache-airflow-providers"])
+
+ monkeypatch.setenv("DOCS_BUILD_DIR", str(docs_dir))
+ monkeypatch.setenv("AIRFLOW_ROOT", str(airflow_root))
+
+ result = main()
+
+ assert result == 0
+ assert not (docs_dir / "apache-airflow-providers" /
"stable.txt").exists()
+
+ def test_skips_packages_without_stable_dir(self, tmp_path, monkeypatch):
+ airflow_root = self._create_fake_airflow_root(tmp_path)
+ docs_dir = tmp_path / "docs_build"
+ (docs_dir / "some-package").mkdir(parents=True)
+
+ monkeypatch.setenv("DOCS_BUILD_DIR", str(docs_dir))
+ monkeypatch.setenv("AIRFLOW_ROOT", str(airflow_root))
+
+ result = main()
+
+ assert result == 0
+ assert not (docs_dir / "some-package" / "stable.txt").exists()
+
+ def test_returns_1_when_no_docs_dir(self, tmp_path, monkeypatch):
+ monkeypatch.setenv("DOCS_BUILD_DIR", str(tmp_path / "nonexistent"))
+ monkeypatch.setenv("AIRFLOW_ROOT", str(tmp_path))
+
+ assert main() == 1