@@ -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:
132152class 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
204224def _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+
458548def 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+ )
0 commit comments