blob: 4b5936de2767747e819f68b72a8eb21549370541 [file]
#!/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())