Skip to content

Commit a215a52

Browse files
committed
feat(version): append +dev suffix for non-release builds
A wheel install from PyPI ships only the static version recorded in pyproject.toml, so ``stonks --version`` from a release looks like ``0.6.3``. Source checkouts (``pip install -e .`` against a git working tree, or ``uv sync`` in a clone) have no such cue, and users running an in-progress build look identical to release users when filing bug reports. Detect source checkouts by looking for a ``.git`` directory next to the ``src/`` tree and, when present, append a PEP 440 local-version suffix derived from ``git describe --tags --always --dirty=.dirty``: * 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 -> ``0.6.3+dev.<sha>[.dirty]`` Failures (missing git, timeout, repo not present) silently drop back to the bare version so PyPI / wheel installs never pay for the probe and never carry the suffix. ``doctor._version_tuple`` is updated to strip the local-version segment before parsing so a dev build of 0.6.3 still compares equal to PyPI's 0.6.3, instead of collapsing to ``(0, 6)`` and triggering a spurious "out of date" warning. Signed-off-by: Igor Opaniuk <igor.opaniuk@gmail.com>
1 parent 5ddfd1d commit a215a52

3 files changed

Lines changed: 204 additions & 5 deletions

File tree

src/stonks_cli/__init__.py

Lines changed: 80 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,82 @@
1+
"""stonks-cli package metadata.
2+
3+
``__version__`` is the static version recorded in ``pyproject.toml`` for
4+
released wheels. When the package is imported from a source checkout
5+
(the ``.git`` directory sits next to the ``src/`` tree), a PEP 440
6+
local-version suffix is appended so that ``stonks --version`` makes it
7+
obvious the running build isn't a tagged release:
8+
9+
* exact tag, clean tree -> ``0.6.3``
10+
* exact tag, dirty tree -> ``0.6.3+dirty``
11+
* past tag, clean tree -> ``0.6.3+dev.<N>.g<sha>``
12+
* past tag, dirty tree -> ``0.6.3+dev.<N>.g<sha>.dirty``
13+
* no reachable tag, clean / dirty -> ``0.6.3+dev.<sha>[.dirty]``
14+
15+
PyPI / wheel installs never carry the suffix because no ``.git``
16+
directory is co-located with the installed package.
17+
"""
18+
19+
import subprocess
120
from importlib.metadata import PackageNotFoundError, version
21+
from pathlib import Path
22+
23+
24+
def _git_dev_suffix(base: str) -> str:
25+
"""Return a PEP 440 local-version suffix for source-checkout installs.
26+
27+
Returns an empty string for wheel/PyPI installs (no ``.git`` next to the
28+
package), when ``git`` isn't on PATH, when the probe times out, or when
29+
HEAD is exactly on the release tag with a clean tree.
30+
"""
31+
repo_root = Path(__file__).resolve().parents[2]
32+
if not (repo_root / ".git").exists():
33+
return ""
34+
try:
35+
result = subprocess.run(
36+
[
37+
"git",
38+
"-C",
39+
str(repo_root),
40+
"describe",
41+
"--tags",
42+
"--always",
43+
"--dirty=.dirty",
44+
],
45+
capture_output=True,
46+
text=True,
47+
timeout=2,
48+
check=False,
49+
)
50+
except (OSError, subprocess.SubprocessError):
51+
return ""
52+
if result.returncode != 0:
53+
return ""
54+
desc = result.stdout.strip()
55+
if not desc:
56+
return ""
57+
58+
tag_prefix = f"v{base}"
59+
# Exactly on the release tag with a clean tree -- no suffix needed.
60+
if desc == tag_prefix:
61+
return ""
62+
# On the release tag with uncommitted changes.
63+
if desc == f"{tag_prefix}.dirty":
64+
return "+dirty"
65+
# Past the release tag: "v0.6.3-2-gabc1234[.dirty]" -> "+dev.2.gabc1234[.dirty]"
66+
if desc.startswith(f"{tag_prefix}-"):
67+
rest = desc[len(tag_prefix) + 1 :]
68+
return f"+dev.{rest.replace('-', '.')}"
69+
# No reachable matching tag (shallow clone, different tag, etc.) --
70+
# fall back to whatever git describe returned, normalized for PEP 440.
71+
return f"+dev.{desc.replace('-', '.')}"
72+
73+
74+
def _resolve_version() -> str:
75+
try:
76+
base = version("stonks-cli")
77+
except PackageNotFoundError:
78+
return "0.0.0.dev"
79+
return base + _git_dev_suffix(base)
80+
281

3-
try:
4-
__version__ = version("stonks-cli")
5-
except PackageNotFoundError:
6-
__version__ = "0.0.0.dev"
82+
__version__ = _resolve_version()

src/stonks_cli/doctor.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,11 @@ def check_python_version() -> bool:
240240

241241

242242
def _version_tuple(v: str) -> tuple[int, ...]:
243-
return tuple(int(x) for x in v.split(".") if x.isdigit())
243+
# Strip any PEP 440 local-version segment (e.g. "+dev.abc1234.dirty")
244+
# so a dev build of 0.6.3 still compares equal to PyPI's 0.6.3 rather
245+
# than collapsing to (0, 6).
246+
base = v.split("+", 1)[0]
247+
return tuple(int(x) for x in base.split(".") if x.isdigit())
244248

245249

246250
def check_version() -> bool:

tests/test_version.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
"""Tests for ``stonks_cli.__init__`` version-suffix logic."""
2+
3+
import subprocess
4+
from unittest.mock import MagicMock, patch
5+
6+
import pytest
7+
8+
from stonks_cli import _git_dev_suffix
9+
from stonks_cli.doctor import _version_tuple
10+
11+
12+
def _set_repo_root(mock_path: MagicMock, root) -> None:
13+
"""Wire ``mock_path`` so ``Path(__file__).resolve().parents[2]`` returns *root*."""
14+
mock_path.return_value.resolve.return_value.parents.__getitem__.return_value = root
15+
16+
17+
def _fake_git_describe(stdout: str = "", returncode: int = 0) -> MagicMock:
18+
"""Build a mock ``subprocess.run`` return for a successful ``git describe``."""
19+
result = MagicMock()
20+
result.stdout = stdout
21+
result.returncode = returncode
22+
return result
23+
24+
25+
class TestGitDevSuffix:
26+
@patch("stonks_cli.Path")
27+
def test_no_git_dir_returns_empty(self, mock_path, tmp_path):
28+
_set_repo_root(mock_path, tmp_path)
29+
# No ``.git`` dir under tmp_path -- the git probe must be skipped
30+
# entirely so PyPI/wheel installs never pay for subprocess.run.
31+
assert _git_dev_suffix("0.6.3") == ""
32+
33+
@patch("stonks_cli.subprocess.run")
34+
@patch("stonks_cli.Path")
35+
def test_on_exact_tag_clean_returns_empty(self, mock_path, mock_run, tmp_path):
36+
(tmp_path / ".git").mkdir()
37+
_set_repo_root(mock_path, tmp_path)
38+
mock_run.return_value = _fake_git_describe("v0.6.3")
39+
assert _git_dev_suffix("0.6.3") == ""
40+
41+
@patch("stonks_cli.subprocess.run")
42+
@patch("stonks_cli.Path")
43+
def test_on_exact_tag_dirty_returns_plus_dirty(self, mock_path, mock_run, tmp_path):
44+
(tmp_path / ".git").mkdir()
45+
_set_repo_root(mock_path, tmp_path)
46+
mock_run.return_value = _fake_git_describe("v0.6.3.dirty")
47+
assert _git_dev_suffix("0.6.3") == "+dirty"
48+
49+
@patch("stonks_cli.subprocess.run")
50+
@patch("stonks_cli.Path")
51+
def test_past_tag_clean_returns_dev_suffix(self, mock_path, mock_run, tmp_path):
52+
(tmp_path / ".git").mkdir()
53+
_set_repo_root(mock_path, tmp_path)
54+
mock_run.return_value = _fake_git_describe("v0.6.3-2-gabc1234")
55+
assert _git_dev_suffix("0.6.3") == "+dev.2.gabc1234"
56+
57+
@patch("stonks_cli.subprocess.run")
58+
@patch("stonks_cli.Path")
59+
def test_past_tag_dirty_returns_dev_dirty_suffix(
60+
self, mock_path, mock_run, tmp_path
61+
):
62+
(tmp_path / ".git").mkdir()
63+
_set_repo_root(mock_path, tmp_path)
64+
mock_run.return_value = _fake_git_describe("v0.6.3-2-gabc1234.dirty")
65+
assert _git_dev_suffix("0.6.3") == "+dev.2.gabc1234.dirty"
66+
67+
@patch("stonks_cli.subprocess.run")
68+
@patch("stonks_cli.Path")
69+
def test_no_reachable_tag_returns_sha_only(self, mock_path, mock_run, tmp_path):
70+
# Shallow clone or pre-first-tag history: git describe --always
71+
# falls back to just the short SHA.
72+
(tmp_path / ".git").mkdir()
73+
_set_repo_root(mock_path, tmp_path)
74+
mock_run.return_value = _fake_git_describe("abc1234.dirty")
75+
assert _git_dev_suffix("0.6.3") == "+dev.abc1234.dirty"
76+
77+
@patch("stonks_cli.subprocess.run", side_effect=FileNotFoundError("no git"))
78+
@patch("stonks_cli.Path")
79+
def test_missing_git_binary_returns_empty(self, mock_path, _run, tmp_path):
80+
(tmp_path / ".git").mkdir()
81+
_set_repo_root(mock_path, tmp_path)
82+
assert _git_dev_suffix("0.6.3") == ""
83+
84+
@patch(
85+
"stonks_cli.subprocess.run",
86+
side_effect=subprocess.TimeoutExpired(cmd="git", timeout=2),
87+
)
88+
@patch("stonks_cli.Path")
89+
def test_subprocess_timeout_returns_empty(self, mock_path, _run, tmp_path):
90+
(tmp_path / ".git").mkdir()
91+
_set_repo_root(mock_path, tmp_path)
92+
assert _git_dev_suffix("0.6.3") == ""
93+
94+
@patch("stonks_cli.subprocess.run")
95+
@patch("stonks_cli.Path")
96+
def test_nonzero_returncode_returns_empty(self, mock_path, mock_run, tmp_path):
97+
(tmp_path / ".git").mkdir()
98+
_set_repo_root(mock_path, tmp_path)
99+
mock_run.return_value = _fake_git_describe("", returncode=128)
100+
assert _git_dev_suffix("0.6.3") == ""
101+
102+
103+
class TestVersionTupleStripsLocalSegment:
104+
@pytest.mark.parametrize(
105+
"raw, expected",
106+
[
107+
("0.6.3", (0, 6, 3)),
108+
("0.6.3+dirty", (0, 6, 3)),
109+
("0.6.3+dev.abc1234.dirty", (0, 6, 3)),
110+
("0.6.3+dev.2.gabc1234", (0, 6, 3)),
111+
("1.0.0", (1, 0, 0)),
112+
],
113+
)
114+
def test_strips_local_segment(self, raw, expected):
115+
# Without the strip, "0.6.3+dev.abc1234.dirty" would split into
116+
# ["0","6","3+dev","abc1234","dirty"] and collapse to (0, 6),
117+
# causing doctor to falsely report the dev build as out of date
118+
# against PyPI's 0.6.3.
119+
assert _version_tuple(raw) == expected

0 commit comments

Comments
 (0)