diff --git a/src/stonks_cli/__init__.py b/src/stonks_cli/__init__.py index 7a200fa..518add1 100644 --- a/src/stonks_cli/__init__.py +++ b/src/stonks_cli/__init__.py @@ -1,6 +1,82 @@ +"""stonks-cli package metadata. + +``__version__`` is the static version recorded in ``pyproject.toml`` for +released wheels. When the package is imported from a source checkout +(the ``.git`` directory sits next to the ``src/`` tree), a PEP 440 +local-version suffix is appended so that ``stonks --version`` makes it +obvious the running build isn't a tagged release: + +* exact tag, clean tree -> ``0.6.3`` +* exact tag, dirty tree -> ``0.6.3+dirty`` +* past tag, clean tree -> ``0.6.3+dev..g`` +* past tag, dirty tree -> ``0.6.3+dev..g.dirty`` +* no reachable tag, clean / dirty -> ``0.6.3+dev.[.dirty]`` + +PyPI / wheel installs never carry the suffix because no ``.git`` +directory is co-located with the installed package. +""" + +import subprocess from importlib.metadata import PackageNotFoundError, version +from pathlib import Path + + +def _git_dev_suffix(base: str) -> str: + """Return a PEP 440 local-version suffix for source-checkout installs. + + Returns an empty string for wheel/PyPI installs (no ``.git`` next to the + package), when ``git`` isn't on PATH, when the probe times out, or when + HEAD is exactly on the release tag with a clean tree. + """ + repo_root = Path(__file__).resolve().parents[2] + if not (repo_root / ".git").exists(): + return "" + try: + result = subprocess.run( + [ + "git", + "-C", + str(repo_root), + "describe", + "--tags", + "--always", + "--dirty=.dirty", + ], + capture_output=True, + text=True, + timeout=2, + check=False, + ) + except (OSError, subprocess.SubprocessError): + return "" + if result.returncode != 0: + return "" + desc = result.stdout.strip() + if not desc: + return "" + + tag_prefix = f"v{base}" + # Exactly on the release tag with a clean tree -- no suffix needed. + if desc == tag_prefix: + return "" + # On the release tag with uncommitted changes. + if desc == f"{tag_prefix}.dirty": + return "+dirty" + # Past the release tag: "v0.6.3-2-gabc1234[.dirty]" -> "+dev.2.gabc1234[.dirty]" + if desc.startswith(f"{tag_prefix}-"): + rest = desc[len(tag_prefix) + 1 :] + return f"+dev.{rest.replace('-', '.')}" + # No reachable matching tag (shallow clone, different tag, etc.) -- + # fall back to whatever git describe returned, normalized for PEP 440. + return f"+dev.{desc.replace('-', '.')}" + + +def _resolve_version() -> str: + try: + base = version("stonks-cli") + except PackageNotFoundError: + return "0.0.0.dev" + return base + _git_dev_suffix(base) + -try: - __version__ = version("stonks-cli") -except PackageNotFoundError: - __version__ = "0.0.0.dev" +__version__ = _resolve_version() diff --git a/src/stonks_cli/doctor.py b/src/stonks_cli/doctor.py index d30d8e7..c3c9951 100644 --- a/src/stonks_cli/doctor.py +++ b/src/stonks_cli/doctor.py @@ -240,7 +240,11 @@ def check_python_version() -> bool: def _version_tuple(v: str) -> tuple[int, ...]: - return tuple(int(x) for x in v.split(".") if x.isdigit()) + # Strip any PEP 440 local-version segment (e.g. "+dev.abc1234.dirty") + # so a dev build of 0.6.3 still compares equal to PyPI's 0.6.3 rather + # than collapsing to (0, 6). + base = v.split("+", 1)[0] + return tuple(int(x) for x in base.split(".") if x.isdigit()) def check_version() -> bool: diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 0000000..6cf5511 --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,119 @@ +"""Tests for ``stonks_cli.__init__`` version-suffix logic.""" + +import subprocess +from unittest.mock import MagicMock, patch + +import pytest + +from stonks_cli import _git_dev_suffix +from stonks_cli.doctor import _version_tuple + + +def _set_repo_root(mock_path: MagicMock, root) -> None: + """Wire ``mock_path`` so ``Path(__file__).resolve().parents[2]`` returns *root*.""" + mock_path.return_value.resolve.return_value.parents.__getitem__.return_value = root + + +def _fake_git_describe(stdout: str = "", returncode: int = 0) -> MagicMock: + """Build a mock ``subprocess.run`` return for a successful ``git describe``.""" + result = MagicMock() + result.stdout = stdout + result.returncode = returncode + return result + + +class TestGitDevSuffix: + @patch("stonks_cli.Path") + def test_no_git_dir_returns_empty(self, mock_path, tmp_path): + _set_repo_root(mock_path, tmp_path) + # No ``.git`` dir under tmp_path -- the git probe must be skipped + # entirely so PyPI/wheel installs never pay for subprocess.run. + assert _git_dev_suffix("0.6.3") == "" + + @patch("stonks_cli.subprocess.run") + @patch("stonks_cli.Path") + def test_on_exact_tag_clean_returns_empty(self, mock_path, mock_run, tmp_path): + (tmp_path / ".git").mkdir() + _set_repo_root(mock_path, tmp_path) + mock_run.return_value = _fake_git_describe("v0.6.3") + assert _git_dev_suffix("0.6.3") == "" + + @patch("stonks_cli.subprocess.run") + @patch("stonks_cli.Path") + def test_on_exact_tag_dirty_returns_plus_dirty(self, mock_path, mock_run, tmp_path): + (tmp_path / ".git").mkdir() + _set_repo_root(mock_path, tmp_path) + mock_run.return_value = _fake_git_describe("v0.6.3.dirty") + assert _git_dev_suffix("0.6.3") == "+dirty" + + @patch("stonks_cli.subprocess.run") + @patch("stonks_cli.Path") + def test_past_tag_clean_returns_dev_suffix(self, mock_path, mock_run, tmp_path): + (tmp_path / ".git").mkdir() + _set_repo_root(mock_path, tmp_path) + mock_run.return_value = _fake_git_describe("v0.6.3-2-gabc1234") + assert _git_dev_suffix("0.6.3") == "+dev.2.gabc1234" + + @patch("stonks_cli.subprocess.run") + @patch("stonks_cli.Path") + def test_past_tag_dirty_returns_dev_dirty_suffix( + self, mock_path, mock_run, tmp_path + ): + (tmp_path / ".git").mkdir() + _set_repo_root(mock_path, tmp_path) + mock_run.return_value = _fake_git_describe("v0.6.3-2-gabc1234.dirty") + assert _git_dev_suffix("0.6.3") == "+dev.2.gabc1234.dirty" + + @patch("stonks_cli.subprocess.run") + @patch("stonks_cli.Path") + def test_no_reachable_tag_returns_sha_only(self, mock_path, mock_run, tmp_path): + # Shallow clone or pre-first-tag history: git describe --always + # falls back to just the short SHA. + (tmp_path / ".git").mkdir() + _set_repo_root(mock_path, tmp_path) + mock_run.return_value = _fake_git_describe("abc1234.dirty") + assert _git_dev_suffix("0.6.3") == "+dev.abc1234.dirty" + + @patch("stonks_cli.subprocess.run", side_effect=FileNotFoundError("no git")) + @patch("stonks_cli.Path") + def test_missing_git_binary_returns_empty(self, mock_path, _run, tmp_path): + (tmp_path / ".git").mkdir() + _set_repo_root(mock_path, tmp_path) + assert _git_dev_suffix("0.6.3") == "" + + @patch( + "stonks_cli.subprocess.run", + side_effect=subprocess.TimeoutExpired(cmd="git", timeout=2), + ) + @patch("stonks_cli.Path") + def test_subprocess_timeout_returns_empty(self, mock_path, _run, tmp_path): + (tmp_path / ".git").mkdir() + _set_repo_root(mock_path, tmp_path) + assert _git_dev_suffix("0.6.3") == "" + + @patch("stonks_cli.subprocess.run") + @patch("stonks_cli.Path") + def test_nonzero_returncode_returns_empty(self, mock_path, mock_run, tmp_path): + (tmp_path / ".git").mkdir() + _set_repo_root(mock_path, tmp_path) + mock_run.return_value = _fake_git_describe("", returncode=128) + assert _git_dev_suffix("0.6.3") == "" + + +class TestVersionTupleStripsLocalSegment: + @pytest.mark.parametrize( + "raw, expected", + [ + ("0.6.3", (0, 6, 3)), + ("0.6.3+dirty", (0, 6, 3)), + ("0.6.3+dev.abc1234.dirty", (0, 6, 3)), + ("0.6.3+dev.2.gabc1234", (0, 6, 3)), + ("1.0.0", (1, 0, 0)), + ], + ) + def test_strips_local_segment(self, raw, expected): + # Without the strip, "0.6.3+dev.abc1234.dirty" would split into + # ["0","6","3+dev","abc1234","dirty"] and collapse to (0, 6), + # causing doctor to falsely report the dev build as out of date + # against PyPI's 0.6.3. + assert _version_tuple(raw) == expected