| #!/usr/bin/env python3 | |
| # | |
| # 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. | |
| """Check versions or start the next development cycle after a release.""" | |
| import argparse | |
| import json | |
| import re | |
| import sys | |
| from pathlib import Path | |
| from typing import Dict, List | |
| VERSION_PATTERN = re.compile(r"^\d+\.\d+\.\d+$") | |
| DOCS_URL = "https://paimon.apache.org/docs/cpp/" | |
| MAIN_ENTRY_VERSION = "main" | |
| def series(version: str) -> str: | |
| """The major.minor documentation series a version belongs to.""" | |
| return ".".join(version.split(".")[:2]) | |
| def stable_entry(version: str) -> Dict[str, object]: | |
| """Dropdown entry for the latest release published at the site root.""" | |
| return { | |
| "name": f"{version} (stable)", | |
| "version": "", | |
| "url": DOCS_URL, | |
| "preferred": True, | |
| } | |
| def series_entry(version: str) -> Dict[str, object]: | |
| """Dropdown entry for an archived release series under <series>/.""" | |
| return { | |
| "name": version, | |
| "version": series(version), | |
| "url": f"{DOCS_URL}{series(version)}/", | |
| } | |
| class VersionError(RuntimeError): | |
| """Version metadata is missing or inconsistent.""" | |
| def require_version(value: str) -> None: | |
| if VERSION_PATTERN.fullmatch(value) is None: | |
| raise VersionError(f"invalid version {value!r}; expected MAJOR.MINOR.PATCH") | |
| def replace_once(text: str, pattern: str, replacement: str, description: str) -> str: | |
| result, count = re.subn(pattern, replacement, text, flags=re.MULTILINE) | |
| if count != 1: | |
| raise VersionError(f"expected one {description}, found {count}") | |
| return result | |
| def load_versions(path: Path) -> List[Dict[str, object]]: | |
| try: | |
| value = json.loads(path.read_text(encoding="utf-8")) | |
| except (OSError, json.JSONDecodeError) as error: | |
| raise VersionError(f"cannot read {path}: {error}") from error | |
| if not isinstance(value, list) or not all(isinstance(item, dict) for item in value): | |
| raise VersionError(f"{path} must contain a JSON array of objects") | |
| return value | |
| def check_version_list(versions: List[Dict[str, object]]) -> None: | |
| """Validate the dev/stable/archived layout of versions.json.""" | |
| if len(versions) < 2: | |
| raise VersionError( | |
| "versions.json must contain a dev entry followed by a stable entry" | |
| ) | |
| dev = versions[0] | |
| if ( | |
| dev.get("version") != MAIN_ENTRY_VERSION | |
| # Older Git refs retain the previous display label. | |
| or dev.get("name") not in ("main", "main (development)") | |
| or dev.get("url") != f"{DOCS_URL}main/" | |
| ): | |
| raise VersionError( | |
| "the first versions.json entry must be the main-branch docs: " | |
| f"name 'main', version {MAIN_ENTRY_VERSION!r}, " | |
| f"url {DOCS_URL}main/" | |
| ) | |
| stable = versions[1] | |
| name = stable.get("name") | |
| match = ( | |
| re.fullmatch(r"(\d+\.\d+\.\d+) \(stable\)", name) | |
| if isinstance(name, str) | |
| else None | |
| ) | |
| if ( | |
| match is None | |
| or stable.get("version") != "" | |
| or stable.get("url") != DOCS_URL | |
| or stable.get("preferred") is not True | |
| ): | |
| raise VersionError( | |
| "the second versions.json entry must be the stable docs: name " | |
| "'X.Y.Z (stable)', version '', url the site root, preferred true" | |
| ) | |
| for item in versions[2:]: | |
| item_name = item.get("name") | |
| if ( | |
| not isinstance(item_name, str) | |
| or VERSION_PATTERN.fullmatch(item_name) is None | |
| or item.get("version") != series(item_name) | |
| or item.get("url") != f"{DOCS_URL}{series(item_name)}/" | |
| ): | |
| raise VersionError( | |
| "archived versions.json entries must use name X.Y.Z, " | |
| f"version X.Y and url {DOCS_URL}X.Y/, found {item!r}" | |
| ) | |
| def checked_files(root: Path, expected: str) -> Dict[Path, str]: | |
| cmake_path = root / "CMakeLists.txt" | |
| docs_path = root / "docs/source/conf.py" | |
| versions_path = root / "docs/source/_static/versions.json" | |
| cmake = cmake_path.read_text(encoding="utf-8") | |
| docs = docs_path.read_text(encoding="utf-8") | |
| versions = load_versions(versions_path) | |
| cmake_matches = re.findall( | |
| r"^[ \t]*VERSION[ \t]+(\d+\.\d+\.\d+)[ \t]*$", cmake, re.MULTILINE | |
| ) | |
| docs_matches = re.findall( | |
| r'^version = "(\d+\.\d+\.\d+)"$', docs, re.MULTILINE | |
| ) | |
| if cmake_matches != [expected]: | |
| raise VersionError(f"CMake version is {cmake_matches}, expected [{expected!r}]") | |
| check_version_list(versions) | |
| stable_version = versions[1]["name"].split(" ")[0] | |
| if docs_matches not in ([expected], [stable_version]): | |
| raise VersionError( | |
| f"documentation version is {docs_matches}, expected project version " | |
| f"{expected!r} or stable documentation version {stable_version!r}" | |
| ) | |
| return { | |
| cmake_path: cmake, | |
| docs_path: docs, | |
| versions_path: json.dumps(versions, indent=4) + "\n", | |
| } | |
| def updated_files(root: Path, current: str, new: str) -> Dict[Path, str]: | |
| """Promote the released version and start the next development cycle.""" | |
| files = checked_files(root, current) | |
| cmake_path = root / "CMakeLists.txt" | |
| docs_path = root / "docs/source/conf.py" | |
| files[cmake_path] = replace_once( | |
| files[cmake_path], | |
| rf"^([ \t]*VERSION[ \t]+){re.escape(current)}([ \t]*)$", | |
| rf"\g<1>{new}\g<2>", | |
| f"CMake VERSION {current}", | |
| ) | |
| files[docs_path] = replace_once( | |
| files[docs_path], | |
| r'^version = "\d+\.\d+\.\d+"$', | |
| f'version = "{new}"', | |
| f"documentation version {current}", | |
| ) | |
| versions_path = root / "docs/source/_static/versions.json" | |
| versions = load_versions(versions_path) | |
| promote_stable(versions, current) | |
| versions[0]["name"] = "main" | |
| files[versions_path] = json.dumps(versions, indent=4) + "\n" | |
| return files | |
| def promote_stable(versions: List[Dict[str, object]], version: str) -> None: | |
| """Update the stable label, archiving the previous stable series if needed.""" | |
| previous_version = versions[1]["name"].split(" ")[0] | |
| if series(previous_version) != series(version): | |
| versions.insert(2, series_entry(previous_version)) | |
| versions[1] = stable_entry(version) | |
| def promoted_files(root: Path, version: str) -> Dict[Path, str]: | |
| """Promote the current project version without changing project metadata.""" | |
| files = checked_files(root, version) | |
| docs = files[root / "docs/source/conf.py"] | |
| if re.findall(r'^version = "(\d+\.\d+\.\d+)"$', docs, re.MULTILINE) != [version]: | |
| raise VersionError("documentation version must match the version being promoted") | |
| versions_path = root / "docs/source/_static/versions.json" | |
| versions = load_versions(versions_path) | |
| promote_stable(versions, version) | |
| return {versions_path: json.dumps(versions, indent=4) + "\n"} | |
| def main() -> int: | |
| parser = argparse.ArgumentParser(description=__doc__) | |
| parser.add_argument("current_version", nargs="?", help="just-released project version") | |
| parser.add_argument("new_version", nargs="?", help="next development version") | |
| mode = parser.add_mutually_exclusive_group() | |
| mode.add_argument("--check", metavar="VERSION", help="validate version metadata") | |
| mode.add_argument( | |
| "--promote-stable", | |
| metavar="VERSION", | |
| help="promote the current project version to stable docs", | |
| ) | |
| parser.add_argument("--dry-run", action="store_true") | |
| parser.add_argument( | |
| "--root", type=Path, default=Path(__file__).resolve().parents[2] | |
| ) | |
| args = parser.parse_args() | |
| try: | |
| if args.check or args.promote_stable: | |
| if args.current_version or args.new_version: | |
| raise VersionError( | |
| "--check and --promote-stable cannot be combined with version arguments" | |
| ) | |
| if args.check: | |
| require_version(args.check) | |
| checked_files(args.root.resolve(), args.check) | |
| print(f"Release version metadata is consistent: {args.check}") | |
| return 0 | |
| if args.promote_stable: | |
| require_version(args.promote_stable) | |
| files = promoted_files(args.root.resolve(), args.promote_stable) | |
| else: | |
| if not args.current_version or not args.new_version: | |
| raise VersionError("CURRENT_VERSION and NEW_VERSION are required") | |
| require_version(args.current_version) | |
| require_version(args.new_version) | |
| if args.current_version == args.new_version: | |
| raise VersionError("current and new versions must differ") | |
| files = updated_files( | |
| args.root.resolve(), args.current_version, args.new_version | |
| ) | |
| for path, content in files.items(): | |
| if args.dry_run: | |
| print(f"Would update {path}") | |
| else: | |
| path.write_text(content, encoding="utf-8") | |
| print(f"Updated {path}") | |
| return 0 | |
| except (OSError, VersionError) as error: | |
| print(f"Version update failed: {error}", file=sys.stderr) | |
| return 1 | |
| if __name__ == "__main__": | |
| sys.exit(main()) |