Files
EOS/tests/test_version.py
T

547 lines
20 KiB
Python
Raw Normal View History

# 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
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"
def test_get_version_uses_home_assistant_build_version():
"""The installed package and EOSdash use the add-on's advertised version."""
expected = "0.3.0.dev2608221553448643"
env = {**os.environ, "EOS_BUILD_VERSION": expected}
result = subprocess.run(
[sys.executable, str(GET_VERSION_SCRIPT)],
capture_output=True,
text=True,
check=True,
env=env,
)
assert result.stdout.strip() == expected
def test_get_version_ignores_unversioned_docker_build():
"""The default Docker build argument must not become the package version."""
env = {**os.environ, "EOS_BUILD_VERSION": "dev"}
result = subprocess.run(
[sys.executable, str(GET_VERSION_SCRIPT)],
capture_output=True,
text=True,
check=True,
env=env,
)
assert result.stdout.strip() == _version_calculate()
# --- 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()
@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')
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", "--initial-branch=main"], 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