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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
65 changes: 65 additions & 0 deletions src/setuptools_scm/_compat.py
Original file line number Diff line number Diff line change
@@ -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)
8 changes: 3 additions & 5 deletions src/setuptools_scm/_file_finders/git.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 0 additions & 2 deletions src/setuptools_scm/_overrides.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 3 additions & 5 deletions src/setuptools_scm/git.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
135 changes: 122 additions & 13 deletions src/setuptools_scm/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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
)
16 changes: 15 additions & 1 deletion testing/test_basic_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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,
Expand Down
2 changes: 0 additions & 2 deletions testing/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
Loading
Loading