diff --git a/Makefile b/Makefile index 7c1113b..043088e 100755 --- a/Makefile +++ b/Makefile @@ -948,7 +948,7 @@ publish-all: .PHONY: validate-version-consistency validate-version-consistency: - @$(PYTHON) -c 'import pathlib, sys, tomllib; pyproject=tomllib.loads(pathlib.Path("pyproject.toml").read_text())["project"]; expected=pyproject["version"]; src=pathlib.Path("src/ecli/__init__.py").read_text(); sys.path.insert(0, "src"); import ecli; actual=ecli.__version__; ok=(actual == expected or actual == "0.0.0+local") and "version(\"ecli-editor\")" in src; print(f"pyproject={expected} ecli.__version__={actual}"); sys.exit(0 if ok else 1)' + @$(PYTHON) -c 'import pathlib, sys, tomllib; root=pathlib.Path.cwd().resolve(); sys.path.insert(0, str(root / "src")); import ecli; pyproject=tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8"))["project"]; expected=pyproject["version"]; actual=ecli.__version__; imported=pathlib.Path(ecli.__file__).resolve(); source_root=root / "src" / "ecli"; print(f"pyproject={expected} ecli.__version__={actual}"); ok=(actual == expected and imported.is_relative_to(source_root)); sys.exit(0 if ok else 1)' .PHONY: validate-pypi-contract validate-pypi-contract: diff --git a/src/ecli/__init__.py b/src/ecli/__init__.py index bf3d5f6..e28ddc3 100755 --- a/src/ecli/__init__.py +++ b/src/ecli/__init__.py @@ -13,12 +13,46 @@ """ECLI — terminal-based text editor.""" +import tomllib from importlib.metadata import PackageNotFoundError, version +from pathlib import Path -try: - __version__ = version("ecli-editor") -except PackageNotFoundError: - __version__ = "0.0.0+local" +_DISTRIBUTION_NAME = "ecli-editor" + + +def _read_pyproject_version(pyproject_path: Path) -> str | None: + if not pyproject_path.is_file(): + return None + try: + with pyproject_path.open("rb") as pyproject_file: + raw_version = tomllib.load(pyproject_file)["project"]["version"] + except (KeyError, OSError, TypeError, tomllib.TOMLDecodeError): + return None + if not isinstance(raw_version, str) or not raw_version.strip(): + return None + return raw_version + + +def _source_tree_version(package_file: Path | None = None) -> str | None: + source_file = Path(__file__) if package_file is None else package_file + pyproject_path = source_file.resolve().parents[2] / "pyproject.toml" + return _read_pyproject_version(pyproject_path) + + +def _installed_package_version( + distribution_name: str = _DISTRIBUTION_NAME, +) -> str | None: + try: + return version(distribution_name) + except PackageNotFoundError: + return None + + +def _resolve_version() -> str: + return _source_tree_version() or _installed_package_version() or "0.0.0+local" + + +__version__ = _resolve_version() __all__ = ["__version__"] diff --git a/tests/test_version_resolution.py b/tests/test_version_resolution.py new file mode 100644 index 0000000..8d2bac2 --- /dev/null +++ b/tests/test_version_resolution.py @@ -0,0 +1,134 @@ +# SPDX-License-Identifier: Apache-2.0 +# +# Project: Ecli +# File: tests/test_version_resolution.py +# Website: https://www.ecli.io +# Repository: https://github.com/SSobol77/ecli +# PyPI: https://pypi.org/project/ecli-editor/0.0.1/ +# +# Copyright (c) 2026 Siergej Sobolewski +# +# Licensed under the Apache License, Version 2.0. +# See the LICENSE file in the project root for full license text. + +"""Tests for source-tree and installed-package version resolution.""" + +from __future__ import annotations + +import shutil +from importlib.metadata import PackageNotFoundError +from pathlib import Path +from typing import Iterator + +import pytest + +import ecli + + +@pytest.fixture +def version_workspace(request: pytest.FixtureRequest) -> Iterator[Path]: + logs_root = Path.cwd() / "logs" / "test-version-resolution" + test_root = logs_root / request.node.name.replace("/", "_").replace(":", "_") + shutil.rmtree(test_root, ignore_errors=True) + test_root.mkdir(parents=True) + try: + yield test_root + finally: + shutil.rmtree(test_root, ignore_errors=True) + + +def package_file_under(root: Path) -> Path: + package_dir = root / "src" / "ecli" + package_dir.mkdir(parents=True) + package_file = package_dir / "__init__.py" + package_file.write_text("# test package marker\n", encoding="utf-8") + return package_file + + +def test_source_tree_version_resolves_from_pyproject(version_workspace: Path) -> None: + package_file = package_file_under(version_workspace) + (version_workspace / "pyproject.toml").write_text( + '[project]\nversion = "9.8.7"\n', + encoding="utf-8", + ) + + assert ecli._source_tree_version(package_file) == "9.8.7" + + +def test_source_tree_version_returns_none_when_pyproject_is_missing( + version_workspace: Path, +) -> None: + package_file = package_file_under(version_workspace) + + assert ecli._source_tree_version(package_file) is None + + +def test_source_tree_version_returns_none_for_malformed_pyproject( + version_workspace: Path, +) -> None: + package_file = package_file_under(version_workspace) + (version_workspace / "pyproject.toml").write_text( + "[project\nversion = broken\n", + encoding="utf-8", + ) + + assert ecli._source_tree_version(package_file) is None + + +def test_source_tree_version_returns_none_when_project_version_is_missing( + version_workspace: Path, +) -> None: + package_file = package_file_under(version_workspace) + (version_workspace / "pyproject.toml").write_text( + '[project]\nname = "ecli-editor"\n', + encoding="utf-8", + ) + + assert ecli._source_tree_version(package_file) is None + + +def test_source_tree_version_returns_none_for_non_string_version( + version_workspace: Path, +) -> None: + package_file = package_file_under(version_workspace) + (version_workspace / "pyproject.toml").write_text( + "[project]\nversion = 123\n", + encoding="utf-8", + ) + + assert ecli._source_tree_version(package_file) is None + + +def test_resolve_version_falls_back_to_installed_package_metadata( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(ecli, "_source_tree_version", lambda: None) + monkeypatch.setattr(ecli, "_installed_package_version", lambda: "1.2.3") + + assert ecli._resolve_version() == "1.2.3" + + +def test_installed_package_version_returns_none_when_metadata_is_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def missing_distribution(_: str) -> str: + raise PackageNotFoundError("ecli-editor") + + monkeypatch.setattr(ecli, "version", missing_distribution) + + assert ecli._installed_package_version() is None + + +def test_resolve_version_falls_back_to_local_when_metadata_is_unavailable( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(ecli, "_source_tree_version", lambda: None) + monkeypatch.setattr(ecli, "_installed_package_version", lambda: None) + + assert ecli._resolve_version() == "0.0.0+local" + + +def test_version_workspace_stays_under_logs(version_workspace: Path) -> None: + logs_root = (Path.cwd() / "logs").resolve(strict=False) + + assert version_workspace.resolve(strict=False).is_relative_to(logs_root)