@@ -59,18 +59,23 @@ def _open_context(self):
5959 self .contexts .append (_ctx (_context (self )["start" ]))
6060 return self .contexts [- 1 ]
6161
62- def _accept (self ):
62+ def _accept (self , key = None ):
6363 """Close the current ctx successfully and merge the results."""
6464 finished = self .contexts .pop ()
6565 self .contexts [- 1 ]["norm" ] += finished ["norm" ]
66+ if key :
67+ self .contexts [- 1 ][key ] = finished ["norm" ]
68+
6669 self .contexts [- 1 ]["start" ] = finished ["start" ]
6770 return True
6871
6972def _context (self ):
7073 return self .contexts [- 1 ]
7174
72- def _discard (self ):
75+ def _discard (self , key = None ):
7376 self .contexts .pop ()
77+ if key :
78+ self .contexts [- 1 ][key ] = ""
7479 return False
7580
7681def _new (input ):
@@ -313,9 +318,9 @@ def accept_epoch(parser):
313318 if accept_digits (parser ) and accept (parser , _is ("!" ), "!" ):
314319 if ctx ["norm" ] == "0!" :
315320 ctx ["norm" ] = ""
316- return parser .accept ()
321+ return parser .accept ("epoch" )
317322 else :
318- return parser .discard ()
323+ return parser .discard ("epoch" )
319324
320325def accept_release (parser ):
321326 """Accept the release segment, numbers separated by dots.
@@ -329,10 +334,10 @@ def accept_release(parser):
329334 parser .open_context ()
330335
331336 if not accept_digits (parser ):
332- return parser .discard ()
337+ return parser .discard ("release" )
333338
334339 accept_dot_number_sequence (parser )
335- return parser .accept ()
340+ return parser .accept ("release" )
336341
337342def accept_pre_l (parser ):
338343 """PEP 440: Pre-release spelling.
@@ -374,15 +379,15 @@ def accept_prerelease(parser):
374379 accept (parser , _in (["-" , "_" , "." ]), "" )
375380
376381 if not accept_pre_l (parser ):
377- return parser .discard ()
382+ return parser .discard ("pre" )
378383
379384 accept (parser , _in (["-" , "_" , "." ]), "" )
380385
381386 if not accept_digits (parser ):
382387 # PEP 440: Implicit pre-release number
383388 ctx ["norm" ] += "0"
384389
385- return parser .accept ()
390+ return parser .accept ("pre" )
386391
387392def accept_implicit_postrelease (parser ):
388393 """PEP 440: Implicit post releases.
@@ -444,9 +449,9 @@ def accept_postrelease(parser):
444449 parser .open_context ()
445450
446451 if accept_implicit_postrelease (parser ) or accept_explicit_postrelease (parser ):
447- return parser .accept ()
452+ return parser .accept ("post" )
448453
449- return parser .discard ()
454+ return parser .discard ("post" )
450455
451456def accept_devrelease (parser ):
452457 """PEP 440: Developmental releases.
@@ -470,9 +475,9 @@ def accept_devrelease(parser):
470475 # PEP 440: Implicit development release number
471476 ctx ["norm" ] += "0"
472477
473- return parser .accept ()
478+ return parser .accept ("dev" )
474479
475- return parser .discard ()
480+ return parser .discard ("dev" )
476481
477482def accept_local (parser ):
478483 """PEP 440: Local version identifiers.
@@ -487,9 +492,9 @@ def accept_local(parser):
487492
488493 if accept (parser , _is ("+" ), "+" ) and accept_alnum (parser ):
489494 accept_separator_alnum_sequence (parser )
490- return parser .accept ()
495+ return parser .accept ("local" )
491496
492- return parser .discard ()
497+ return parser .discard ("local" )
493498
494499def normalize_pep440 (version ):
495500 """Escape the version component of a filename.
@@ -518,63 +523,66 @@ def _parse(version_str, strict = True):
518523 Returns:
519524 string containing the normalized version.
520525 """
521- version_str = version_str .strip () # PEP 440: Leading and Trailing Whitespace
526+ version = version_str .strip () # PEP 440: Leading and Trailing Whitespace
522527 is_prefix = False
523528
524529 if not strict :
525- is_prefix = version_str .endswith (".*" )
526- version_str = version_str .strip (" .*" ) # PEP 440: Leading and Trailing Whitespace and ".*"
530+ is_prefix = version .endswith (".*" )
531+ version = version .strip (" .*" ) # PEP 440: Leading and Trailing Whitespace and ".*"
527532
528- parser = _new (version_str )
533+ parser = _new (version )
529534 accept (parser , _is ("v" ), "" ) # PEP 440: Preceding v character
530- fns = [
531- ("epoch" , accept_epoch ),
532- ("release" , accept_release ),
533- ("pre" , accept_prerelease ),
534- ("post" , accept_postrelease ),
535- ("dev" , accept_devrelease ),
536- ("local" , accept_local ),
537- ]
538- parts = {
539- "is_prefix" : is_prefix ,
540- }
541- for key , fn in fns :
542- start = len (parser .context ()["norm" ])
543- fn (parser )
544- parts [key ] = parser .context ()["norm" ][start :]
545- parts ["norm" ] = parser .context ()["norm" ]
546-
547- # TODO @aignas 2025-05-09: move the `is_prefix` handling to `accept_release`
548- if is_prefix and (parts ["local" ] or parts ["post" ] or parts ["dev" ] or parts ["pre" ]):
535+ accept_epoch (parser )
536+ accept_release (parser )
537+ accept_prerelease (parser )
538+ accept_postrelease (parser )
539+ accept_devrelease (parser )
540+ accept_local (parser )
541+
542+ parser_ctx = parser .context ()
543+ if is_prefix and (parser_ctx ["local" ] or parser_ctx ["post" ] or parser_ctx ["dev" ] or parser_ctx ["pre" ]):
549544 if strict :
550545 fail ("local version part has been obtained, but only public segments can have prefix matches" )
551546
552547 # https://peps.python.org/pep-0440/#public-version-identifiers
553548 return None
554549
555- if parser .input [parser . context () ["start" ]:]:
550+ if parser .input [parser_ctx ["start" ]:]:
556551 if strict :
557552 fail (
558553 "Failed to parse PEP 440 version identifier '%s'." % parser .input ,
559- "Parse error at '%s'" % parser .input [parser . context () ["start" ]:],
554+ "Parse error at '%s'" % parser .input [parser_ctx ["start" ]:],
560555 )
561556
562557 return None
563558
564- return parts
559+ parser_ctx ["is_prefix" ] = is_prefix
560+ return parser_ctx
565561
566562def parse (version_str , strict = False ):
567- """Parse a PEP4408 compliant version
563+ """Parse a PEP4408 compliant version.
568564
569- See https://packaging.python.org/en/latest/specifications/binary-distribution-format/#escaping-and-unicode
570- and https://peps.python.org/pep-0440/
565+ This is similar to `normalize_pep440`, but it parsers individual components to to
566+ comparable types.
571567
572568 Args:
573569 version_str: version string to be normalized according to PEP 440.
574570 strict: fail if the version is invalid.
575571
576572 Returns:
577- string containing the normalized version.
573+ a struct with individual components of a version:
574+ * `epoch` {type}`int`, defaults to `0`
575+ * `release` {type}`tuple[int]` an n-tuple of ints
576+ * `pre` {type}`tuple[str, int] | None` a tuple of a string and an int,
577+ e.g. ("a", 1)
578+ * `post` {type}`tuple[str, int] | None` a tuple of a string and an int,
579+ e.g. ("~", 1)
580+ * `dev` {type}`tuple[str, int] | None` a tuple of a string and an int,
581+ e.g. ("", 1)
582+ * `local` {type}`tuple[str, int] | None` a tuple of components in the local
583+ version, e.g. ("abc", 123).
584+ * `is_prefix` {type}`bool` whether the version_str ends with `.*`.
585+ * `string` {type}`str` normalized value of the input.
578586 """
579587
580588 parts = _parse (version_str , strict = strict )
@@ -715,8 +723,6 @@ def _version_compatible(left, right):
715723 else :
716724 right_star = "{}." .format (right_star )
717725
718- # TODO @aignas 2025-05-09: more tests:
719- # negative tests
720726 return _version_ge (left , right ) and left .string .startswith (right_star )
721727
722728def _version_ne (left , right ):
0 commit comments