Skip to content

Commit b9d03e4

Browse files
Merge pull request #1175 from RonnyPfannschmidt/fix-1019-pretend-keep-local
always transfer build-tags from scm tags to final versions
2 parents 0b59018 + 00d42a7 commit b9d03e4

File tree

13 files changed

+577
-46
lines changed

13 files changed

+577
-46
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## Unreleased
44

5+
### Breaking
6+
7+
- fix #1019: pass python version build tags from scm version to results propperly
8+
59
### Added
610

711
- add `setuptools-scm` console_scripts entry point to make the CLI directly executable

src/setuptools_scm/_compat.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"""Compatibility utilities for cross-platform functionality."""
2+
3+
from __future__ import annotations
4+
5+
6+
def normalize_path_for_assertion(path: str) -> str:
7+
"""Normalize path separators for cross-platform assertions.
8+
9+
On Windows, this converts backslashes to forward slashes to ensure
10+
path comparisons work correctly. On other platforms, returns the path unchanged.
11+
The length of the string is not changed by this operation.
12+
13+
Args:
14+
path: The path string to normalize
15+
16+
Returns:
17+
The path with normalized separators
18+
"""
19+
return path.replace("\\", "/")
20+
21+
22+
def strip_path_suffix(
23+
full_path: str, suffix_path: str, error_msg: str | None = None
24+
) -> str:
25+
"""Strip a suffix from a path, with cross-platform path separator handling.
26+
27+
This function first normalizes path separators for Windows compatibility,
28+
then asserts that the full path ends with the suffix, and finally returns
29+
the path with the suffix removed. This is the common pattern used for
30+
computing parent directories from git output.
31+
32+
Args:
33+
full_path: The full path string
34+
suffix_path: The suffix path to strip from the end
35+
error_msg: Optional custom error message for the assertion
36+
37+
Returns:
38+
The prefix path with the suffix removed
39+
40+
Raises:
41+
AssertionError: If the full path doesn't end with the suffix
42+
"""
43+
normalized_full = normalize_path_for_assertion(full_path)
44+
45+
if error_msg:
46+
assert normalized_full.endswith(suffix_path), error_msg
47+
else:
48+
assert normalized_full.endswith(suffix_path), (
49+
f"Path assertion failed: {full_path!r} does not end with {suffix_path!r}"
50+
)
51+
52+
return full_path[: -len(suffix_path)]
53+
54+
55+
# Legacy aliases for backward compatibility during transition
56+
def assert_path_endswith(
57+
full_path: str, suffix_path: str, error_msg: str | None = None
58+
) -> None:
59+
"""Legacy alias - use strip_path_suffix instead."""
60+
strip_path_suffix(full_path, suffix_path, error_msg)
61+
62+
63+
def compute_path_prefix(full_path: str, suffix_path: str) -> str:
64+
"""Legacy alias - use strip_path_suffix instead."""
65+
return strip_path_suffix(full_path, suffix_path)

src/setuptools_scm/_file_finders/git.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,9 @@ def _git_toplevel(path: str) -> str | None:
3939
# ``cwd`` is absolute path to current working directory.
4040
# the below method removes the length of ``out`` from
4141
# ``cwd``, which gives the git toplevel
42-
assert cwd.replace("\\", "/").endswith(out), f"cwd={cwd!r}\nout={out!r}"
43-
# In windows cwd contains ``\`` which should be replaced by ``/``
44-
# for this assertion to work. Length of string isn't changed by replace
45-
# ``\\`` is just and escape for `\`
46-
out = cwd[: -len(out)]
42+
from .._compat import strip_path_suffix
43+
44+
out = strip_path_suffix(cwd, out, f"cwd={cwd!r}\nout={out!r}")
4745
log.debug("find files toplevel %s", out)
4846
return norm_real(out)
4947
except subprocess.CalledProcessError:

src/setuptools_scm/_overrides.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -164,8 +164,6 @@ def _read_pretended_version_for(
164164
pretended = read_named_env(name="PRETEND_VERSION", dist_name=config.dist_name)
165165

166166
if pretended:
167-
# we use meta here since the pretended version
168-
# must adhere to the pep to begin with
169167
return version.meta(tag=pretended, preformatted=True, config=config)
170168
else:
171169
return None

src/setuptools_scm/git.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -93,11 +93,9 @@ def from_potential_worktree(cls, wd: _t.PathT) -> GitWorkdir | None:
9393
real_wd = os.fspath(wd)
9494
else:
9595
str_wd = os.fspath(wd)
96-
assert str_wd.replace("\\", "/").endswith(real_wd)
97-
# In windows wd contains ``\`` which should be replaced by ``/``
98-
# for this assertion to work. Length of string isn't changed by replace
99-
# ``\\`` is just and escape for `\`
100-
real_wd = str_wd[: -len(real_wd)]
96+
from ._compat import strip_path_suffix
97+
98+
real_wd = strip_path_suffix(str_wd, real_wd)
10199
log.debug("real root %s", real_wd)
102100
if not samefile(real_wd, wd):
103101
return None

src/setuptools_scm/version.py

Lines changed: 122 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -111,11 +111,31 @@ def tag_to_version(
111111
version_str = tag_dict["version"]
112112
log.debug("version pre parse %s", version_str)
113113

114-
if suffix := tag_dict.get("suffix", ""):
115-
warnings.warn(f"tag {tag!r} will be stripped of its suffix {suffix!r}")
114+
# Try to create version from base version first
115+
try:
116+
version: _VersionT = config.version_cls(version_str)
117+
log.debug("version=%r", version)
118+
except Exception:
119+
warnings.warn(
120+
f"tag {tag!r} will be stripped of its suffix {tag_dict.get('suffix', '')!r}"
121+
)
122+
# Fall back to trying without any suffix
123+
version = config.version_cls(version_str)
124+
log.debug("version=%r", version)
125+
return version
116126

117-
version: _VersionT = config.version_cls(version_str)
118-
log.debug("version=%r", version)
127+
# If base version is valid, check if we can preserve the suffix
128+
if suffix := tag_dict.get("suffix", ""):
129+
log.debug("tag %r includes local build data %r, preserving it", tag, suffix)
130+
# Try creating version with suffix - if it fails, we'll use the base version
131+
try:
132+
version_with_suffix = config.version_cls(version_str + suffix)
133+
log.debug("version with suffix=%r", version_with_suffix)
134+
return version_with_suffix
135+
except Exception:
136+
warnings.warn(f"tag {tag!r} will be stripped of its suffix {suffix!r}")
137+
# Return the base version without suffix
138+
return version
119139

120140
return version
121141

@@ -132,8 +152,8 @@ def _source_epoch_or_utc_now() -> datetime:
132152
class ScmVersion:
133153
"""represents a parsed version from scm"""
134154

135-
tag: _v.Version | _v.NonNormalizedVersion | str
136-
"""the related tag or preformatted version string"""
155+
tag: _v.Version | _v.NonNormalizedVersion
156+
"""the related tag or preformatted version"""
137157
config: _config.Configuration
138158
"""the configuration used to parse the version"""
139159
distance: int = 0
@@ -203,9 +223,16 @@ def format_next_version(
203223

204224
def _parse_tag(
205225
tag: _VersionT | str, preformatted: bool, config: _config.Configuration
206-
) -> _VersionT | str:
226+
) -> _VersionT:
207227
if preformatted:
208-
return tag
228+
# For preformatted versions, tag should already be validated as a version object
229+
# String validation is handled in meta function before calling this
230+
if isinstance(tag, str):
231+
# This should not happen with enhanced meta, but kept for safety
232+
return _v.NonNormalizedVersion(tag)
233+
else:
234+
# Already a version object (including test mocks), return as-is
235+
return tag
209236
elif not isinstance(tag, config.version_cls):
210237
version = tag_to_version(tag, config)
211238
assert version is not None
@@ -226,7 +253,16 @@ def meta(
226253
node_date: date | None = None,
227254
time: datetime | None = None,
228255
) -> ScmVersion:
229-
parsed_version = _parse_tag(tag, preformatted, config)
256+
parsed_version: _VersionT
257+
# Enhanced string validation for preformatted versions
258+
if preformatted and isinstance(tag, str):
259+
# Validate PEP 440 compliance using NonNormalizedVersion
260+
# Let validation errors bubble up to the caller
261+
parsed_version = _v.NonNormalizedVersion(tag)
262+
else:
263+
# Use existing _parse_tag logic for non-preformatted or already validated inputs
264+
parsed_version = _parse_tag(tag, preformatted, config)
265+
230266
log.info("version %s -> %s", tag, parsed_version)
231267
assert parsed_version is not None, f"Can't parse version {tag}"
232268
scm_version = ScmVersion(
@@ -455,20 +491,93 @@ def postrelease_version(version: ScmVersion) -> str:
455491
return version.format_with("{tag}.post{distance}")
456492

457493

494+
def _combine_version_with_local_parts(
495+
main_version: str, *local_parts: str | None
496+
) -> str:
497+
"""
498+
Combine a main version with multiple local parts into a valid PEP 440 version string.
499+
Handles deduplication of local parts to avoid adding the same local data twice.
500+
501+
Args:
502+
main_version: The main version string (e.g., "1.2.0", "1.2.dev3")
503+
*local_parts: Variable number of local version parts, can be None or empty
504+
505+
Returns:
506+
A valid PEP 440 version string
507+
508+
Examples:
509+
_combine_version_with_local_parts("1.2.0", "build.123", "d20090213") -> "1.2.0+build.123.d20090213"
510+
_combine_version_with_local_parts("1.2.0", "build.123", None) -> "1.2.0+build.123"
511+
_combine_version_with_local_parts("1.2.0+build.123", "d20090213") -> "1.2.0+build.123.d20090213"
512+
_combine_version_with_local_parts("1.2.0+build.123", "build.123") -> "1.2.0+build.123" # no duplication
513+
_combine_version_with_local_parts("1.2.0", None, None) -> "1.2.0"
514+
"""
515+
# Split main version into base and existing local parts
516+
if "+" in main_version:
517+
main_part, existing_local = main_version.split("+", 1)
518+
all_local_parts = existing_local.split(".")
519+
else:
520+
main_part = main_version
521+
all_local_parts = []
522+
523+
# Process each new local part
524+
for part in local_parts:
525+
if not part or not part.strip():
526+
continue
527+
528+
# Strip any leading + and split into segments
529+
clean_part = part.strip("+")
530+
if not clean_part:
531+
continue
532+
533+
# Split multi-part local identifiers (e.g., "build.123" -> ["build", "123"])
534+
part_segments = clean_part.split(".")
535+
536+
# Add each segment if not already present
537+
for segment in part_segments:
538+
if segment and segment not in all_local_parts:
539+
all_local_parts.append(segment)
540+
541+
# Return combined result
542+
if all_local_parts:
543+
return main_part + "+" + ".".join(all_local_parts)
544+
else:
545+
return main_part
546+
547+
458548
def format_version(version: ScmVersion) -> str:
459549
log.debug("scm version %s", version)
460550
log.debug("config %s", version.config)
461551
if version.preformatted:
462-
assert isinstance(version.tag, str)
463-
return version.tag
552+
return str(version.tag)
553+
554+
# Extract original tag's local data for later combination
555+
original_local = ""
556+
if hasattr(version.tag, "local") and version.tag.local is not None:
557+
original_local = str(version.tag.local)
558+
559+
# Create a patched ScmVersion with only the base version (no local data) for version schemes
560+
from dataclasses import replace
561+
562+
# Extract the base version (public part) from the tag using config's version_cls
563+
base_version_str = str(version.tag.public)
564+
base_tag = version.config.version_cls(base_version_str)
565+
version_for_scheme = replace(version, tag=base_tag)
464566

465567
main_version = _entrypoints._call_version_scheme(
466-
version, "setuptools_scm.version_scheme", version.config.version_scheme
568+
version_for_scheme,
569+
"setuptools_scm.version_scheme",
570+
version.config.version_scheme,
467571
)
468572
log.debug("version %s", main_version)
469573
assert main_version is not None
574+
470575
local_version = _entrypoints._call_version_scheme(
471576
version, "setuptools_scm.local_scheme", version.config.local_scheme, "+unknown"
472577
)
473578
log.debug("local_version %s", local_version)
474-
return main_version + local_version
579+
580+
# Combine main version with original local data and new local scheme data
581+
return _combine_version_with_local_parts(
582+
str(main_version), original_local, local_version
583+
)

testing/test_basic_api.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,9 @@ def assert_root(monkeypatch: pytest.MonkeyPatch, expected_root: str) -> None:
5555

5656
def assertion(config: Configuration) -> ScmVersion:
5757
assert config.absolute_root == expected_root
58-
return ScmVersion("1.0", config=config)
58+
from packaging.version import Version
59+
60+
return ScmVersion(Version("1.0"), config=config)
5961

6062
monkeypatch.setattr(setuptools_scm._get_version_impl, "parse_version", assertion)
6163

@@ -250,6 +252,18 @@ def __init__(self, tag_str: str) -> None:
250252
def __repr__(self) -> str:
251253
return f"hello,{self.version}"
252254

255+
@property
256+
def public(self) -> str:
257+
"""The public portion of the version (without local part)."""
258+
return self.version.split("+")[0]
259+
260+
@property
261+
def local(self) -> str | None:
262+
"""The local version segment."""
263+
if "+" in self.version:
264+
return self.version.split("+", 1)[1]
265+
return None
266+
253267
# you can not use normalize=False and version_cls at the same time
254268
with pytest.raises(
255269
ValueError,

testing/test_cli.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,6 @@ def test_cli_create_archival_file_stable(
9393
archival_file = wd.cwd / ".git_archival.txt"
9494
assert not archival_file.exists()
9595

96-
# Test successful creation
9796
result = main(["create-archival-file", "--stable"])
9897
assert result == 0
9998
assert archival_file.exists()
@@ -122,7 +121,6 @@ def test_cli_create_archival_file_full(
122121
archival_file = wd.cwd / ".git_archival.txt"
123122
assert not archival_file.exists()
124123

125-
# Test successful creation
126124
result = main(["create-archival-file", "--full"])
127125
assert result == 0
128126
assert archival_file.exists()

0 commit comments

Comments
 (0)