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 <noreply@anthropic.com>
This commit is contained in:
Bobby Noelte
2026-11-12 22:01:05 +01:00
co-authored by Claude Opus 5.5
parent 14e439c265
commit 88904874e7
5 changed files with 285 additions and 42 deletions
+3
View File
@@ -57,6 +57,9 @@ and the generated documentation:
make prepare-version 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: Check the changes by:
```bash ```bash
+74 -30
View File
@@ -1,19 +1,30 @@
#!.venv/bin/python #!/usr/bin/env python3
"""General version replacement script. """General version replacement script.
Usage: Usage:
python scripts/update_version.py <version> <file1> [file2 ...] python scripts/update_version.py <version> [<file1> [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 re
import sys import sys
from datetime import timezone
from pathlib import Path 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 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 # Add the src directory to sys.path so import akkudoktoreos works in all cases
SRC_DIR = PROJECT_ROOT / "src" 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 return file_would_be_updated
def update_version_date_file() -> str: def write_version_date(version_date: datetime, date_file: Path) -> bool:
"""Write current version date to _version_date.py, only if changed.""" """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 from akkudoktoreos.core.version import VERSION_DATE_FILE, _version_date_hash
version_date, _ = _version_date_hash() version_date, _ = _version_date_hash()
version_date_utc = version_date.astimezone(timezone.utc) if write_version_date(version_date, VERSION_DATE_FILE):
version_date_str = version_date_utc.isoformat() print(f"Updated {VERSION_DATE_FILE}: {VERSION_DATE_FILE.read_text(encoding='utf-8').strip()}")
new_content = f'VERSION_DATE = "{version_date_str}"\n' else:
if VERSION_DATE_FILE.exists() and VERSION_DATE_FILE.read_text(encoding="utf-8") == new_content:
print(f"No change to {VERSION_DATE_FILE}") 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: if not version:
raise ValueError("No version provided") raise ValueError("No version provided")
if not files: if not files:
@@ -138,16 +189,9 @@ def main(version: str, files: List[str]):
if updated_files: if updated_files:
print(f"Updated files: {', '.join(updated_files)}") print(f"Updated files: {', '.join(updated_files)}")
version_files = {path.resolve() for path in DEFAULT_VERSION_FILES}
# Only update VERSION_DATE_FILE if a real package file was touched if any(Path(f).resolve() in version_files for f in updated_files):
# Exclude VERSION_DATE_FILE itself to avoid a self-referencing loop update_version_date_file()
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())
else: else:
print("No files updated.") print("No files updated.")
+1 -1
View File
@@ -1 +1 @@
VERSION_DATE = "2026-02-24T16:58:00Z" VERSION_DATE = "2026-11-12T12:35:37Z"
+76 -11
View File
@@ -34,6 +34,15 @@ VERSION_DEV_HASH_PRECISION = 8
# File to hold the date of the latest commit. # File to hold the date of the latest commit.
VERSION_DATE_FILE = Path(__file__).parent / "_version_date.py" 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<date>[^"]+)"', re.MULTILINE)
# Hashing configuration # Hashing configuration
DIR_PACKAGE_ROOT = Path(__file__).resolve().parent.parent DIR_PACKAGE_ROOT = Path(__file__).resolve().parent.parent
ALLOWED_SUFFIXES: set[str] = {".py", ".md", ".json"} ALLOWED_SUFFIXES: set[str] = {".py", ".md", ".json"}
@@ -197,6 +206,67 @@ def hash_tree(
return digest, files 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: def newest_commit_or_dirty_datetime(files: list[Path]) -> datetime:
"""Return the newest relevant datetime for the given files in UTC. """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 # Fallback to VERSION_DATE_FILE
if VERSION_DATE_FILE.exists(): if VERSION_DATE_FILE.exists():
try: try:
ns: dict[str, str] = {} stored_date = parse_version_date_file(VERSION_DATE_FILE.read_text(encoding="utf-8"))
exec(VERSION_DATE_FILE.read_text(), {}, ns) # noqa: S102 except OSError:
date_str = ns.get("VERSION_DATE") stored_date = None
if date_str: if stored_date is not None:
dt = datetime.fromisoformat(date_str) return stored_date
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
raise RuntimeError("This should not happen - No version date info available") 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( hash_digits = str(hash_value % (10**VERSION_DEV_HASH_PRECISION)).zfill(
VERSION_DEV_HASH_PRECISION 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}" return f"{VERSION_BASE}{date_digits}{hash_digits}"
else: else:
# Release version - use base as-is # Release version - use base as-is
+131
View File
@@ -1,8 +1,11 @@
# tests/test_version.py # tests/test_version.py
import importlib.util
import os import os
import subprocess import subprocess
import sys import sys
from datetime import datetime
from pathlib import Path from pathlib import Path
from types import ModuleType
from typing import Optional, Union from typing import Optional, Union
import pytest import pytest
@@ -347,6 +350,134 @@ def test_update_version_multiple_formats(tmp_path):
assert f'"version": "{new_version}"' in json_file.read_text() 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 --- # --- 3️⃣ Test bump_dev_version.py ---
def test_bump_dev_version_appends_dev(tmp_path): def test_bump_dev_version_appends_dev(tmp_path):
version_file = write_file(tmp_path / "version.py", 'VERSION_BASE = "0.2.0"\n') version_file = write_file(tmp_path / "version.py", 'VERSION_BASE = "0.2.0"\n')