From 88904874e7ab58884911ec2a64faeacc9f35453c Mon Sep 17 00:00:00 2001 From: Bobby Noelte Date: Thu, 12 Nov 2026 14:34:50 +0000 Subject: [PATCH] fix(version): keep VERSION_DATE in _version_date.py up to date Additional fixes Use a portable shebang instead of the workspace .venv in update_version.py Update the stale VERSION_DATE of 2026-02-24 to 2026-11-12 Assisted-by: Claude Code (claude-opus-5-5) Co-Authored-By: Claude Opus 5.5 --- docs/develop/release.md | 3 + scripts/update_version.py | 104 +++++++++++++------ src/akkudoktoreos/core/_version_date.py | 2 +- src/akkudoktoreos/core/version.py | 87 ++++++++++++++-- tests/test_version.py | 131 ++++++++++++++++++++++++ 5 files changed, 285 insertions(+), 42 deletions(-) diff --git a/docs/develop/release.md b/docs/develop/release.md index 29090e6b..d462f400 100644 --- a/docs/develop/release.md +++ b/docs/develop/release.md @@ -57,6 +57,9 @@ and the generated documentation: make prepare-version ``` +If the version string changes, `make prepare-version` also sets `VERSION_DATE` +in src/akkudoktoreos/core/_version_date.py. Do not edit this file by hand. + Check the changes by: ```bash diff --git a/scripts/update_version.py b/scripts/update_version.py index 06842ad7..4067f26d 100644 --- a/scripts/update_version.py +++ b/scripts/update_version.py @@ -1,19 +1,30 @@ -#!.venv/bin/python +#!/usr/bin/env python3 """General version replacement script. Usage: - python scripts/update_version.py [file2 ...] + python scripts/update_version.py [ [file2 ...]] + +Without files, the version is replaced in DEFAULT_VERSION_FILES. + +If the version string changed in one of the project's version files +(DEFAULT_VERSION_FILES), a new version was created and +``src/akkudoktoreos/core/_version_date.py`` is regenerated. Its +VERSION_DATE is the date ``_version_date_hash()`` computes: the date of the +last commit touching package files, or the current UTC time if package files +have uncommitted changes (a commit is expected soon). """ -#!/usr/bin/env python3 +from __future__ import annotations + import re import sys -from datetime import timezone from pathlib import Path -from typing import List +from typing import TYPE_CHECKING, List + +if TYPE_CHECKING: + from datetime import datetime # type of the dates of akkudoktoreos.core.version PROJECT_ROOT = Path(__file__).parent.parent -PACKAGE_DIR = PROJECT_ROOT / "src" / "akkudoktoreos" # Add the src directory to sys.path so import akkudoktoreos works in all cases SRC_DIR = PROJECT_ROOT / "src" @@ -103,25 +114,65 @@ def update_version_in_file(file_path: Path, new_version: str) -> bool: return file_would_be_updated -def update_version_date_file() -> str: - """Write current version date to _version_date.py, only if changed.""" +def write_version_date(version_date: datetime, date_file: Path) -> bool: + """Write VERSION_DATE to a version date file if the version date changes. + + The file is left untouched when it already holds a date in the + VERSION_DATE_FORMAT that gives the same dev version date digits (the same + hour) as ``version_date``. + + Args: + version_date: Version date to write; naive datetimes are taken as UTC. + date_file: Path of the version date file. + + Returns: + True if the file was written. + """ + from akkudoktoreos.core.version import ( + format_version_date_file, + parse_version_date_file, + version_date_digits, + ) + + if date_file.exists(): + stored_content = date_file.read_text(encoding="utf-8") + stored_date = parse_version_date_file(stored_content) + if ( + stored_date is not None + and stored_content == format_version_date_file(stored_date) + and version_date_digits(stored_date) == version_date_digits(version_date) + ): + return False + + with date_file.open("w", encoding="utf-8", newline="\n") as file: + file.write(format_version_date_file(version_date)) + return True + + +def update_version_date_file() -> None: + """Write the current version date to _version_date.py if it changes.""" from akkudoktoreos.core.version import VERSION_DATE_FILE, _version_date_hash + version_date, _ = _version_date_hash() - version_date_utc = version_date.astimezone(timezone.utc) - version_date_str = version_date_utc.isoformat() - new_content = f'VERSION_DATE = "{version_date_str}"\n' - - if VERSION_DATE_FILE.exists() and VERSION_DATE_FILE.read_text(encoding="utf-8") == new_content: + if write_version_date(version_date, VERSION_DATE_FILE): + print(f"Updated {VERSION_DATE_FILE}: {VERSION_DATE_FILE.read_text(encoding='utf-8').strip()}") + else: print(f"No change to {VERSION_DATE_FILE}") - return str(VERSION_DATE_FILE) - - with VERSION_DATE_FILE.open("w", encoding="utf-8", newline="\n") as file: - file.write(new_content) - print(f"Updated {VERSION_DATE_FILE} with UTC date {version_date_str}") - return str(VERSION_DATE_FILE) -def main(version: str, files: List[str]): +def main(version: str, files: List[str]) -> None: + """Replace the version string in the given files. + + If the version string changed in one of the project's version files + (DEFAULT_VERSION_FILES), the version date file is regenerated as well. + + Args: + version: New version string. + files: Files to update. + + Raises: + ValueError: If no version or no files are given. + """ if not version: raise ValueError("No version provided") if not files: @@ -138,16 +189,9 @@ def main(version: str, files: List[str]): if updated_files: print(f"Updated files: {', '.join(updated_files)}") - - # Only update VERSION_DATE_FILE if a real package file was touched - # Exclude VERSION_DATE_FILE itself to avoid a self-referencing loop - from akkudoktoreos.core.version import VERSION_DATE_FILE - package_files_updated = any( - str(PACKAGE_DIR) in f and Path(f).resolve() != VERSION_DATE_FILE.resolve() - for f in updated_files - ) - if package_files_updated: - updated_files.append(update_version_date_file()) + version_files = {path.resolve() for path in DEFAULT_VERSION_FILES} + if any(Path(f).resolve() in version_files for f in updated_files): + update_version_date_file() else: print("No files updated.") diff --git a/src/akkudoktoreos/core/_version_date.py b/src/akkudoktoreos/core/_version_date.py index 1fb370c3..3d56258e 100644 --- a/src/akkudoktoreos/core/_version_date.py +++ b/src/akkudoktoreos/core/_version_date.py @@ -1 +1 @@ -VERSION_DATE = "2026-02-24T16:58:00Z" +VERSION_DATE = "2026-11-12T12:35:37Z" diff --git a/src/akkudoktoreos/core/version.py b/src/akkudoktoreos/core/version.py index c0610272..78acce74 100644 --- a/src/akkudoktoreos/core/version.py +++ b/src/akkudoktoreos/core/version.py @@ -34,6 +34,15 @@ VERSION_DEV_HASH_PRECISION = 8 # File to hold the date of the latest commit. VERSION_DATE_FILE = Path(__file__).parent / "_version_date.py" +# Format of VERSION_DATE in VERSION_DATE_FILE (UTC) +VERSION_DATE_FORMAT = "%Y-%m-%dT%H:%M:%SZ" + +# Format of the date digits in dev versions; dev versions resolve the date to the hour +VERSION_DATE_DEV_FORMAT = "%y%m%d%H" + +# Assignment of VERSION_DATE in VERSION_DATE_FILE +VERSION_DATE_PATTERN = re.compile(r'^VERSION_DATE\s*=\s*"(?P[^"]+)"', re.MULTILINE) + # Hashing configuration DIR_PACKAGE_ROOT = Path(__file__).resolve().parent.parent ALLOWED_SUFFIXES: set[str] = {".py", ".md", ".json"} @@ -197,6 +206,67 @@ def hash_tree( return digest, files +# ------------------------ +# Version date file format +# ------------------------ + + +def version_date_utc(version_date: datetime) -> datetime: + """Return a version date in UTC. + + Args: + version_date: Version date; a naive datetime is taken as UTC, not converted. + + Returns: + The version date in UTC. + """ + if version_date.tzinfo is None: + version_date = version_date.replace(tzinfo=timezone.utc) + return version_date.astimezone(timezone.utc) + + +def version_date_digits(version_date: datetime) -> str: + """Return the date digits a version date gives in a dev version. + + Args: + version_date: Version date; a naive datetime is taken as UTC. + + Returns: + The UTC date in VERSION_DATE_DEV_FORMAT, e.g. ``"26111214"``. + """ + return version_date_utc(version_date).strftime(VERSION_DATE_DEV_FORMAT) + + +def format_version_date_file(version_date: datetime) -> str: + """Return the content of VERSION_DATE_FILE for a version date. + + Args: + version_date: Version date; a naive datetime is taken as UTC. + + Returns: + The file content: one line, e.g. ``VERSION_DATE = "2026-11-12T14:28:49Z"``. + """ + return f'VERSION_DATE = "{version_date_utc(version_date).strftime(VERSION_DATE_FORMAT)}"\n' + + +def parse_version_date_file(content: str) -> Optional[datetime]: + """Return the version date stored in the content of VERSION_DATE_FILE. + + Args: + content: Content of a version date file. + + Returns: + The stored date in UTC, or None if the content holds no valid date. + """ + match = VERSION_DATE_PATTERN.search(content) + if match is None: + return None + try: + return version_date_utc(datetime.fromisoformat(match.group("date"))) + except ValueError: + return None + + def newest_commit_or_dirty_datetime(files: list[Path]) -> datetime: """Return the newest relevant datetime for the given files in UTC. @@ -249,16 +319,11 @@ def newest_commit_or_dirty_datetime(files: list[Path]) -> datetime: # Fallback to VERSION_DATE_FILE if VERSION_DATE_FILE.exists(): try: - ns: dict[str, str] = {} - exec(VERSION_DATE_FILE.read_text(), {}, ns) # noqa: S102 - date_str = ns.get("VERSION_DATE") - if date_str: - dt = datetime.fromisoformat(date_str) - if dt.tzinfo is None: - dt = dt.replace(tzinfo=timezone.utc) # treat naive as UTC, don't convert - return dt.astimezone(timezone.utc) - except Exception: # noqa: S110 - pass + stored_date = parse_version_date_file(VERSION_DATE_FILE.read_text(encoding="utf-8")) + except OSError: + stored_date = None + if stored_date is not None: + return stored_date raise RuntimeError("This should not happen - No version date info available") @@ -312,7 +377,7 @@ def _version_calculate() -> str: hash_digits = str(hash_value % (10**VERSION_DEV_HASH_PRECISION)).zfill( VERSION_DEV_HASH_PRECISION ) - date_digits = version_date.strftime("%y%m%d%H") if version_date else "00000000" + date_digits = version_date_digits(version_date) if version_date else "00000000" return f"{VERSION_BASE}{date_digits}{hash_digits}" else: # Release version - use base as-is diff --git a/tests/test_version.py b/tests/test_version.py index 0a6b3717..b5302766 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -1,8 +1,11 @@ # tests/test_version.py +import importlib.util import os import subprocess import sys +from datetime import datetime from pathlib import Path +from types import ModuleType from typing import Optional, Union import pytest @@ -347,6 +350,134 @@ def test_update_version_multiple_formats(tmp_path): assert f'"version": "{new_version}"' in json_file.read_text() +@pytest.fixture +def update_version() -> ModuleType: + """Load scripts/update_version.py as a module.""" + spec = importlib.util.spec_from_file_location("update_version", UPDATE_SCRIPT) + if spec is None or spec.loader is None: + raise ImportError(f"Cannot load {UPDATE_SCRIPT}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class TestWriteVersionDate: + """Tests for writing VERSION_DATE with update_version.py.""" + + def test_writes_utc_z_format(self, update_version: ModuleType, tmp_path: Path) -> None: + """A new file gets the date in UTC with a Z suffix, without microseconds.""" + date_file = tmp_path / "_version_date.py" + version_date = to_datetime("2026-11-12T15:30:45.123456+01:00") + + assert update_version.write_version_date(version_date, date_file) is True + assert date_file.read_text(encoding="utf-8") == 'VERSION_DATE = "2026-11-12T14:30:45Z"\n' + + def test_same_hour_is_not_written(self, update_version: ModuleType, tmp_path: Path) -> None: + """A date in the same hour gives the same dev version, so the file stays as it is.""" + date_file = write_file(tmp_path / "_version_date.py", 'VERSION_DATE = "2026-11-12T14:00:00Z"\n') + + written = update_version.write_version_date(to_datetime("2026-11-12T14:59:59Z"), date_file) + + assert written is False + assert date_file.read_text(encoding="utf-8") == 'VERSION_DATE = "2026-11-12T14:00:00Z"\n' + + def test_same_hour_in_other_format_is_written( + self, update_version: ModuleType, tmp_path: Path + ) -> None: + """A stored date in the same hour but not in the Z format is rewritten in the Z format.""" + date_file = write_file(tmp_path / "_version_date.py", 'VERSION_DATE = "2026-11-12T14:00:00+00:00"\n') + + written = update_version.write_version_date(to_datetime("2026-11-12T14:59:59Z"), date_file) + + assert written is True + assert date_file.read_text(encoding="utf-8") == 'VERSION_DATE = "2026-11-12T14:59:59Z"\n' + + def test_other_hour_is_written(self, update_version: ModuleType, tmp_path: Path) -> None: + """A date in another hour replaces the stored date.""" + date_file = write_file(tmp_path / "_version_date.py", 'VERSION_DATE = "2026-02-24T16:58:00Z"\n') + + written = update_version.write_version_date(to_datetime("2026-11-12T15:00:00Z"), date_file) + + assert written is True + assert date_file.read_text(encoding="utf-8") == 'VERSION_DATE = "2026-11-12T15:00:00Z"\n' + + def test_naive_date_is_taken_as_utc(self, update_version: ModuleType, tmp_path: Path) -> None: + """A date without time zone is written as UTC, not converted from local time.""" + date_file = tmp_path / "_version_date.py" + + # Stdlib datetime on purpose: to_datetime always adds a time zone. + written = update_version.write_version_date(datetime(2026, 11, 12, 15, 0, 0), date_file) + + assert written is True + assert date_file.read_text(encoding="utf-8") == 'VERSION_DATE = "2026-11-12T15:00:00Z"\n' + + def test_invalid_stored_date_is_replaced( + self, update_version: ModuleType, tmp_path: Path + ) -> None: + """A file without a valid date is rewritten.""" + date_file = write_file(tmp_path / "_version_date.py", 'VERSION_DATE = "not a date"\n') + + written = update_version.write_version_date(to_datetime("2026-11-12T15:00:00Z"), date_file) + + assert written is True + assert date_file.read_text(encoding="utf-8") == 'VERSION_DATE = "2026-11-12T15:00:00Z"\n' + + def test_written_date_is_read_by_version_fallback( + self, update_version: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The written file is understood by the fallback in newest_commit_or_dirty_datetime.""" + import akkudoktoreos.core.version as version_module + + date_file = tmp_path / "_version_date.py" + update_version.write_version_date(to_datetime("2026-11-12T15:30:00Z"), date_file) + + monkeypatch.setattr(version_module, "IS_GITHUB_ACTIONS", True) + monkeypatch.setattr(version_module, "VERSION_DATE_FILE", date_file) + + result = version_module.newest_commit_or_dirty_datetime([]) + + assert result == to_datetime("2026-11-12T15:30:00Z") + + +class TestUpdateVersionMain: + """Tests for when update_version.py main() regenerates the version date file.""" + + @pytest.mark.parametrize( + "is_version_file, via_symlink, old_version, expected_calls", + [ + (True, False, "0.1.0", 1), + (True, False, "0.2.0", 0), + (False, False, "0.1.0", 0), + (True, True, "0.1.0", 1), + ], + ids=["new-version", "same-version", "other-file", "symlinked-version-file"], + ) + def test_date_updated_only_for_new_version_in_version_file( + self, + update_version: ModuleType, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + is_version_file: bool, + via_symlink: bool, + old_version: str, + expected_calls: int, + ) -> None: + """main() regenerates the version date only if a project version file gets a new version.""" + config_file = write_file(tmp_path / "config.yaml", f'version: "{old_version}"\n') + version_file = tmp_path / "linked-config.yaml" if via_symlink else config_file + if via_symlink: + version_file.symlink_to(config_file) + calls: list[bool] = [] + monkeypatch.setattr( + update_version, "DEFAULT_VERSION_FILES", [version_file] if is_version_file else [] + ) + monkeypatch.setattr(update_version, "update_version_date_file", lambda: calls.append(True)) + + update_version.main("0.2.0", [str(config_file)]) + + assert len(calls) == expected_calls + + # --- 3️⃣ Test bump_dev_version.py --- def test_bump_dev_version_appends_dev(tmp_path): version_file = write_file(tmp_path / "version.py", 'VERSION_BASE = "0.2.0"\n')