@@ -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
@@ -455,20 +475,107 @@ def postrelease_version(version: ScmVersion) -> str:
455
475
return version .format_with ("{tag}.post{distance}" )
456
476
457
477
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
+
458
532
def format_version (version : ScmVersion ) -> str :
459
533
log .debug ("scm version %s" , version )
460
534
log .debug ("config %s" , version .config )
461
535
if version .preformatted :
462
536
assert isinstance (version .tag , str )
463
537
return version .tag
464
538
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
+
465
565
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 ,
467
569
)
468
570
log .debug ("version %s" , main_version )
469
571
assert main_version is not None
572
+
470
573
local_version = _entrypoints ._call_version_scheme (
471
574
version , "setuptools_scm.local_scheme" , version .config .local_scheme , "+unknown"
472
575
)
473
576
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
+ )
0 commit comments