Skip to content

Commit 0f78a7e

Browse files
Add expect_parse() helper for SCM property assertions
Replace string-based version assertions with property-based ones to better distinguish between parsing (SCM extraction) and formatting (version scheme application) tests. Key changes: - Add VersionExpectations TypedDict for type-safe property matching - Add ScmVersion.matches() method to compare parsed properties - Add WorkDir.expect_parse() helper for test assertions - Add mismatches class for detailed error reporting - Refactor tests to use expect_parse() for parsing validation - Keep string assertions only for formatting/version scheme tests This improves test clarity by making explicit what is being tested: parsing correctness vs formatting behavior. Note: The linter renamed MissMatches to mismatches following Python conventions for factory-like classes used in boolean contexts. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent 1ff4f3b commit 0f78a7e

File tree

8 files changed

+457
-39
lines changed

8 files changed

+457
-39
lines changed

src/setuptools_scm/version.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@
2828
from typing_extensions import Concatenate
2929
from typing_extensions import ParamSpec
3030

31+
if sys.version_info >= (3, 11):
32+
from typing import Unpack
33+
else:
34+
from typing_extensions import Unpack
35+
3136
_P = ParamSpec("_P")
3237

3338
from typing import TypedDict
@@ -51,6 +56,52 @@ class _TagDict(TypedDict):
5156
suffix: str
5257

5358

59+
class VersionExpectations(TypedDict, total=False):
60+
"""Expected properties for ScmVersion matching."""
61+
62+
tag: str | _VersionT
63+
distance: int
64+
dirty: bool
65+
node_prefix: str # Prefix of the node/commit hash
66+
branch: str | None
67+
exact: bool
68+
preformatted: bool
69+
node_date: date | None
70+
time: datetime | None
71+
72+
73+
@dataclasses.dataclass
74+
class mismatches:
75+
"""Represents mismatches between expected and actual ScmVersion properties."""
76+
77+
expected: dict[str, Any]
78+
actual: dict[str, Any]
79+
80+
def __bool__(self) -> bool:
81+
"""mismatches is falsy to allow `if not version.matches(...)`."""
82+
return False
83+
84+
def __str__(self) -> str:
85+
"""Format mismatches for error reporting."""
86+
lines = []
87+
for key, exp_val in self.expected.items():
88+
if key == "node_prefix":
89+
# Special handling for node prefix matching
90+
actual_node = self.actual.get("node")
91+
if not actual_node or not actual_node.startswith(exp_val):
92+
lines.append(
93+
f" node: expected prefix '{exp_val}', got '{actual_node}'"
94+
)
95+
else:
96+
act_val = self.actual.get(key)
97+
if str(exp_val) != str(act_val):
98+
lines.append(f" {key}: expected {exp_val!r}, got {act_val!r}")
99+
return "\n".join(lines)
100+
101+
def __repr__(self) -> str:
102+
return f"mismatches(expected={self.expected!r}, actual={self.actual!r})"
103+
104+
54105
def _parse_version_tag(
55106
tag: str | object, config: _config.Configuration
56107
) -> _TagDict | None:
@@ -220,6 +271,58 @@ def format_next_version(
220271
guessed = guess_next(self, *k, **kw)
221272
return self.format_with(fmt, guessed=guessed)
222273

274+
def matches(self, **expectations: Unpack[VersionExpectations]) -> bool | mismatches:
275+
"""Check if this ScmVersion matches the given expectations.
276+
277+
Returns True if all specified properties match, or a mismatches
278+
object (which is falsy) containing details of what didn't match.
279+
280+
Args:
281+
**expectations: Properties to check, using VersionExpectations TypedDict
282+
"""
283+
# Map expectation keys to ScmVersion attributes
284+
attr_map: dict[str, Callable[[], Any]] = {
285+
"tag": lambda: str(self.tag),
286+
"node_prefix": lambda: self.node,
287+
"distance": lambda: self.distance,
288+
"dirty": lambda: self.dirty,
289+
"branch": lambda: self.branch,
290+
"exact": lambda: self.exact,
291+
"preformatted": lambda: self.preformatted,
292+
"node_date": lambda: self.node_date,
293+
"time": lambda: self.time,
294+
}
295+
296+
# Build actual values dict
297+
actual: dict[str, Any] = {
298+
key: attr_map[key]() for key in expectations if key in attr_map
299+
}
300+
301+
# Process expectations
302+
expected = {
303+
"tag" if k == "tag" else k: str(v) if k == "tag" else v
304+
for k, v in expectations.items()
305+
}
306+
307+
# Check for mismatches
308+
def has_mismatch() -> bool:
309+
for key, exp_val in expected.items():
310+
if key == "node_prefix":
311+
act_val = actual.get("node_prefix")
312+
if not act_val or not act_val.startswith(exp_val):
313+
return True
314+
else:
315+
if str(exp_val) != str(actual.get(key)):
316+
return True
317+
return False
318+
319+
if has_mismatch():
320+
# Rename node_prefix back to node for actual values in mismatch reporting
321+
if "node_prefix" in actual:
322+
actual["node"] = actual.pop("node_prefix")
323+
return mismatches(expected=expected, actual=actual)
324+
return True
325+
223326

224327
def _parse_tag(
225328
tag: _VersionT | str, preformatted: bool, config: _config.Configuration

testing/conftest.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
import shutil
66
import sys
77

8+
from datetime import datetime
9+
from datetime import timezone
810
from pathlib import Path
911
from types import TracebackType
1012
from typing import Any
@@ -21,10 +23,18 @@
2123

2224
from .wd_wrapper import WorkDir
2325

26+
# Test time constants: 2009-02-13T23:31:30+00:00
27+
TEST_SOURCE_DATE = datetime(2009, 2, 13, 23, 31, 30, tzinfo=timezone.utc)
28+
TEST_SOURCE_DATE_EPOCH = int(TEST_SOURCE_DATE.timestamp())
29+
TEST_SOURCE_DATE_FORMATTED = "20090213" # As used in node-and-date local scheme
30+
TEST_SOURCE_DATE_TIMESTAMP = (
31+
"20090213233130" # As used in node-and-timestamp local scheme
32+
)
33+
2434

2535
def pytest_configure(config: pytest.Config) -> None:
2636
# 2009-02-13T23:31:30+00:00
27-
os.environ["SOURCE_DATE_EPOCH"] = "1234567890"
37+
os.environ["SOURCE_DATE_EPOCH"] = str(TEST_SOURCE_DATE_EPOCH)
2838
os.environ["SETUPTOOLS_SCM_DEBUG"] = "1"
2939

3040

testing/test_expect_parse.py

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
"""Test the expect_parse and matches functionality."""
2+
3+
from __future__ import annotations
4+
5+
from datetime import date
6+
from datetime import datetime
7+
from datetime import timezone
8+
from pathlib import Path
9+
10+
import pytest
11+
12+
from setuptools_scm import Configuration
13+
from setuptools_scm.version import ScmVersion
14+
from setuptools_scm.version import meta
15+
from setuptools_scm.version import mismatches
16+
17+
from .conftest import TEST_SOURCE_DATE
18+
from .wd_wrapper import WorkDir
19+
20+
21+
def test_scm_version_matches_basic() -> None:
22+
"""Test the ScmVersion.matches method with various combinations."""
23+
c = Configuration()
24+
25+
# Create test version with all properties set
26+
version = meta(
27+
"1.2.3",
28+
distance=5,
29+
dirty=True,
30+
node="abc123def456",
31+
branch="main",
32+
config=c,
33+
)
34+
35+
# Test individual matches
36+
assert version.matches(tag="1.2.3")
37+
assert version.matches(distance=5)
38+
assert version.matches(dirty=True)
39+
assert version.matches(branch="main")
40+
assert version.matches(exact=False)
41+
42+
# Test combined matches
43+
assert version.matches(tag="1.2.3", distance=5, dirty=True, branch="main")
44+
45+
# Test node prefix matching
46+
assert version.matches(node_prefix="a")
47+
assert version.matches(node_prefix="abc")
48+
assert version.matches(node_prefix="abc123")
49+
assert version.matches(node_prefix="abc123def456")
50+
51+
# Test mismatches are falsy
52+
assert not version.matches(tag="1.2.4")
53+
assert not version.matches(node_prefix="xyz")
54+
assert not version.matches(distance=10)
55+
assert not version.matches(dirty=False)
56+
57+
58+
def test_scm_version_matches_exact() -> None:
59+
"""Test exact matching."""
60+
c = Configuration()
61+
62+
# Exact version
63+
exact_version = meta("2.0.0", distance=0, dirty=False, config=c)
64+
assert exact_version.matches(exact=True)
65+
66+
# Non-exact due to distance
67+
with_distance = meta("2.0.0", distance=1, dirty=False, config=c)
68+
assert not with_distance.matches(exact=True)
69+
70+
# Non-exact due to dirty
71+
dirty_version = meta("2.0.0", distance=0, dirty=True, config=c)
72+
assert not dirty_version.matches(exact=True)
73+
74+
75+
def test_expect_parse_calls_matches(tmp_path: Path) -> None:
76+
"""Test that expect_parse correctly parses and calls matches."""
77+
wd = WorkDir(tmp_path)
78+
79+
# Create a mock parse function that returns a predefined ScmVersion
80+
c = Configuration()
81+
mock_version = meta(
82+
"3.0.0",
83+
distance=2,
84+
dirty=False,
85+
node="fedcba987654",
86+
branch="develop",
87+
config=c,
88+
)
89+
90+
def mock_parse(root: Path, config: Configuration) -> ScmVersion | None:
91+
return mock_version
92+
93+
wd.parse = mock_parse
94+
95+
# Test successful match
96+
wd.expect_parse(tag="3.0.0", distance=2, dirty=False)
97+
wd.expect_parse(node_prefix="fed")
98+
wd.expect_parse(branch="develop")
99+
100+
# Test that mismatches raise AssertionError
101+
with pytest.raises(AssertionError, match="Version mismatch"):
102+
wd.expect_parse(tag="3.0.1")
103+
104+
with pytest.raises(AssertionError, match="Version mismatch"):
105+
wd.expect_parse(dirty=True)
106+
107+
with pytest.raises(AssertionError, match="Version mismatch"):
108+
wd.expect_parse(node_prefix="abc")
109+
110+
111+
def test_expect_parse_without_parse_function(tmp_path: Path) -> None:
112+
"""Test that expect_parse raises error when parse is not configured."""
113+
wd = WorkDir(tmp_path)
114+
115+
with pytest.raises(RuntimeError, match="No SCM configured"):
116+
wd.expect_parse(tag="1.0.0")
117+
118+
119+
def test_expect_parse_with_none_result(tmp_path: Path) -> None:
120+
"""Test that expect_parse handles None result from parse."""
121+
wd = WorkDir(tmp_path)
122+
123+
def mock_parse_none(root: Path, config: Configuration) -> ScmVersion | None:
124+
return None
125+
126+
wd.parse = mock_parse_none
127+
128+
with pytest.raises(AssertionError, match="Failed to parse version"):
129+
wd.expect_parse(tag="1.0.0")
130+
131+
132+
def test_missmatches_string_formatting() -> None:
133+
"""Test mismatches string representation for good error messages."""
134+
mismatch_obj = mismatches(
135+
expected={"tag": "1.0.0", "distance": 0, "dirty": False},
136+
actual={"tag": "2.0.0", "distance": 5, "dirty": True},
137+
)
138+
139+
# Test that mismatches is falsy
140+
assert not mismatch_obj
141+
142+
# Test string representation
143+
str_repr = str(mismatch_obj)
144+
assert "tag: expected '1.0.0', got '2.0.0'" in str_repr
145+
assert "distance: expected 0, got 5" in str_repr
146+
assert "dirty: expected False, got True" in str_repr
147+
148+
149+
def test_missmatches_node_prefix_formatting() -> None:
150+
"""Test mismatches formatting for node prefix mismatches."""
151+
mismatch_obj = mismatches(
152+
expected={"node_prefix": "abc"},
153+
actual={"node": "def123456"},
154+
)
155+
156+
str_repr = str(mismatch_obj)
157+
assert "node: expected prefix 'abc', got 'def123456'" in str_repr
158+
159+
160+
def test_scm_version_matches_datetime() -> None:
161+
"""Test that ScmVersion.matches works with datetime fields."""
162+
c = Configuration()
163+
164+
# Create version with specific datetime
165+
version = meta(
166+
"1.0.0",
167+
distance=0,
168+
dirty=False,
169+
node_date=date(2023, 6, 15),
170+
time=TEST_SOURCE_DATE,
171+
config=c,
172+
)
173+
174+
# Test date matching
175+
assert version.matches(node_date=date(2023, 6, 15))
176+
assert not version.matches(node_date=date(2023, 6, 16))
177+
178+
# Test time matching
179+
assert version.matches(time=TEST_SOURCE_DATE)
180+
assert not version.matches(time=datetime(2023, 1, 1, tzinfo=timezone.utc))

0 commit comments

Comments
 (0)