Skip to content
Merged
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ test = [
"build",
"pytest",
"pytest-timeout", # Timeout protection for CI/CD
"pytest-xdist",
"rich",
"ruff",
"mypy~=1.13.0", # pinned to old for python 3.8
Expand Down
6 changes: 5 additions & 1 deletion src/setuptools_scm/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,13 @@
from setuptools_scm import Configuration
from setuptools_scm._file_finders import find_files
from setuptools_scm._get_version_impl import _get_version
from setuptools_scm._integration.pyproject_reading import PyProjectData
from setuptools_scm.discover import walk_potential_roots


def main(args: list[str] | None = None) -> int:
def main(
args: list[str] | None = None, *, _given_pyproject_data: PyProjectData | None = None
) -> int:
opts = _get_cli_opts(args)
inferred_root: str = opts.root or "."

Expand All @@ -24,6 +27,7 @@ def main(args: list[str] | None = None) -> int:
config = Configuration.from_file(
pyproject,
root=(os.path.abspath(opts.root) if opts.root is not None else None),
pyproject_data=_given_pyproject_data,
)
except (LookupError, FileNotFoundError) as ex:
# no pyproject.toml OR no [tool.setuptools_scm]
Expand Down
103 changes: 103 additions & 0 deletions src/setuptools_scm/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@
from typing_extensions import Concatenate
from typing_extensions import ParamSpec

if sys.version_info >= (3, 11):
from typing import Unpack
else:
from typing_extensions import Unpack

_P = ParamSpec("_P")

from typing import TypedDict
Expand All @@ -51,6 +56,52 @@ class _TagDict(TypedDict):
suffix: str


class VersionExpectations(TypedDict, total=False):
"""Expected properties for ScmVersion matching."""

tag: str | _VersionT
distance: int
dirty: bool
node_prefix: str # Prefix of the node/commit hash
branch: str | None
exact: bool
preformatted: bool
node_date: date | None
time: datetime | None


@dataclasses.dataclass
class mismatches:
"""Represents mismatches between expected and actual ScmVersion properties."""

expected: dict[str, Any]
actual: dict[str, Any]

def __bool__(self) -> bool:
"""mismatches is falsy to allow `if not version.matches(...)`."""
return False

def __str__(self) -> str:
"""Format mismatches for error reporting."""
lines = []
for key, exp_val in self.expected.items():
if key == "node_prefix":
# Special handling for node prefix matching
actual_node = self.actual.get("node")
if not actual_node or not actual_node.startswith(exp_val):
lines.append(
f" node: expected prefix '{exp_val}', got '{actual_node}'"
)
else:
act_val = self.actual.get(key)
if str(exp_val) != str(act_val):
lines.append(f" {key}: expected {exp_val!r}, got {act_val!r}")
return "\n".join(lines)

def __repr__(self) -> str:
return f"mismatches(expected={self.expected!r}, actual={self.actual!r})"


def _parse_version_tag(
tag: str | object, config: _config.Configuration
) -> _TagDict | None:
Expand Down Expand Up @@ -220,6 +271,58 @@ def format_next_version(
guessed = guess_next(self, *k, **kw)
return self.format_with(fmt, guessed=guessed)

def matches(self, **expectations: Unpack[VersionExpectations]) -> bool | mismatches:
"""Check if this ScmVersion matches the given expectations.

Returns True if all specified properties match, or a mismatches
object (which is falsy) containing details of what didn't match.

Args:
**expectations: Properties to check, using VersionExpectations TypedDict
"""
# Map expectation keys to ScmVersion attributes
attr_map: dict[str, Callable[[], Any]] = {
"tag": lambda: str(self.tag),
"node_prefix": lambda: self.node,
"distance": lambda: self.distance,
"dirty": lambda: self.dirty,
"branch": lambda: self.branch,
"exact": lambda: self.exact,
"preformatted": lambda: self.preformatted,
"node_date": lambda: self.node_date,
"time": lambda: self.time,
}

# Build actual values dict
actual: dict[str, Any] = {
key: attr_map[key]() for key in expectations if key in attr_map
}

# Process expectations
expected = {
"tag" if k == "tag" else k: str(v) if k == "tag" else v
for k, v in expectations.items()
}

# Check for mismatches
def has_mismatch() -> bool:
for key, exp_val in expected.items():
if key == "node_prefix":
act_val = actual.get("node_prefix")
if not act_val or not act_val.startswith(exp_val):
return True
else:
if str(exp_val) != str(actual.get(key)):
return True
return False

if has_mismatch():
# Rename node_prefix back to node for actual values in mismatch reporting
if "node_prefix" in actual:
actual["node"] = actual.pop("node_prefix")
return mismatches(expected=expected, actual=actual)
return True


def _parse_tag(
tag: _VersionT | str, preformatted: bool, config: _config.Configuration
Expand Down
32 changes: 20 additions & 12 deletions testing/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import shutil
import sys

from datetime import datetime
from datetime import timezone
from pathlib import Path
from types import TracebackType
from typing import Any
Expand All @@ -21,10 +23,18 @@

from .wd_wrapper import WorkDir

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

def pytest_configure() -> None:

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


Expand All @@ -42,10 +52,10 @@ def pytest_report_header() -> list[str]:
# Replace everything up to and including site-packages with site::
parts = path.split("site-packages", 1)
if len(parts) > 1:
path = "site:." + parts[1]
path = "site::" + parts[1]
elif path and str(Path.cwd()) in path:
# Replace current working directory with CWD::
path = path.replace(str(Path.cwd()), "CWD:.")
path = path.replace(str(Path.cwd()), "CWD::")
res.append(f"{pkg} version {pkg_version} from {path}")
return res

Expand Down Expand Up @@ -90,6 +100,10 @@ def debug_mode() -> Iterator[DebugMode]:

@pytest.fixture
def wd(tmp_path: Path) -> WorkDir:
"""Base WorkDir fixture that returns an unconfigured working directory.

Individual test modules should override this fixture to set up specific SCM configurations.
"""
target_wd = tmp_path.resolve() / "wd"
target_wd.mkdir()
return WorkDir(target_wd)
Expand All @@ -109,12 +123,7 @@ def repositories_hg_git(tmp_path: Path) -> tuple[WorkDir, WorkDir]:
path_git = tmp_path / "repo_git"
path_git.mkdir()

wd = WorkDir(path_git)
wd("git init")
wd("git config user.email test@example.com")
wd('git config user.name "a test"')
wd.add_command = "git add ."
wd.commit_command = "git commit -m test-{reason}"
wd = WorkDir(path_git).setup_git()

path_hg = tmp_path / "repo_hg"
run(["hg", "clone", path_git, path_hg, "--config", "extensions.hggit="], tmp_path)
Expand All @@ -124,7 +133,6 @@ def repositories_hg_git(tmp_path: Path) -> tuple[WorkDir, WorkDir]:
file.write("[extensions]\nhggit =\n")

wd_hg = WorkDir(path_hg)
wd_hg.add_command = "hg add ."
wd_hg.commit_command = 'hg commit -m test-{reason} -u test -d "0 0"'
wd_hg.configure_hg_commands()

return wd_hg, wd
33 changes: 7 additions & 26 deletions testing/test_better_root_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,32 +16,13 @@
from setuptools_scm._get_version_impl import _version_missing
from testing.wd_wrapper import WorkDir


def setup_git_repo(wd: WorkDir) -> WorkDir:
"""Set up a git repository for testing."""
wd("git init")
wd("git config user.email test@example.com")
wd('git config user.name "a test"')
wd.add_command = "git add ."
wd.commit_command = "git commit -m test-{reason}"
return wd


def setup_hg_repo(wd: WorkDir) -> WorkDir:
"""Set up a mercurial repository for testing."""
try:
wd("hg init")
wd.add_command = "hg add ."
wd.commit_command = 'hg commit -m test-{reason} -u test -d "0 0"'
return wd
except Exception:
pytest.skip("hg not available")
# No longer need to import setup functions - using WorkDir methods directly


def test_find_scm_in_parents_finds_git(wd: WorkDir) -> None:
"""Test that _find_scm_in_parents correctly finds git repositories in parent directories."""
# Set up git repo in root
setup_git_repo(wd)
wd.setup_git()

# Create a subdirectory structure
subdir = wd.cwd / "subproject" / "nested"
Expand All @@ -57,7 +38,7 @@ def test_find_scm_in_parents_finds_git(wd: WorkDir) -> None:
def test_find_scm_in_parents_finds_hg(wd: WorkDir) -> None:
"""Test that _find_scm_in_parents correctly finds mercurial repositories in parent directories."""
# Set up hg repo in root
setup_hg_repo(wd)
wd.setup_hg()

# Create a subdirectory structure
subdir = wd.cwd / "subproject" / "nested"
Expand Down Expand Up @@ -85,7 +66,7 @@ def test_find_scm_in_parents_returns_none(wd: WorkDir) -> None:
def test_version_missing_with_scm_in_parent(wd: WorkDir) -> None:
"""Test that _version_missing provides helpful error message when SCM is found in parent."""
# Set up git repo in root
setup_git_repo(wd)
wd.setup_git()

# Create a subdirectory structure
subdir = wd.cwd / "subproject" / "nested"
Expand Down Expand Up @@ -130,7 +111,7 @@ def test_version_missing_no_scm_found(wd: WorkDir) -> None:
def test_version_missing_with_relative_to_set(wd: WorkDir) -> None:
"""Test that when relative_to is set, we don't search parents for error messages."""
# Set up git repo in root
setup_git_repo(wd)
wd.setup_git()

# Create a subdirectory structure
subdir = wd.cwd / "subproject" / "nested"
Expand Down Expand Up @@ -161,7 +142,7 @@ def test_search_parent_directories_works_as_suggested(
) -> None:
"""Test that the suggested search_parent_directories=True solution actually works."""
# Set up git repo
setup_git_repo(wd)
wd.setup_git()
wd.commit_testfile() # Make sure there's a commit for version detection

# Create a subdirectory
Expand All @@ -182,7 +163,7 @@ def test_integration_better_error_from_nested_directory(
) -> None:
"""Integration test: get_version from nested directory should give helpful error."""
# Set up git repo
setup_git_repo(wd)
wd.setup_git()

# Create a subdirectory
subdir = wd.cwd / "subproject"
Expand Down
Loading
Loading