@@ -111,11 +111,31 @@ def tag_to_version(
111
111
version_str = tag_dict ["version" ]
112
112
log .debug ("version pre parse %s" , version_str )
113
113
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
116
126
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
119
139
120
140
return version
121
141
@@ -132,8 +152,8 @@ def _source_epoch_or_utc_now() -> datetime:
132
152
class ScmVersion :
133
153
"""represents a parsed version from scm"""
134
154
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"""
137
157
config : _config .Configuration
138
158
"""the configuration used to parse the version"""
139
159
distance : int = 0
@@ -203,9 +223,16 @@ def format_next_version(
203
223
204
224
def _parse_tag (
205
225
tag : _VersionT | str , preformatted : bool , config : _config .Configuration
206
- ) -> _VersionT | str :
226
+ ) -> _VersionT :
207
227
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
209
236
elif not isinstance (tag , config .version_cls ):
210
237
version = tag_to_version (tag , config )
211
238
assert version is not None
@@ -226,7 +253,16 @@ def meta(
226
253
node_date : date | None = None ,
227
254
time : datetime | None = None ,
228
255
) -> 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
+
230
266
log .info ("version %s -> %s" , tag , parsed_version )
231
267
assert parsed_version is not None , f"Can't parse version { tag } "
232
268
scm_version = ScmVersion (
@@ -455,20 +491,93 @@ def postrelease_version(version: ScmVersion) -> str:
455
491
return version .format_with ("{tag}.post{distance}" )
456
492
457
493
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
+
458
548
def format_version (version : ScmVersion ) -> str :
459
549
log .debug ("scm version %s" , version )
460
550
log .debug ("config %s" , version .config )
461
551
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 )
464
566
465
567
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 ,
467
571
)
468
572
log .debug ("version %s" , main_version )
469
573
assert main_version is not None
574
+
470
575
local_version = _entrypoints ._call_version_scheme (
471
576
version , "setuptools_scm.local_scheme" , version .config .local_scheme , "+unknown"
472
577
)
473
578
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
+ )
0 commit comments