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 4dc4ebff93f Automate keeping documented "tested with" versions in sync
with the test matrix (#69124)
4dc4ebff93f is described below
commit 4dc4ebff93f7a1d60d1c5aa482007e6befca4233
Author: Jarek Potiuk <[email protected]>
AuthorDate: Mon Jul 20 00:24:04 2026 +0200
Automate keeping documented "tested with" versions in sync with the test
matrix (#69124)
The Python, database and Kubernetes versions Airflow is tested with live in
global_constants.py, but are also listed by hand in the installation
prerequisites doc and the README. Those lists drifted: PostgreSQL 18 (and
MySQL 8.4) were added to the test matrix and the README, but the
prerequisites
doc was forgotten, so it still advertised PostgreSQL up to 17.
Add a prek hook that regenerates the tested-versions block in
prerequisites.rst
and the "Main version (dev)" column of the README from global_constants.py,
so
this drift becomes an auto-fixable check failure instead of a silent doc
bug.
Also correct the stale PostgreSQL and MySQL versions in prerequisites.rst.
Generated-by: Claude Code (Opus 4.8) following the guidelines
---
.pre-commit-config.yaml | 6 +
airflow-core/docs/installation/prerequisites.rst | 4 +
scripts/ci/prek/common_prek_utils.py | 36 +++-
scripts/ci/prek/update_tested_versions.py | 209 +++++++++++++++++++++
scripts/tests/ci/prek/test_common_prek_utils.py | 40 ++++
.../tests/ci/prek/test_update_tested_versions.py | 123 ++++++++++++
6 files changed, 417 insertions(+), 1 deletion(-)
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index c28e71d1ed7..d4a3ffd372e 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -707,6 +707,12 @@ repos:
^scripts/ci/prek/extract_permissions\.py$|
^airflow-core/docs/security/api_permissions_ref\.rst$
pass_filenames: false
+ - id: update-tested-versions
+ name: Update tested Python/DB/Kubernetes versions in docs
+ entry: ./scripts/ci/prek/update_tested_versions.py
+ language: python
+ files:
^airflow-core/docs/installation/prerequisites\.rst$|^README\.md$|^dev/breeze/src/airflow_breeze/global_constants\.py$|^scripts/ci/prek/update_tested_versions\.py$|^scripts/ci/prek/common_prek_utils\.py$
+ pass_filenames: false
- id: check-revision-heads-map
name: Check that the REVISION_HEADS_MAP is up-to-date
language: python
diff --git a/airflow-core/docs/installation/prerequisites.rst
b/airflow-core/docs/installation/prerequisites.rst
index 190dd397555..b92ae330e67 100644
--- a/airflow-core/docs/installation/prerequisites.rst
+++ b/airflow-core/docs/installation/prerequisites.rst
@@ -20,6 +20,8 @@ Prerequisites
Airflow® is tested with:
+ .. Beginning of the auto-generated tested versions
+
* Python: 3.10, 3.11, 3.12, 3.13, 3.14
* Databases:
@@ -30,6 +32,8 @@ Airflow® is tested with:
* Kubernetes: 1.30, 1.31, 1.32, 1.33, 1.34, 1.35
+ .. End of the auto-generated tested versions
+
While we recommend a minimum of 4GB of memory for Airflow, the actual
requirements heavily depend on your chosen deployment.
.. warning::
diff --git a/scripts/ci/prek/common_prek_utils.py
b/scripts/ci/prek/common_prek_utils.py
index 2f960aa3e32..5d50133906b 100644
--- a/scripts/ci/prek/common_prek_utils.py
+++ b/scripts/ci/prek/common_prek_utils.py
@@ -133,13 +133,21 @@ GLOBAL_CONSTANTS_PATH = (
def _read_global_constants_assignment(name: str) -> Any:
- """Read a top-level assignment from global_constants.py."""
+ """Read a top-level assignment from global_constants.py.
+
+ Handles both plain assignments (``NAME = ...``) and annotated assignments
+ (``NAME: type = ...``). The value must be a literal so it can be safely
+ evaluated with ``ast.literal_eval``.
+ """
tree = ast.parse(GLOBAL_CONSTANTS_PATH.read_text())
for node in tree.body:
if isinstance(node, ast.Assign):
for target in node.targets:
if isinstance(target, ast.Name) and target.id == name:
return ast.literal_eval(node.value)
+ elif isinstance(node, ast.AnnAssign):
+ if isinstance(node.target, ast.Name) and node.target.id == name
and node.value is not None:
+ return ast.literal_eval(node.value)
raise RuntimeError(f"{name} not found in global_constants.py")
@@ -152,6 +160,32 @@ def read_allowed_kubernetes_versions() -> list[str]:
return [v.lstrip("v") for v in versions]
+def read_allowed_python_major_minor_versions() -> list[str]:
+ """Parse ALLOWED_PYTHON_MAJOR_MINOR_VERSIONS from global_constants.py
(single source of truth)."""
+ return
list(_read_global_constants_assignment("ALLOWED_PYTHON_MAJOR_MINOR_VERSIONS"))
+
+
+def read_current_postgres_versions() -> list[str]:
+ """Parse CURRENT_POSTGRES_VERSIONS from global_constants.py (single source
of truth)."""
+ return list(_read_global_constants_assignment("CURRENT_POSTGRES_VERSIONS"))
+
+
+def read_current_mysql_versions() -> list[str]:
+ """The MySQL release versions Airflow currently tests with.
+
+ Mirrors how ``CURRENT_MYSQL_VERSIONS`` is built in global_constants.py: the
+ "old" releases plus the LTS releases, plus an innovation release when one
is
+ configured. Returns the numeric versions only (e.g. ``["8.0", "8.4"]``);
the
+ docs add the textual "Innovation" annotation on top of these.
+ """
+ versions: list[str] =
list(_read_global_constants_assignment("MYSQL_OLD_RELEASES"))
+ versions += list(_read_global_constants_assignment("MYSQL_LTS_RELEASES"))
+ innovation = _read_global_constants_assignment("MYSQL_INNOVATION_RELEASE")
+ if innovation:
+ versions.append(innovation)
+ return versions
+
+
def read_default_python_major_minor_version_for_images() -> str:
"""Parse DEFAULT_PYTHON_MAJOR_MINOR_VERSION_FOR_IMAGES from
global_constants.py."""
value =
_read_global_constants_assignment("DEFAULT_PYTHON_MAJOR_MINOR_VERSION_FOR_IMAGES")
diff --git a/scripts/ci/prek/update_tested_versions.py
b/scripts/ci/prek/update_tested_versions.py
new file mode 100755
index 00000000000..4b5936de276
--- /dev/null
+++ b/scripts/ci/prek/update_tested_versions.py
@@ -0,0 +1,209 @@
+#!/usr/bin/env python
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+# /// script
+# requires-python = ">=3.10,<3.11"
+# dependencies = []
+# ///
+"""Keep the documented "tested with" versions in sync with global_constants.py.
+
+The list of Python, database and Kubernetes versions Airflow is tested with
lives
+in a single source of truth
(``dev/breeze/src/airflow_breeze/global_constants.py``)
+but is also rendered for humans in two hand-maintained places:
+
+* ``airflow-core/docs/installation/prerequisites.rst`` — the "Prerequisites"
bullet list.
+* ``README.md`` — the "Main version (dev)" column of the Requirements table.
+
+These used to drift out of sync silently (e.g. PostgreSQL 18 was added to the
test
+matrix and the README but the prerequisites doc was forgotten). This hook
regenerates
+both from the constants so the drift becomes a (auto-fixable) prek failure
instead of
+a documentation bug nobody notices.
+
+Only versions that have a clean single source in global_constants.py are
generated:
+Python, PostgreSQL, MySQL (numeric releases) and Kubernetes. SQLite has no
constant
+and the MySQL "Innovation" annotation is editorial, so both are defined here.
+"""
+
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+from common_prek_utils import (
+ AIRFLOW_ROOT_PATH,
+ read_allowed_kubernetes_versions,
+ read_allowed_python_major_minor_versions,
+ read_current_mysql_versions,
+ read_current_postgres_versions,
+)
+
+PREREQUISITES_RST = AIRFLOW_ROOT_PATH / "airflow-core" / "docs" /
"installation" / "prerequisites.rst"
+README_MD = AIRFLOW_ROOT_PATH / "README.md"
+
+PREREQUISITES_START = " .. Beginning of the auto-generated tested versions\n"
+PREREQUISITES_END = " .. End of the auto-generated tested versions\n"
+
+README_DEV_HEADER = "Main version (dev)"
+
+# Editorial values without a single source of truth in global_constants.py.
+SQLITE_VERSION = "3.15.0+"
+MYSQL_INNOVATION_ANNOTATION = "Innovation"
+MYSQL_INNOVATION_RST_LINK = (
+ "`Innovation <https://dev.mysql.com/blog-archive/"
+ "introducing-mysql-innovation-and-long-term-support-lts-versions>`_"
+)
+
+
+def kubernetes_major_minor_versions() -> list[str]:
+ """Kubernetes versions reduced to ``major.minor`` (e.g. ``1.30``) for
display."""
+ return [".".join(version.split(".")[:2]) for version in
read_allowed_kubernetes_versions()]
+
+
+def comma(values: list[str]) -> str:
+ return ", ".join(values)
+
+
+def render_prerequisites_block() -> str:
+ """The bullet list rendered between the prerequisites.rst markers."""
+ python_versions = comma(read_allowed_python_major_minor_versions())
+ postgres_versions = comma(read_current_postgres_versions())
+ mysql_versions = comma([*read_current_mysql_versions(),
MYSQL_INNOVATION_RST_LINK])
+ kubernetes_versions = comma(kubernetes_major_minor_versions())
+ return (
+ "\n"
+ f"* Python: {python_versions}\n"
+ "\n"
+ "* Databases:\n"
+ "\n"
+ f" * PostgreSQL: {postgres_versions}\n"
+ f" * MySQL: {mysql_versions}\n"
+ f" * SQLite: {SQLITE_VERSION}\n"
+ "\n"
+ f"* Kubernetes: {kubernetes_versions}\n"
+ "\n"
+ )
+
+
+def dev_version_values() -> dict[str, str]:
+ """Generated value for each README row label whose dev column we keep in
sync."""
+ return {
+ "Python": comma(read_allowed_python_major_minor_versions()),
+ "Kubernetes": comma(kubernetes_major_minor_versions()),
+ "PostgreSQL": comma(read_current_postgres_versions()),
+ "MySQL": comma([*read_current_mysql_versions(),
MYSQL_INNOVATION_ANNOTATION]),
+ }
+
+
+def replace_text_between(text: str, start: str, end: str, replacement: str) ->
str:
+ if start not in text or end not in text:
+ raise RuntimeError(f"Could not find markers {start!r} / {end!r} in the
file")
+ leading = text.split(start)[0]
+ trailing = text.split(end)[1]
+ return leading + start + replacement + end + trailing
+
+
+def update_prerequisites() -> bool:
+ """Regenerate the tested-versions block in prerequisites.rst. Return True
if changed."""
+ original = PREREQUISITES_RST.read_text()
+ updated = replace_text_between(
+ original, PREREQUISITES_START, PREREQUISITES_END,
render_prerequisites_block()
+ )
+ if updated != original:
+ PREREQUISITES_RST.write_text(updated)
+ return True
+ return False
+
+
+def update_readme() -> bool:
+ """Sync the "Main version (dev)" column of the README Requirements table.
+
+ Only the dev cell of the matched rows is rewritten; the label and the
historical
+ "Stable version" columns are preserved byte-for-byte. The dev column is
widened (and
+ only the dev column) if a generated value no longer fits, keeping the
table aligned
+ without touching the stable columns.
+ """
+ lines = README_MD.read_text().splitlines(keepends=True)
+ header_index = next(
+ (i for i, line in enumerate(lines) if README_DEV_HEADER in line and
line.lstrip().startswith("|")),
+ None,
+ )
+ if header_index is None:
+ raise RuntimeError(
+ f"Could not find the Requirements table header
({README_DEV_HEADER!r}) in README.md"
+ )
+
+ # The table spans contiguous lines starting with '|', header first then
separator.
+ table_end = header_index
+ while table_end < len(lines) and lines[table_end].lstrip().startswith("|"):
+ table_end += 1
+ table_rows = list(range(header_index, table_end))
+ separator_index = header_index + 1
+
+ generated = dev_version_values()
+
+ def cells(line: str) -> list[str]:
+ # Drop the empty fragments before the first and after the last pipe.
+ return line.rstrip("\n").split("|")[1:-1]
+
+ # Determine the dev column width: max of the current width and every value
we render.
+ current_width = len(cells(lines[header_index])[1]) - 2
+ needed = [current_width]
+ for idx in table_rows:
+ if idx == separator_index:
+ continue
+ row = cells(lines[idx])
+ label = row[0].strip()
+ value = generated.get(label, row[1].strip())
+ needed.append(len(value))
+ dev_width = max(needed)
+
+ changed = False
+ for idx in table_rows:
+ row = cells(lines[idx])
+ if idx == separator_index:
+ new_dev = "-" * (dev_width + 2)
+ else:
+ label = row[0].strip()
+ value = generated.get(label, row[1].strip())
+ new_dev = f" {value.ljust(dev_width)} "
+ if new_dev == row[1]:
+ continue
+ row[1] = new_dev
+ lines[idx] = "|" + "|".join(row) + "|\n"
+ changed = True
+
+ if changed:
+ README_MD.write_text("".join(lines))
+ return changed
+
+
+def main() -> int:
+ changed_files: list[Path] = []
+ if update_prerequisites():
+ changed_files.append(PREREQUISITES_RST)
+ if update_readme():
+ changed_files.append(README_MD)
+ if changed_files:
+ for path in changed_files:
+ print(f"Updated tested versions in
{path.relative_to(AIRFLOW_ROOT_PATH)}")
+ print("Files were re-generated from global_constants.py - please
re-stage them.")
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/tests/ci/prek/test_common_prek_utils.py
b/scripts/tests/ci/prek/test_common_prek_utils.py
index f28961f9006..e23baabc542 100644
--- a/scripts/tests/ci/prek/test_common_prek_utils.py
+++ b/scripts/tests/ci/prek/test_common_prek_utils.py
@@ -33,6 +33,9 @@ from ci.prek.common_prek_utils import (
pre_process_mypy_files,
read_airflow_version,
read_allowed_kubernetes_versions,
+ read_allowed_python_major_minor_versions,
+ read_current_mysql_versions,
+ read_current_postgres_versions,
read_default_python_major_minor_version_for_images,
resolve_github_token,
retrieve_gh_token,
@@ -386,6 +389,43 @@ class TestReadDefaultPythonMajorMinorVersionForImages:
assert parts[1].isdigit()
+class TestReadAllowedPythonMajorMinorVersions:
+ def test_returns_list_of_versions(self):
+ versions = read_allowed_python_major_minor_versions()
+ assert isinstance(versions, list)
+ assert len(versions) > 0
+
+ def test_versions_look_like_major_minor(self):
+ for version in read_allowed_python_major_minor_versions():
+ parts = version.split(".")
+ assert len(parts) == 2, f"Version {version!r} should be
major.minor"
+ assert parts[0].isdigit()
+ assert parts[1].isdigit()
+
+
+class TestReadCurrentPostgresVersions:
+ def test_returns_list_of_versions(self):
+ versions = read_current_postgres_versions()
+ assert isinstance(versions, list)
+ assert len(versions) > 0
+
+ def test_versions_are_numeric_major_versions(self):
+ for version in read_current_postgres_versions():
+ assert version.isdigit(), f"Postgres version {version!r} should be
a numeric major version"
+
+
+class TestReadCurrentMysqlVersions:
+ def test_returns_list_of_versions(self):
+ versions = read_current_mysql_versions()
+ assert isinstance(versions, list)
+ assert len(versions) > 0
+
+ def test_reads_annotated_assignment(self):
+ # MYSQL_LTS_RELEASES is an annotated assignment (``NAME: list[str] =
[...]``);
+ # the reader must handle it, so its value must show up here.
+ assert "8.4" in read_current_mysql_versions()
+
+
class TestConsoleDiff:
def test_dump_added_lines(self):
diff = ConsoleDiff()
diff --git a/scripts/tests/ci/prek/test_update_tested_versions.py
b/scripts/tests/ci/prek/test_update_tested_versions.py
new file mode 100644
index 00000000000..08afc23ae72
--- /dev/null
+++ b/scripts/tests/ci/prek/test_update_tested_versions.py
@@ -0,0 +1,123 @@
+# 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 textwrap
+
+from ci.prek import update_tested_versions as hook
+from ci.prek.common_prek_utils import (
+ read_allowed_python_major_minor_versions,
+ read_current_postgres_versions,
+)
+
+
+def _dev_cell(line: str) -> str:
+ """The (stripped) "Main version (dev)" cell of a markdown table row."""
+ return line.split("|")[1:-1][1].strip()
+
+
+def _stable_cells(line: str) -> list[str]:
+ """The stable-column cells (everything after the dev column)."""
+ return [c.strip() for c in line.split("|")[1:-1][2:]]
+
+
+class TestRenderPrerequisitesBlock:
+ def test_block_reflects_constants(self):
+ block = hook.render_prerequisites_block()
+ assert f"* Python: {',
'.join(read_allowed_python_major_minor_versions())}\n" in block
+ assert f" * PostgreSQL: {',
'.join(read_current_postgres_versions())}\n" in block
+ # SQLite has no central constant - it is rendered from the hook's own
value.
+ assert f" * SQLite: {hook.SQLITE_VERSION}\n" in block
+ # MySQL always carries the editorial "Innovation" annotation.
+ assert "Innovation" in block
+
+ def test_block_is_wrapped_by_markers_content_only(self):
+ # The rendered block must not itself contain the markers (those wrap
it).
+ block = hook.render_prerequisites_block()
+ assert hook.PREREQUISITES_START not in block
+ assert hook.PREREQUISITES_END not in block
+
+
+class TestUpdateReadme:
+ TABLE = textwrap.dedent(
+ """\
+ ## Requirements
+
+ | | Main version (dev) | Stable version (3.2.0) | Stable
version (2.11.2) |
+
|------------|--------------------|------------------------|-------------------------|
+ | Python | WRONG | 3.10, 3.11 | 3.10
|
+ | Platform | AMD64/ARM64 | AMD64/ARM64 |
AMD64/ARM64 |
+ | PostgreSQL | WRONG | 14, 15 | 12, 13
|
+ | SQLite | 3.15.0+ | 3.15.0+ | 3.15.0+
|
+
+ text after the table
+ """
+ )
+
+ def test_syncs_dev_column_and_preserves_stable_columns(self, tmp_path,
monkeypatch):
+ readme = tmp_path / "README.md"
+ readme.write_text(self.TABLE)
+ monkeypatch.setattr(hook, "README_MD", readme)
+
+ assert hook.update_readme() is True
+
+ lines = readme.read_text().splitlines()
+ rows = {line.split("|")[1].strip(): line for line in lines if
line.startswith("|")}
+
+ expected_python = ", ".join(read_allowed_python_major_minor_versions())
+ expected_postgres = ", ".join(read_current_postgres_versions())
+
+ # Dev column is now synced from the constants.
+ assert _dev_cell(rows["Python"]) == expected_python
+ assert _dev_cell(rows["PostgreSQL"]) == expected_postgres
+
+ # Stable columns are untouched.
+ assert _stable_cells(rows["Python"]) == ["3.10, 3.11", "3.10"]
+ assert _stable_cells(rows["PostgreSQL"]) == ["14, 15", "12, 13"]
+
+ # A non-target row keeps its dev value verbatim.
+ assert _dev_cell(rows["Platform"]) == "AMD64/ARM64"
+
+ # Text outside the table is preserved.
+ assert "text after the table" in readme.read_text()
+
+ def test_noop_when_already_synced(self, tmp_path, monkeypatch):
+ readme = tmp_path / "README.md"
+ readme.write_text(self.TABLE)
+ monkeypatch.setattr(hook, "README_MD", readme)
+
+ # First pass syncs and reports a change; a second pass is a no-op.
+ assert hook.update_readme() is True
+ synced = readme.read_text()
+ assert hook.update_readme() is False
+ assert readme.read_text() == synced
+
+
+class TestRepoDocsInSync:
+ """Regression guard: the committed docs must already match the
constants."""
+
+ def test_prerequisites_in_sync(self):
+ assert hook.update_prerequisites() is False, (
+ "prerequisites.rst is out of sync with global_constants.py - "
+ "run 'prek run update-tested-versions --all-files'"
+ )
+
+ def test_readme_in_sync(self):
+ assert hook.update_readme() is False, (
+ "README.md tested-versions table is out of sync with
global_constants.py - "
+ "run 'prek run update-tested-versions --all-files'"
+ )