Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 80 additions & 4 deletions src/stonks_cli/__init__.py
Original file line number Diff line number Diff line change
@@ -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.<N>.g<sha>``
* past tag, dirty tree -> ``0.6.3+dev.<N>.g<sha>.dirty``
* no reachable tag, clean / dirty -> ``0.6.3+dev.<sha>[.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('-', '.')}"
Comment on lines +58 to +71

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current implementation assumes that git tags always have a v prefix (e.g., v0.6.3). While common, some repositories use tags without the prefix (e.g., 0.6.3). Making this check more robust by supporting both formats ensures the version string remains clean even if the tagging convention changes or differs from the assumption.

Suggested change
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('-', '.')}"
# Handle both 'v1.2.3' and '1.2.3' tag formats
for prefix in (f"v{base}", base):
if desc == prefix:
return ""
if desc == f"{prefix}.dirty":
return "+dirty"
if desc.startswith(f"{prefix}-"):
rest = desc[len(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()
6 changes: 5 additions & 1 deletion src/stonks_cli/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
119 changes: 119 additions & 0 deletions tests/test_version.py
Original file line number Diff line number Diff line change
@@ -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
Loading