diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d6d4ef3..10d46481 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Breaking + +- fix #1019: pass python version build tags from scm version to results propperly + ### Added - add `setuptools-scm` console_scripts entry point to make the CLI directly executable diff --git a/src/setuptools_scm/_compat.py b/src/setuptools_scm/_compat.py new file mode 100644 index 00000000..4e9e301f --- /dev/null +++ b/src/setuptools_scm/_compat.py @@ -0,0 +1,65 @@ +"""Compatibility utilities for cross-platform functionality.""" + +from __future__ import annotations + + +def normalize_path_for_assertion(path: str) -> str: + """Normalize path separators for cross-platform assertions. + + On Windows, this converts backslashes to forward slashes to ensure + path comparisons work correctly. On other platforms, returns the path unchanged. + The length of the string is not changed by this operation. + + Args: + path: The path string to normalize + + Returns: + The path with normalized separators + """ + return path.replace("\\", "/") + + +def strip_path_suffix( + full_path: str, suffix_path: str, error_msg: str | None = None +) -> str: + """Strip a suffix from a path, with cross-platform path separator handling. + + This function first normalizes path separators for Windows compatibility, + then asserts that the full path ends with the suffix, and finally returns + the path with the suffix removed. This is the common pattern used for + computing parent directories from git output. + + Args: + full_path: The full path string + suffix_path: The suffix path to strip from the end + error_msg: Optional custom error message for the assertion + + Returns: + The prefix path with the suffix removed + + Raises: + AssertionError: If the full path doesn't end with the suffix + """ + normalized_full = normalize_path_for_assertion(full_path) + + if error_msg: + assert normalized_full.endswith(suffix_path), error_msg + else: + assert normalized_full.endswith(suffix_path), ( + f"Path assertion failed: {full_path!r} does not end with {suffix_path!r}" + ) + + return full_path[: -len(suffix_path)] + + +# Legacy aliases for backward compatibility during transition +def assert_path_endswith( + full_path: str, suffix_path: str, error_msg: str | None = None +) -> None: + """Legacy alias - use strip_path_suffix instead.""" + strip_path_suffix(full_path, suffix_path, error_msg) + + +def compute_path_prefix(full_path: str, suffix_path: str) -> str: + """Legacy alias - use strip_path_suffix instead.""" + return strip_path_suffix(full_path, suffix_path) diff --git a/src/setuptools_scm/_file_finders/git.py b/src/setuptools_scm/_file_finders/git.py index fe6dfb05..4379c21a 100644 --- a/src/setuptools_scm/_file_finders/git.py +++ b/src/setuptools_scm/_file_finders/git.py @@ -39,11 +39,9 @@ def _git_toplevel(path: str) -> str | None: # ``cwd`` is absolute path to current working directory. # the below method removes the length of ``out`` from # ``cwd``, which gives the git toplevel - assert cwd.replace("\\", "/").endswith(out), f"cwd={cwd!r}\nout={out!r}" - # In windows cwd contains ``\`` which should be replaced by ``/`` - # for this assertion to work. Length of string isn't changed by replace - # ``\\`` is just and escape for `\` - out = cwd[: -len(out)] + from .._compat import strip_path_suffix + + out = strip_path_suffix(cwd, out, f"cwd={cwd!r}\nout={out!r}") log.debug("find files toplevel %s", out) return norm_real(out) except subprocess.CalledProcessError: diff --git a/src/setuptools_scm/_overrides.py b/src/setuptools_scm/_overrides.py index 698f0fa3..5621534f 100644 --- a/src/setuptools_scm/_overrides.py +++ b/src/setuptools_scm/_overrides.py @@ -164,8 +164,6 @@ def _read_pretended_version_for( pretended = read_named_env(name="PRETEND_VERSION", dist_name=config.dist_name) if pretended: - # we use meta here since the pretended version - # must adhere to the pep to begin with return version.meta(tag=pretended, preformatted=True, config=config) else: return None diff --git a/src/setuptools_scm/git.py b/src/setuptools_scm/git.py index acfd8e76..966ab69c 100644 --- a/src/setuptools_scm/git.py +++ b/src/setuptools_scm/git.py @@ -93,11 +93,9 @@ def from_potential_worktree(cls, wd: _t.PathT) -> GitWorkdir | None: real_wd = os.fspath(wd) else: str_wd = os.fspath(wd) - assert str_wd.replace("\\", "/").endswith(real_wd) - # In windows wd contains ``\`` which should be replaced by ``/`` - # for this assertion to work. Length of string isn't changed by replace - # ``\\`` is just and escape for `\` - real_wd = str_wd[: -len(real_wd)] + from ._compat import strip_path_suffix + + real_wd = strip_path_suffix(str_wd, real_wd) log.debug("real root %s", real_wd) if not samefile(real_wd, wd): return None diff --git a/src/setuptools_scm/version.py b/src/setuptools_scm/version.py index 69b184eb..77c26dc9 100644 --- a/src/setuptools_scm/version.py +++ b/src/setuptools_scm/version.py @@ -111,11 +111,31 @@ def tag_to_version( version_str = tag_dict["version"] log.debug("version pre parse %s", version_str) - if suffix := tag_dict.get("suffix", ""): - warnings.warn(f"tag {tag!r} will be stripped of its suffix {suffix!r}") + # Try to create version from base version first + try: + version: _VersionT = config.version_cls(version_str) + log.debug("version=%r", version) + except Exception: + warnings.warn( + f"tag {tag!r} will be stripped of its suffix {tag_dict.get('suffix', '')!r}" + ) + # Fall back to trying without any suffix + version = config.version_cls(version_str) + log.debug("version=%r", version) + return version - version: _VersionT = config.version_cls(version_str) - log.debug("version=%r", version) + # If base version is valid, check if we can preserve the suffix + if suffix := tag_dict.get("suffix", ""): + log.debug("tag %r includes local build data %r, preserving it", tag, suffix) + # Try creating version with suffix - if it fails, we'll use the base version + try: + version_with_suffix = config.version_cls(version_str + suffix) + log.debug("version with suffix=%r", version_with_suffix) + return version_with_suffix + except Exception: + warnings.warn(f"tag {tag!r} will be stripped of its suffix {suffix!r}") + # Return the base version without suffix + return version return version @@ -132,8 +152,8 @@ def _source_epoch_or_utc_now() -> datetime: class ScmVersion: """represents a parsed version from scm""" - tag: _v.Version | _v.NonNormalizedVersion | str - """the related tag or preformatted version string""" + tag: _v.Version | _v.NonNormalizedVersion + """the related tag or preformatted version""" config: _config.Configuration """the configuration used to parse the version""" distance: int = 0 @@ -203,9 +223,16 @@ def format_next_version( def _parse_tag( tag: _VersionT | str, preformatted: bool, config: _config.Configuration -) -> _VersionT | str: +) -> _VersionT: if preformatted: - return tag + # For preformatted versions, tag should already be validated as a version object + # String validation is handled in meta function before calling this + if isinstance(tag, str): + # This should not happen with enhanced meta, but kept for safety + return _v.NonNormalizedVersion(tag) + else: + # Already a version object (including test mocks), return as-is + return tag elif not isinstance(tag, config.version_cls): version = tag_to_version(tag, config) assert version is not None @@ -226,7 +253,16 @@ def meta( node_date: date | None = None, time: datetime | None = None, ) -> ScmVersion: - parsed_version = _parse_tag(tag, preformatted, config) + parsed_version: _VersionT + # Enhanced string validation for preformatted versions + if preformatted and isinstance(tag, str): + # Validate PEP 440 compliance using NonNormalizedVersion + # Let validation errors bubble up to the caller + parsed_version = _v.NonNormalizedVersion(tag) + else: + # Use existing _parse_tag logic for non-preformatted or already validated inputs + parsed_version = _parse_tag(tag, preformatted, config) + log.info("version %s -> %s", tag, parsed_version) assert parsed_version is not None, f"Can't parse version {tag}" scm_version = ScmVersion( @@ -455,20 +491,93 @@ def postrelease_version(version: ScmVersion) -> str: return version.format_with("{tag}.post{distance}") +def _combine_version_with_local_parts( + main_version: str, *local_parts: str | None +) -> str: + """ + Combine a main version with multiple local parts into a valid PEP 440 version string. + Handles deduplication of local parts to avoid adding the same local data twice. + + Args: + main_version: The main version string (e.g., "1.2.0", "1.2.dev3") + *local_parts: Variable number of local version parts, can be None or empty + + Returns: + A valid PEP 440 version string + + Examples: + _combine_version_with_local_parts("1.2.0", "build.123", "d20090213") -> "1.2.0+build.123.d20090213" + _combine_version_with_local_parts("1.2.0", "build.123", None) -> "1.2.0+build.123" + _combine_version_with_local_parts("1.2.0+build.123", "d20090213") -> "1.2.0+build.123.d20090213" + _combine_version_with_local_parts("1.2.0+build.123", "build.123") -> "1.2.0+build.123" # no duplication + _combine_version_with_local_parts("1.2.0", None, None) -> "1.2.0" + """ + # Split main version into base and existing local parts + if "+" in main_version: + main_part, existing_local = main_version.split("+", 1) + all_local_parts = existing_local.split(".") + else: + main_part = main_version + all_local_parts = [] + + # Process each new local part + for part in local_parts: + if not part or not part.strip(): + continue + + # Strip any leading + and split into segments + clean_part = part.strip("+") + if not clean_part: + continue + + # Split multi-part local identifiers (e.g., "build.123" -> ["build", "123"]) + part_segments = clean_part.split(".") + + # Add each segment if not already present + for segment in part_segments: + if segment and segment not in all_local_parts: + all_local_parts.append(segment) + + # Return combined result + if all_local_parts: + return main_part + "+" + ".".join(all_local_parts) + else: + return main_part + + def format_version(version: ScmVersion) -> str: log.debug("scm version %s", version) log.debug("config %s", version.config) if version.preformatted: - assert isinstance(version.tag, str) - return version.tag + return str(version.tag) + + # Extract original tag's local data for later combination + original_local = "" + if hasattr(version.tag, "local") and version.tag.local is not None: + original_local = str(version.tag.local) + + # Create a patched ScmVersion with only the base version (no local data) for version schemes + from dataclasses import replace + + # Extract the base version (public part) from the tag using config's version_cls + base_version_str = str(version.tag.public) + base_tag = version.config.version_cls(base_version_str) + version_for_scheme = replace(version, tag=base_tag) main_version = _entrypoints._call_version_scheme( - version, "setuptools_scm.version_scheme", version.config.version_scheme + version_for_scheme, + "setuptools_scm.version_scheme", + version.config.version_scheme, ) log.debug("version %s", main_version) assert main_version is not None + local_version = _entrypoints._call_version_scheme( version, "setuptools_scm.local_scheme", version.config.local_scheme, "+unknown" ) log.debug("local_version %s", local_version) - return main_version + local_version + + # Combine main version with original local data and new local scheme data + return _combine_version_with_local_parts( + str(main_version), original_local, local_version + ) diff --git a/testing/test_basic_api.py b/testing/test_basic_api.py index 0eb4a247..ca0c3041 100644 --- a/testing/test_basic_api.py +++ b/testing/test_basic_api.py @@ -55,7 +55,9 @@ def assert_root(monkeypatch: pytest.MonkeyPatch, expected_root: str) -> None: def assertion(config: Configuration) -> ScmVersion: assert config.absolute_root == expected_root - return ScmVersion("1.0", config=config) + from packaging.version import Version + + return ScmVersion(Version("1.0"), config=config) monkeypatch.setattr(setuptools_scm._get_version_impl, "parse_version", assertion) @@ -250,6 +252,18 @@ def __init__(self, tag_str: str) -> None: def __repr__(self) -> str: return f"hello,{self.version}" + @property + def public(self) -> str: + """The public portion of the version (without local part).""" + return self.version.split("+")[0] + + @property + def local(self) -> str | None: + """The local version segment.""" + if "+" in self.version: + return self.version.split("+", 1)[1] + return None + # you can not use normalize=False and version_cls at the same time with pytest.raises( ValueError, diff --git a/testing/test_cli.py b/testing/test_cli.py index 480793c5..ffdcebd2 100644 --- a/testing/test_cli.py +++ b/testing/test_cli.py @@ -93,7 +93,6 @@ def test_cli_create_archival_file_stable( archival_file = wd.cwd / ".git_archival.txt" assert not archival_file.exists() - # Test successful creation result = main(["create-archival-file", "--stable"]) assert result == 0 assert archival_file.exists() @@ -122,7 +121,6 @@ def test_cli_create_archival_file_full( archival_file = wd.cwd / ".git_archival.txt" assert not archival_file.exists() - # Test successful creation result = main(["create-archival-file", "--full"]) assert result == 0 assert archival_file.exists() diff --git a/testing/test_compat.py b/testing/test_compat.py new file mode 100644 index 00000000..3cd52771 --- /dev/null +++ b/testing/test_compat.py @@ -0,0 +1,73 @@ +"""Test compatibility utilities.""" + +from __future__ import annotations + +import pytest + +from setuptools_scm._compat import normalize_path_for_assertion +from setuptools_scm._compat import strip_path_suffix + + +def test_normalize_path_for_assertion() -> None: + """Test path normalization for assertions.""" + # Unix-style paths should remain unchanged + assert normalize_path_for_assertion("/path/to/file") == "/path/to/file" + + # Windows-style paths should be normalized + assert normalize_path_for_assertion(r"C:\path\to\file") == "C:/path/to/file" + assert normalize_path_for_assertion(r"path\to\file") == "path/to/file" + + # Mixed paths should be normalized + assert normalize_path_for_assertion(r"C:\path/to\file") == "C:/path/to/file" + + # Already normalized paths should remain unchanged + assert normalize_path_for_assertion("path/to/file") == "path/to/file" + + +def test_strip_path_suffix_success() -> None: + """Test successful path suffix stripping.""" + # Unix-style paths + assert strip_path_suffix("/home/user/project", "project") == "/home/user/" + assert ( + strip_path_suffix("/home/user/project/subdir", "project/subdir") + == "/home/user/" + ) + + # Windows-style paths + assert ( + strip_path_suffix("C:\\Users\\user\\project", "project") == "C:\\Users\\user\\" + ) + assert ( + strip_path_suffix("C:\\Users\\user\\project\\subdir", "project/subdir") + == "C:\\Users\\user\\" + ) + + # Mixed paths should work due to normalization + assert ( + strip_path_suffix("C:\\Users\\user\\project", "project") == "C:\\Users\\user\\" + ) + assert strip_path_suffix("/home/user/project", "project") == "/home/user/" + + # Edge cases + assert strip_path_suffix("project", "project") == "" + assert strip_path_suffix("/project", "project") == "/" + + +def test_strip_path_suffix_failure() -> None: + """Test failed path suffix stripping.""" + with pytest.raises(AssertionError, match="Path assertion failed"): + strip_path_suffix("/home/user/project", "other") + + with pytest.raises(AssertionError, match="Custom error"): + strip_path_suffix("/home/user/project", "other", "Custom error") + + +def test_integration_example() -> None: + """Test the integration pattern used in the codebase.""" + # Simulate the pattern used in git.py and _file_finders/git.py + full_path = r"C:\\Users\\user\\project\\subdir" + suffix = "subdir" + + # Now this is a single operation + prefix = strip_path_suffix(full_path, suffix) + assert prefix == r"C:\\Users\\user\\project\\" diff --git a/testing/test_functions.py b/testing/test_functions.py index d6c4e711..c0cb5166 100644 --- a/testing/test_functions.py +++ b/testing/test_functions.py @@ -47,6 +47,18 @@ def test_next_tag(tag: str, expected: str) -> None: "distance-dirty": meta("1.1", distance=3, dirty=True, config=c), } +# Versions with build metadata in the tag +VERSIONS_WITH_BUILD_METADATA = { + "exact-build": meta("1.1+build.123", distance=0, dirty=False, config=c), + "dirty-build": meta("1.1+build.123", distance=0, dirty=True, config=c), + "distance-clean-build": meta("1.1+build.123", distance=3, dirty=False, config=c), + "distance-dirty-build": meta("1.1+build.123", distance=3, dirty=True, config=c), + "exact-ci": meta("2.0.0+ci.456", distance=0, dirty=False, config=c), + "dirty-ci": meta("2.0.0+ci.456", distance=0, dirty=True, config=c), + "distance-clean-ci": meta("2.0.0+ci.456", distance=2, dirty=False, config=c), + "distance-dirty-ci": meta("2.0.0+ci.456", distance=2, dirty=True, config=c), +} + @pytest.mark.parametrize( ("version", "version_scheme", "local_scheme", "expected"), @@ -77,6 +89,96 @@ def test_format_version( assert format_version(configured_version) == expected +@pytest.mark.parametrize( + ("version", "version_scheme", "local_scheme", "expected"), + [ + # Exact matches should preserve build metadata from tag + ("exact-build", "guess-next-dev", "node-and-date", "1.1+build.123"), + ("exact-build", "guess-next-dev", "no-local-version", "1.1+build.123"), + ("exact-ci", "guess-next-dev", "node-and-date", "2.0.0+ci.456"), + ("exact-ci", "guess-next-dev", "no-local-version", "2.0.0+ci.456"), + # Dirty exact matches - version scheme treats dirty as non-exact, build metadata preserved + ( + "dirty-build", + "guess-next-dev", + "node-and-date", + "1.2.dev0+build.123.d20090213", + ), + ("dirty-build", "guess-next-dev", "no-local-version", "1.2.dev0+build.123"), + ("dirty-ci", "guess-next-dev", "node-and-date", "2.0.1.dev0+ci.456.d20090213"), + # Distance cases - build metadata should be preserved and combined with SCM data + ( + "distance-clean-build", + "guess-next-dev", + "node-and-date", + "1.2.dev3+build.123", + ), + ( + "distance-clean-build", + "guess-next-dev", + "no-local-version", + "1.2.dev3+build.123", + ), + ("distance-clean-ci", "guess-next-dev", "node-and-date", "2.0.1.dev2+ci.456"), + # Distance + dirty cases - build metadata should be preserved and combined with SCM data + ( + "distance-dirty-build", + "guess-next-dev", + "node-and-date", + "1.2.dev3+build.123.d20090213", + ), + ( + "distance-dirty-ci", + "guess-next-dev", + "node-and-date", + "2.0.1.dev2+ci.456.d20090213", + ), + # Post-release scheme tests + ("exact-build", "post-release", "node-and-date", "1.1+build.123"), + ( + "dirty-build", + "post-release", + "node-and-date", + "1.1.post0+build.123.d20090213", + ), + ( + "distance-clean-build", + "post-release", + "node-and-date", + "1.1.post3+build.123", + ), + ( + "distance-dirty-build", + "post-release", + "node-and-date", + "1.1.post3+build.123.d20090213", + ), + ], +) +def test_format_version_with_build_metadata( + version: str, version_scheme: str, local_scheme: str, expected: str +) -> None: + """Test format_version with tags that contain build metadata.""" + from dataclasses import replace + + from packaging.version import Version + + scm_version = VERSIONS_WITH_BUILD_METADATA[version] + configured_version = replace( + scm_version, + config=replace( + scm_version.config, version_scheme=version_scheme, local_scheme=local_scheme + ), + ) + result = format_version(configured_version) + + # Validate result is a valid PEP 440 version + parsed = Version(result) + assert str(parsed) == result, f"Result should be valid PEP 440: {result}" + + assert result == expected, f"Expected {expected}, got {result}" + + def test_dump_version_doesnt_bail_on_value_error(tmp_path: Path) -> None: write_to = "VERSION" version = str(VERSIONS["exact"].tag) diff --git a/testing/test_git.py b/testing/test_git.py index 1c024027..ab105dd1 100644 --- a/testing/test_git.py +++ b/testing/test_git.py @@ -215,16 +215,26 @@ def test_version_from_git(wd: WorkDir) -> None: setup(use_scm_version={'normalize': False, 'write_to': 'VERSION.txt'}) """, "with_created_class": """ - from setuptools import setup +from setuptools import setup + +class MyVersion: + def __init__(self, tag_str: str): + self.version = tag_str + + def __repr__(self): + return self.version - class MyVersion: - def __init__(self, tag_str: str): - self.version = tag_str + @property + def public(self): + return self.version.split('+')[0] - def __repr__(self): - return self.version + @property + def local(self): + if '+' in self.version: + return self.version.split('+', 1)[1] + return None - setup(use_scm_version={'version_cls': MyVersion, 'write_to': 'VERSION.txt'}) +setup(use_scm_version={'version_cls': MyVersion, 'write_to': 'VERSION.txt'}) """, "with_named_import": """ from setuptools import setup diff --git a/testing/test_integration.py b/testing/test_integration.py index c4bfcb74..074785f9 100644 --- a/testing/test_integration.py +++ b/testing/test_integration.py @@ -3,6 +3,7 @@ import importlib.metadata import logging import os +import re import subprocess import sys import textwrap @@ -13,6 +14,8 @@ import pytest +from packaging.version import Version + import setuptools_scm._integration.setuptools if TYPE_CHECKING: @@ -190,14 +193,16 @@ def test_pretend_version_name_takes_precedence( assert wd.get_version(dist_name="test") == "1.0.0" -def test_pretend_version_accepts_bad_string( +def test_pretend_version_rejects_invalid_string( monkeypatch: pytest.MonkeyPatch, wd: WorkDir ) -> None: + """Test that invalid pretend versions raise errors and bubble up.""" monkeypatch.setenv(PRETEND_KEY, "dummy") wd.write("setup.py", SETUP_PY_PLAIN) - assert wd.get_version(write_to="test.py") == "dummy" - pyver = wd([sys.executable, "setup.py", "--version"]) - assert pyver == "0.0.0" + + # With strict validation, invalid pretend versions should raise errors + with pytest.raises(Exception, match=r".*dummy.*"): + wd.get_version(write_to="test.py") def test_pretend_metadata_with_version( @@ -214,8 +219,7 @@ def test_pretend_metadata_with_version( # Test version file template functionality wd.write("setup.py", SETUP_PY_PLAIN) - wd("mkdir -p src") # Create the src directory - # This is a template string, not an f-string - used by setuptools-scm templating + wd("mkdir -p src") version_file_content = """ version = '{version}' major = {version_tuple[0]} @@ -229,7 +233,6 @@ def test_pretend_metadata_with_version( write_to="src/version.py", write_to_template=version_file_content ) - # Read the file using pathlib content = (wd.cwd / "src/version.py").read_text() assert "commit_hash = 'g1337beef'" in content assert "num_commit = 4" in content @@ -261,10 +264,8 @@ def test_pretend_metadata_without_version_warns( # Let's create an empty git repo without commits to truly have no base version monkeypatch.setenv(PRETEND_METADATA_KEY, '{node="g1234567", distance=2}') - # Should get a version with fallback but metadata overrides applied with caplog.at_level(logging.WARNING): version = wd.get_version() - # Should get a fallback version with metadata overrides assert version is not None # In this case, metadata was applied to a fallback version, so no warning about missing base @@ -296,7 +297,6 @@ def test_pretend_metadata_with_scm_version( # Test version file to see if metadata was applied wd.write("setup.py", SETUP_PY_PLAIN) wd("mkdir -p src") - # This is a template string, not an f-string - used by setuptools-scm templating version_file_content = """ version = '{version}' commit_hash = '{scm_version.short_node}' @@ -381,6 +381,136 @@ def test_pretend_metadata_invalid_toml_error( assert "Failed to parse pretend metadata" in caplog.text +def test_git_tag_with_local_build_data_preserved(wd: WorkDir) -> None: + """Test that git tags containing local build data are preserved in final version.""" + wd.commit_testfile() + + # Create a git tag that includes local build data + # This simulates a CI system that creates tags with build metadata + wd("git tag 1.0.0+build.123") + + # The version should preserve the build metadata from the tag + version = wd.get_version() + + # Validate it's a proper PEP 440 version + parsed_version = Version(version) + assert str(parsed_version) == version, ( + f"Version should parse correctly as PEP 440: {version}" + ) + + assert version == "1.0.0+build.123", ( + f"Expected build metadata preserved, got {version}" + ) + + # Validate the local part is correct + assert parsed_version.local == "build.123", ( + f"Expected local part 'build.123', got {parsed_version.local}" + ) + + +def test_git_tag_with_commit_hash_preserved(wd: WorkDir) -> None: + """Test that git tags with commit hash data are preserved.""" + wd.commit_testfile() + + # Create a git tag that includes commit hash metadata + wd("git tag 2.0.0+sha.abcd1234") + + # The version should preserve the commit hash from the tag + version = wd.get_version() + + # Validate it's a proper PEP 440 version + parsed_version = Version(version) + assert str(parsed_version) == version, ( + f"Version should parse correctly as PEP 440: {version}" + ) + + assert version == "2.0.0+sha.abcd1234" + + # Validate the local part is correct + assert parsed_version.local == "sha.abcd1234", ( + f"Expected local part 'sha.abcd1234', got {parsed_version.local}" + ) + + +def test_git_tag_with_local_build_data_preserved_dirty_workdir(wd: WorkDir) -> None: + """Test that git tags with local build data are preserved even with dirty working directory.""" + wd.commit_testfile() + + # Create a git tag that includes local build data + wd("git tag 1.5.0+build.456") + + # Make working directory dirty + wd.write("modified_file.txt", "some changes") + + # The version should preserve the build metadata from the tag + # even when working directory is dirty + version = wd.get_version() + + # Validate it's a proper PEP 440 version + parsed_version = Version(version) + assert str(parsed_version) == version, ( + f"Version should parse correctly as PEP 440: {version}" + ) + + assert version == "1.5.0+build.456", ( + f"Expected build metadata preserved with dirty workdir, got {version}" + ) + + # Validate the local part is correct + assert parsed_version.local == "build.456", ( + f"Expected local part 'build.456', got {parsed_version.local}" + ) + + +def test_git_tag_with_local_build_data_preserved_with_distance(wd: WorkDir) -> None: + """Test that git tags with local build data are preserved with distance.""" + wd.commit_testfile() + + # Create a git tag that includes local build data + wd("git tag 3.0.0+ci.789") + + # Add another commit after the tag to create distance + wd.commit_testfile("after-tag") + + # The version should use version scheme for distance but preserve original tag's build data + version = wd.get_version() + + # Validate it's a proper PEP 440 version + parsed_version = Version(version) + assert str(parsed_version) == version, ( + f"Version should parse correctly as PEP 440: {version}" + ) + + # Tag local data should be preserved and combined with SCM data + assert version.startswith("3.0.1.dev1"), ( + f"Expected dev version with distance, got {version}" + ) + + # Use regex to validate the version format with both tag build data and SCM node data + # Expected format: 3.0.1.dev1+ci.789.g + version_pattern = r"^3\.0\.1\.dev1\+ci\.789\.g[a-f0-9]+$" + assert re.match(version_pattern, version), ( + f"Version should match pattern {version_pattern}, got {version}" + ) + + # The original tag's local data (+ci.789) should be preserved and combined with SCM data + assert "+ci.789" in version, f"Tag local data should be preserved, got {version}" + + # Validate the local part contains both tag and SCM node information + assert parsed_version.local is not None, ( + f"Expected local version part, got {parsed_version.local}" + ) + assert "ci.789" in parsed_version.local, ( + f"Expected local part to contain tag data 'ci.789', got {parsed_version.local}" + ) + assert "g" in parsed_version.local, ( + f"Expected local part to contain SCM node data 'g...', got {parsed_version.local}" + ) + + # Note: This test verifies that local build data from tags is preserved and combined + # with SCM data when there's distance, which is the desired behavior for issue 1019. + + def testwarn_on_broken_setuptools() -> None: _warn_on_old_setuptools("61") with pytest.warns(RuntimeWarning, match="ERROR: setuptools==60"): diff --git a/testing/test_version.py b/testing/test_version.py index eb31cc1e..71850074 100644 --- a/testing/test_version.py +++ b/testing/test_version.py @@ -71,7 +71,27 @@ def test_next_semver(version: ScmVersion, expected_next: str) -> None: def test_next_semver_bad_tag() -> None: - version = meta("1.0.0-foo", preformatted=True, config=c) + # Create a mock version class that represents an invalid version for testing error handling + from typing import cast + + from setuptools_scm._version_cls import _VersionT + + class BrokenVersionForTest: + """A mock version that behaves like a string but passes type checking.""" + + def __init__(self, version_str: str): + self._version_str = version_str + + def __str__(self) -> str: + return self._version_str + + def __repr__(self) -> str: + return f"BrokenVersionForTest({self._version_str!r})" + + # Cast to the expected type to avoid type checking issues + broken_tag = cast(_VersionT, BrokenVersionForTest("1.0.0-foo")) + version = meta(broken_tag, preformatted=True, config=c) + with pytest.raises( ValueError, match=r"1\.0\.0-foo.* can't be parsed as numeric version" ): @@ -471,6 +491,18 @@ def __str__(self) -> str: def __repr__(self) -> str: return f"MyVersion" + @property + def public(self) -> str: + """The public portion of the version (without local part).""" + return self.tag.split("+")[0] + + @property + def local(self) -> str | None: + """The local version segment.""" + if "+" in self.tag: + return self.tag.split("+", 1)[1] + return None + config = Configuration(version_cls=MyVersion) # type: ignore[arg-type] scm_version = meta("1.0.0-foo", config=config)