Skip to content

Commit 82587cc

Browse files
first working iteration on build tag passover from scm tags to version strings
addresses #1019 needs more iteration
1 parent 0b59018 commit 82587cc

File tree

3 files changed

+351
-6
lines changed

3 files changed

+351
-6
lines changed

src/setuptools_scm/version.py

Lines changed: 113 additions & 6 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

@@ -455,20 +475,107 @@ def postrelease_version(version: ScmVersion) -> str:
455475
return version.format_with("{tag}.post{distance}")
456476

457477

478+
def _combine_version_with_local_parts(
479+
main_version: str, *local_parts: str | None
480+
) -> str:
481+
"""
482+
Combine a main version with multiple local parts into a valid PEP 440 version string.
483+
Handles deduplication of local parts to avoid adding the same local data twice.
484+
485+
Args:
486+
main_version: The main version string (e.g., "1.2.0", "1.2.dev3")
487+
*local_parts: Variable number of local version parts, can be None or empty
488+
489+
Returns:
490+
A valid PEP 440 version string
491+
492+
Examples:
493+
_combine_version_with_local_parts("1.2.0", "build.123", "d20090213") -> "1.2.0+build.123.d20090213"
494+
_combine_version_with_local_parts("1.2.0", "build.123", None) -> "1.2.0+build.123"
495+
_combine_version_with_local_parts("1.2.0+build.123", "d20090213") -> "1.2.0+build.123.d20090213"
496+
_combine_version_with_local_parts("1.2.0+build.123", "build.123") -> "1.2.0+build.123" # no duplication
497+
_combine_version_with_local_parts("1.2.0", None, None) -> "1.2.0"
498+
"""
499+
# Split main version into base and existing local parts
500+
if "+" in main_version:
501+
main_part, existing_local = main_version.split("+", 1)
502+
all_local_parts = existing_local.split(".")
503+
else:
504+
main_part = main_version
505+
all_local_parts = []
506+
507+
# Process each new local part
508+
for part in local_parts:
509+
if not part or not part.strip():
510+
continue
511+
512+
# Strip any leading + and split into segments
513+
clean_part = part.strip("+")
514+
if not clean_part:
515+
continue
516+
517+
# Split multi-part local identifiers (e.g., "build.123" -> ["build", "123"])
518+
part_segments = clean_part.split(".")
519+
520+
# Add each segment if not already present
521+
for segment in part_segments:
522+
if segment and segment not in all_local_parts:
523+
all_local_parts.append(segment)
524+
525+
# Return combined result
526+
if all_local_parts:
527+
return main_part + "+" + ".".join(all_local_parts)
528+
else:
529+
return main_part
530+
531+
458532
def format_version(version: ScmVersion) -> str:
459533
log.debug("scm version %s", version)
460534
log.debug("config %s", version.config)
461535
if version.preformatted:
462536
assert isinstance(version.tag, str)
463537
return version.tag
464538

539+
# Extract original tag's local data for later combination
540+
original_local = ""
541+
if hasattr(version.tag, "local") and version.tag.local is not None:
542+
original_local = str(version.tag.local)
543+
544+
# Create a patched ScmVersion with only the base version (no local data) for version schemes
545+
from dataclasses import replace
546+
547+
if version.tag:
548+
# Extract the base version (public part) from the tag using config's version_cls
549+
if hasattr(version.tag, "public"):
550+
# It's a Version object with a public attribute
551+
base_version_str = str(version.tag.public)
552+
elif isinstance(version.tag, str):
553+
# It's a string - strip any local part
554+
base_version_str = version.tag.split("+")[0]
555+
else:
556+
# It's some other type - use string representation and strip local part
557+
base_version_str = str(version.tag).split("+")[0]
558+
559+
# Create the base tag using the config's version class
560+
base_tag = version.config.version_cls(base_version_str)
561+
version_for_scheme = replace(version, tag=base_tag)
562+
else:
563+
version_for_scheme = version
564+
465565
main_version = _entrypoints._call_version_scheme(
466-
version, "setuptools_scm.version_scheme", version.config.version_scheme
566+
version_for_scheme,
567+
"setuptools_scm.version_scheme",
568+
version.config.version_scheme,
467569
)
468570
log.debug("version %s", main_version)
469571
assert main_version is not None
572+
470573
local_version = _entrypoints._call_version_scheme(
471574
version, "setuptools_scm.local_scheme", version.config.local_scheme, "+unknown"
472575
)
473576
log.debug("local_version %s", local_version)
474-
return main_version + local_version
577+
578+
# Combine main version with original local data and new local scheme data
579+
return _combine_version_with_local_parts(
580+
str(main_version), original_local, local_version
581+
)

testing/test_functions.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,18 @@ def test_next_tag(tag: str, expected: str) -> None:
4747
"distance-dirty": meta("1.1", distance=3, dirty=True, config=c),
4848
}
4949

50+
# Versions with build metadata in the tag
51+
VERSIONS_WITH_BUILD_METADATA = {
52+
"exact-build": meta("1.1+build.123", distance=0, dirty=False, config=c),
53+
"dirty-build": meta("1.1+build.123", distance=0, dirty=True, config=c),
54+
"distance-clean-build": meta("1.1+build.123", distance=3, dirty=False, config=c),
55+
"distance-dirty-build": meta("1.1+build.123", distance=3, dirty=True, config=c),
56+
"exact-ci": meta("2.0.0+ci.456", distance=0, dirty=False, config=c),
57+
"dirty-ci": meta("2.0.0+ci.456", distance=0, dirty=True, config=c),
58+
"distance-clean-ci": meta("2.0.0+ci.456", distance=2, dirty=False, config=c),
59+
"distance-dirty-ci": meta("2.0.0+ci.456", distance=2, dirty=True, config=c),
60+
}
61+
5062

5163
@pytest.mark.parametrize(
5264
("version", "version_scheme", "local_scheme", "expected"),
@@ -77,6 +89,96 @@ def test_format_version(
7789
assert format_version(configured_version) == expected
7890

7991

92+
@pytest.mark.parametrize(
93+
("version", "version_scheme", "local_scheme", "expected"),
94+
[
95+
# Exact matches should preserve build metadata from tag
96+
("exact-build", "guess-next-dev", "node-and-date", "1.1+build.123"),
97+
("exact-build", "guess-next-dev", "no-local-version", "1.1+build.123"),
98+
("exact-ci", "guess-next-dev", "node-and-date", "2.0.0+ci.456"),
99+
("exact-ci", "guess-next-dev", "no-local-version", "2.0.0+ci.456"),
100+
# Dirty exact matches - version scheme treats dirty as non-exact, build metadata preserved
101+
(
102+
"dirty-build",
103+
"guess-next-dev",
104+
"node-and-date",
105+
"1.2.dev0+build.123.d20090213",
106+
),
107+
("dirty-build", "guess-next-dev", "no-local-version", "1.2.dev0+build.123"),
108+
("dirty-ci", "guess-next-dev", "node-and-date", "2.0.1.dev0+ci.456.d20090213"),
109+
# Distance cases - build metadata should be preserved and combined with SCM data
110+
(
111+
"distance-clean-build",
112+
"guess-next-dev",
113+
"node-and-date",
114+
"1.2.dev3+build.123",
115+
),
116+
(
117+
"distance-clean-build",
118+
"guess-next-dev",
119+
"no-local-version",
120+
"1.2.dev3+build.123",
121+
),
122+
("distance-clean-ci", "guess-next-dev", "node-and-date", "2.0.1.dev2+ci.456"),
123+
# Distance + dirty cases - build metadata should be preserved and combined with SCM data
124+
(
125+
"distance-dirty-build",
126+
"guess-next-dev",
127+
"node-and-date",
128+
"1.2.dev3+build.123.d20090213",
129+
),
130+
(
131+
"distance-dirty-ci",
132+
"guess-next-dev",
133+
"node-and-date",
134+
"2.0.1.dev2+ci.456.d20090213",
135+
),
136+
# Post-release scheme tests
137+
("exact-build", "post-release", "node-and-date", "1.1+build.123"),
138+
(
139+
"dirty-build",
140+
"post-release",
141+
"node-and-date",
142+
"1.1.post0+build.123.d20090213",
143+
),
144+
(
145+
"distance-clean-build",
146+
"post-release",
147+
"node-and-date",
148+
"1.1.post3+build.123",
149+
),
150+
(
151+
"distance-dirty-build",
152+
"post-release",
153+
"node-and-date",
154+
"1.1.post3+build.123.d20090213",
155+
),
156+
],
157+
)
158+
def test_format_version_with_build_metadata(
159+
version: str, version_scheme: str, local_scheme: str, expected: str
160+
) -> None:
161+
"""Test format_version with tags that contain build metadata."""
162+
from dataclasses import replace
163+
164+
from packaging.version import Version
165+
166+
scm_version = VERSIONS_WITH_BUILD_METADATA[version]
167+
configured_version = replace(
168+
scm_version,
169+
config=replace(
170+
scm_version.config, version_scheme=version_scheme, local_scheme=local_scheme
171+
),
172+
)
173+
result = format_version(configured_version)
174+
175+
# Validate result is a valid PEP 440 version
176+
parsed = Version(result)
177+
assert str(parsed) == result, f"Result should be valid PEP 440: {result}"
178+
179+
assert result == expected, f"Expected {expected}, got {result}"
180+
181+
80182
def test_dump_version_doesnt_bail_on_value_error(tmp_path: Path) -> None:
81183
write_to = "VERSION"
82184
version = str(VERSIONS["exact"].tag)

0 commit comments

Comments
 (0)