Files
EOS/tests/test_version.py
T
Bobby NoelteandGitHub 378377b1ce
Bump Version / Bump Version Workflow (push) Has been cancelled
docker-build / platform-excludes (push) Has been cancelled
docker-build / build (push) Has been cancelled
docker-build / merge (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Run Pytest on Pull Request / test (push) Has been cancelled
Close stale pull requests/issues / Find Stale issues and PRs (push) Has been cancelled
fix: test break docs and on data compaction (2) (#902)
Ensure that the snapping sequence generated in the test fixture
is within the boundaries expected by the test.

Ensure we read the _version_date.py info as UTC datetime and do
no localtime conversion.

Prevent and guard test_version.py to modify the version date file.

Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
2026-02-24 18:56:11 +01:00

388 lines
13 KiB
Python

# tests/test_version.py
import subprocess
import sys
from pathlib import Path
from typing import Optional, Union
import pytest
import yaml
from akkudoktoreos.core.version import (
ALLOWED_SUFFIXES,
DIR_PACKAGE_ROOT,
EXCLUDED_DIR_PATTERNS,
EXCLUDED_FILES,
VERSION_DATE_FILE,
HashConfig,
_version_calculate,
_version_date_hash,
collect_files,
hash_files,
)
from akkudoktoreos.utils.datetimeutil import to_datetime
DIR_PROJECT_ROOT = Path(__file__).parent.parent
GET_VERSION_SCRIPT = DIR_PROJECT_ROOT / "scripts" / "get_version.py"
BUMP_DEV_SCRIPT = DIR_PROJECT_ROOT / "scripts" / "bump_dev_version.py"
UPDATE_SCRIPT = DIR_PROJECT_ROOT / "scripts" / "update_version.py"
@pytest.fixture(autouse=True)
def guard_version_date_file():
"""Ensure no test modifies the VERSION_DATE_FILE (_version_date.py)."""
# Record state before test
if VERSION_DATE_FILE.exists():
before_mtime = VERSION_DATE_FILE.stat().st_mtime
before_content = VERSION_DATE_FILE.read_text(encoding="utf-8")
else:
before_mtime = None
before_content = None
yield
# Check state after test
if VERSION_DATE_FILE.exists():
after_mtime = VERSION_DATE_FILE.stat().st_mtime
after_content = VERSION_DATE_FILE.read_text(encoding="utf-8")
if before_content is None:
pytest.fail(
f"Test created VERSION_DATE_FILE which should not exist: {VERSION_DATE_FILE}"
)
elif after_mtime != before_mtime or after_content != before_content:
# Restore the original content immediately to avoid polluting subsequent tests
VERSION_DATE_FILE.write_text(before_content, encoding="utf-8")
pytest.fail(
f"Test modified VERSION_DATE_FILE: {VERSION_DATE_FILE}\n"
f"Original content:\n{before_content}\n"
f"Modified content:\n{after_content}"
)
else:
if before_content is not None:
pytest.fail(
f"Test deleted VERSION_DATE_FILE: {VERSION_DATE_FILE}"
)
# --- Git helpers ---
def get_git_tracked_files(repo_path: Path) -> Optional[set[Path]]:
"""Get set of all files tracked by git in the repository.
Returns None if not a git repository or git command fails.
"""
try:
result = subprocess.run(
["git", "ls-files"],
cwd=repo_path,
capture_output=True,
text=True,
check=True
)
# Convert relative paths to absolute paths
tracked_files = {
(repo_path / line.strip()).resolve()
for line in result.stdout.splitlines()
if line.strip()
}
return tracked_files
except (subprocess.CalledProcessError, FileNotFoundError):
return None
def is_git_repository(path: Path) -> bool:
"""Check if path is inside a git repository."""
try:
subprocess.run(
["git", "rev-parse", "--git-dir"],
cwd=path,
capture_output=True,
check=True
)
return True
except (subprocess.CalledProcessError, FileNotFoundError):
return False
def get_git_root(path: Path) -> Optional[Path]:
"""Get the root directory of the git repository containing path."""
try:
result = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
cwd=path,
capture_output=True,
text=True,
check=True
)
return Path(result.stdout.strip())
except (subprocess.CalledProcessError, FileNotFoundError):
return None
def check_files_in_git(
files: list[Path],
base_path: Optional[Path] = None
) -> tuple[list[Path], list[Path]]:
"""Check which files are tracked by git.
Args:
files: List of files to check
base_path: Base path to check for git repository (uses first file's parent if None)
Returns:
Tuple of (tracked_files, untracked_files)
Example:
>>> files = collect_files(config)
>>> tracked, untracked = check_files_in_git(files)
>>> if untracked:
... print(f"Warning: {len(untracked)} files not in git")
"""
if not files:
return [], []
check_path = base_path or files[0].parent
assert is_git_repository(check_path)
git_root = get_git_root(check_path)
if not git_root:
return [], files
git_tracked = get_git_tracked_files(git_root)
if git_tracked is None:
return [], files
tracked = [f for f in files if f in git_tracked]
untracked = [f for f in files if f not in git_tracked]
return tracked, untracked
# --- Helper to create test files ---
def write_file(path: Path, content: str):
path.write_text(content, encoding="utf-8")
return path
# -- Test version calculation ---
def test_version_date_hash() -> None:
"""Test which files are used for version hash calculation."""
watched_paths = [DIR_PACKAGE_ROOT]
# Collect files
config = HashConfig(
paths=watched_paths,
allowed_suffixes=ALLOWED_SUFFIXES,
excluded_dir_patterns=EXCLUDED_DIR_PATTERNS,
excluded_files=EXCLUDED_FILES
)
files = collect_files(config)
hash_digest = hash_files(files)
# Check git
tracked, untracked = check_files_in_git(files, DIR_PACKAGE_ROOT)
tracked_files: list[Path] = tracked
untracked_files: list[Path] = untracked
if untracked_files:
error_msg = f"\n{'='*60}"
error_msg += f"Version Hash Inspection"
error_msg += f"{'='*60}\n"
error_msg += f"Hash: {hash_digest}"
error_msg += f"Based on {len(files)} files:\n"
error_msg += f"OK: {len(tracked_files)} files tracked by git:\n"
for i, file_path in enumerate(files, 1):
try:
rel_path = file_path.relative_to(DIR_PACKAGE_ROOT)
status = ""
if file_path in untracked_files:
continue
elif file_path in tracked_files:
status = " [tracked]"
error_msg += f" {i:3d}. {rel_path}{status}\n"
except ValueError:
error_msg += f" {i:3d}. {file_path}\n"
error_msg += f"Warning: {len(untracked_files)} files not tracked by git:\n"
for i, file_path in enumerate(files, 1):
try:
rel_path = file_path.relative_to(DIR_PACKAGE_ROOT)
status = ""
if file_path in untracked_files:
status = " [NOT IN GIT]"
elif file_path in tracked_files:
continue
error_msg += f" {i:3d}. {rel_path}{status}\n"
except ValueError:
error_msg += f" {i:3d}. {file_path}\n"
error_msg += f"\n{'='*60}\n"
pytest.fail(error_msg)
# --- Test version helpers ---
def test_version_non_dev(monkeypatch):
"""If VERSION_BASE does not end with 'dev', no hash digits are appended."""
monkeypatch.setattr("akkudoktoreos.core.version.VERSION_BASE", "0.2.0")
result = _version_calculate()
assert result == "0.2.0"
def test_version_dev_precision_8(monkeypatch):
"""Test that a dev version appends exactly 8 digits derived from the hash."""
fake_hash = "abcdef1234567890"
fake_date = "2025-02-22T09:28:22Z"
fake_date_hash = (to_datetime(fake_date), fake_hash) # deterministic fake digest
monkeypatch.setattr("akkudoktoreos.core.version._version_date_hash", lambda: fake_date_hash)
monkeypatch.setattr("akkudoktoreos.core.version.VERSION_BASE", "0.2.0.dev")
monkeypatch.setattr("akkudoktoreos.core.version.VERSION_DEV_HASH_PRECISION", 8)
result = _version_calculate()
# Compute expected suffix using the same logic as _version_calculate
hash_value = int(fake_hash, 16)
expected_hash_digits = str(hash_value % (10 ** 8)).zfill(8)
expected_date_digits = to_datetime(fake_date, as_string="YYMMDDHH")
expected = f"0.2.0.dev{expected_date_digits}{expected_hash_digits}"
assert result == expected
assert len(expected_hash_digits) == 8
assert result.startswith("0.2.0.dev")
assert result == expected
def test_version_dev_precision_8_different_hash(monkeypatch):
"""A different hash must produce a different 8-digit suffix."""
fake_hash = "1234abcd9999ffff"
fake_date = "2025-02-22T09:28:22Z"
fake_date_hash = (to_datetime(fake_date), fake_hash) # deterministic fake digest
monkeypatch.setattr("akkudoktoreos.core.version._version_date_hash", lambda: fake_date_hash)
monkeypatch.setattr("akkudoktoreos.core.version.VERSION_BASE", "0.2.0.dev")
monkeypatch.setattr("akkudoktoreos.core.version.VERSION_DEV_HASH_PRECISION", 8)
result = _version_calculate()
# Compute expected suffix using the same logic as _version_calculate
hash_value = int(fake_hash, 16)
expected_hash_digits = str(hash_value % (10 ** 8)).zfill(8)
expected_date_digits = to_datetime(fake_date, as_string="YYMMDDHH")
expected = f"0.2.0.dev{expected_date_digits}{expected_hash_digits}"
assert result == expected
assert len(expected_hash_digits) == 8
assert result.startswith("0.2.0.dev")
assert result == expected
# --- 1️⃣ Test get_version.py ---
def test_get_version_prints_non_empty():
result = subprocess.run(
[sys.executable, str(GET_VERSION_SCRIPT)],
capture_output=True,
text=True,
check=True
)
version = result.stdout.strip()
assert version, "get_version.py should print a non-empty version"
assert len(version.split(".")) >= 3, "Version should have at least MAJOR.MINOR.PATCH"
# --- 2️⃣ Test update_version.py on multiple file types ---
def test_update_version_multiple_formats(tmp_path):
py_file = write_file(tmp_path / "version.py", '__version__ = "0.1.0"\n')
yaml_file = write_file(tmp_path / "config.yaml", 'version: "0.1.0"\n')
json_file = write_file(tmp_path / "package.json", '{"version": "0.1.0"}\n')
new_version = "0.2.0"
files = [py_file, yaml_file, json_file]
subprocess.run(
[sys.executable, str(UPDATE_SCRIPT), new_version] + [str(f.resolve()) for f in files],
check=True
)
# Verify updates
assert f'__version__ = "{new_version}"' in py_file.read_text()
assert yaml.safe_load(yaml_file.read_text())["version"] == new_version
assert f'"version": "{new_version}"' in json_file.read_text()
# --- 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')
result = subprocess.run(
[sys.executable, str(BUMP_DEV_SCRIPT), str(version_file.resolve())],
capture_output=True,
text=True,
check=True
)
new_version = result.stdout.strip()
assert new_version == "0.2.0.dev"
content = version_file.read_text()
assert f'VERSION_BASE = "{new_version}"' in content
# --- 4️⃣ Full workflow simulation with git ---
def test_workflow_git(tmp_path):
# Create git repo
subprocess.run(["git", "init"], cwd=tmp_path, check=True)
subprocess.run(["git", "config", "user.name", "test"], cwd=tmp_path, check=True)
subprocess.run(["git", "config", "user.email", "test@test.com"], cwd=tmp_path, check=True)
# Create files
version_file = write_file(tmp_path / "version.py", 'VERSION_BASE = "0.1.0"\n')
config_file = write_file(tmp_path / "config.yaml", 'version: "0.1.0"\n')
subprocess.run(["git", "add", "."], cwd=tmp_path, check=True)
subprocess.run(["git", "commit", "-m", "initial commit"], cwd=tmp_path, check=True)
# --- Step 1: Calculate version (mock) ---
new_version = "0.2.0"
# --- Step 2: Update files ---
subprocess.run(
[sys.executable, str(UPDATE_SCRIPT), new_version, str(config_file.resolve()), str(version_file.resolve())],
cwd=tmp_path,
check=True
)
# --- Step 3: Commit updated files if needed ---
subprocess.run(["git", "add", str(config_file.resolve()), str(version_file.resolve())], cwd=tmp_path, check=True)
diff_result = subprocess.run(["git", "diff", "--cached", "--quiet"], cwd=tmp_path)
assert diff_result.returncode == 1, "There should be staged changes to commit"
subprocess.run(["git", "commit", "-m", f"chore: bump version to {new_version}"], cwd=tmp_path, check=True)
# --- Step 4: Tag version ---
tag_name = f"v{new_version}"
subprocess.run(["git", "tag", "-a", tag_name, "-m", f"Release {new_version}"], cwd=tmp_path, check=True)
tags = subprocess.run(["git", "tag"], cwd=tmp_path, capture_output=True, text=True, check=True).stdout
assert tag_name in tags
# --- Step 5: Bump dev version ---
result = subprocess.run(
[sys.executable, str(BUMP_DEV_SCRIPT), str(version_file.resolve())],
cwd=tmp_path,
capture_output=True,
text=True,
check=True
)
dev_version = result.stdout.strip()
assert dev_version.endswith(".dev")
assert dev_version.count(".dev") == 1
content = version_file.read_text()
assert f'VERSION_BASE = "{dev_version}"' in content