Skip to content

Commit 06f4dc7

Browse files
committed
feat: normalize replace & add from_parts
Signed-off-by: Henry Schreiner <henryfs@princeton.edu>
1 parent 120ce25 commit 06f4dc7

File tree

2 files changed

+67
-21
lines changed

2 files changed

+67
-21
lines changed

src/packaging/version.py

Lines changed: 66 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ def wrapper(*args: object, **kwargs: object) -> object:
6262
"r": "post",
6363
}
6464

65-
__all__ = ["VERSION_PATTERN", "InvalidVersion", "Version", "parse"]
65+
__all__ = ["VERSION_PATTERN", "InvalidVersion", "Version", "normalize_pre", "parse"]
6666

6767

6868
def __dir__() -> list[str]:
@@ -90,12 +90,29 @@ def __dir__() -> list[str]:
9090
class _VersionReplace(TypedDict, total=False):
9191
epoch: int | None
9292
release: tuple[int, ...] | None
93-
pre: tuple[Literal["a", "b", "rc"], int] | None
93+
pre: tuple[str, int] | None
9494
post: int | None
9595
dev: int | None
9696
local: str | None
9797

9898

99+
def normalize_pre(letter: str, /) -> str:
100+
"""Normalize the pre-release segment of a version string.
101+
102+
Returns a lowercase version of the string if not a known pre-release
103+
identifier.
104+
105+
>>> normalize_pre('alpha')
106+
'a'
107+
>>> normalize_pre('BETA')
108+
'b'
109+
>>> normalize_pre('rc')
110+
'rc'
111+
"""
112+
letter = letter.lower()
113+
return _LETTER_NORMALIZATION.get(letter, letter)
114+
115+
99116
def parse(version: str) -> Version:
100117
"""Parse the given version string.
101118
@@ -263,14 +280,12 @@ def _validate_release(value: object, /) -> tuple[int, ...]:
263280
def _validate_pre(value: object, /) -> tuple[Literal["a", "b", "rc"], int] | None:
264281
if value is None:
265282
return value
266-
if (
267-
isinstance(value, tuple)
268-
and len(value) == 2
269-
and value[0] in ("a", "b", "rc")
270-
and isinstance(value[1], int)
271-
and value[1] >= 0
272-
):
273-
return value
283+
if isinstance(value, tuple) and len(value) == 2:
284+
letter, number = value
285+
letter = normalize_pre(letter)
286+
if letter in {"a", "b", "rc"} and isinstance(number, int) and number >= 0:
287+
# type checkers can't infer the Literal type here on letter
288+
return (letter, number) # type: ignore[return-value]
274289
msg = f"pre must be a tuple of ('a'|'b'|'rc', non-negative int), got {value}"
275290
raise InvalidVersion(msg)
276291

@@ -306,9 +321,9 @@ def _validate_local(value: object, /) -> LocalType | None:
306321
class _Version(NamedTuple):
307322
epoch: int
308323
release: tuple[int, ...]
309-
dev: tuple[str, int] | None
310-
pre: tuple[str, int] | None
311-
post: tuple[str, int] | None
324+
dev: tuple[Literal["dev"], int] | None
325+
pre: tuple[Literal["a", "b", "rc"], int] | None
326+
post: tuple[Literal["post"], int] | None
312327
local: LocalType | None
313328

314329

@@ -343,9 +358,9 @@ class Version(_BaseVersion):
343358

344359
_epoch: int
345360
_release: tuple[int, ...]
346-
_dev: tuple[str, int] | None
347-
_pre: tuple[str, int] | None
348-
_post: tuple[str, int] | None
361+
_dev: tuple[Literal["dev"], int] | None
362+
_pre: tuple[Literal["a", "b", "rc"], int] | None
363+
_post: tuple[Literal["post"], int] | None
349364
_local: LocalType | None
350365

351366
_key_cache: CmpKey | None
@@ -366,16 +381,47 @@ def __init__(self, version: str) -> None:
366381
raise InvalidVersion(f"Invalid version: {version!r}")
367382
self._epoch = int(match.group("epoch")) if match.group("epoch") else 0
368383
self._release = tuple(map(int, match.group("release").split(".")))
369-
self._pre = _parse_letter_version(match.group("pre_l"), match.group("pre_n"))
370-
self._post = _parse_letter_version(
384+
# We can type ignore the assignments below because the regex guarantees
385+
# the correct strings
386+
self._pre = _parse_letter_version(match.group("pre_l"), match.group("pre_n")) # type: ignore[assignment]
387+
self._post = _parse_letter_version( # type: ignore[assignment]
371388
match.group("post_l"), match.group("post_n1") or match.group("post_n2")
372389
)
373-
self._dev = _parse_letter_version(match.group("dev_l"), match.group("dev_n"))
390+
self._dev = _parse_letter_version(match.group("dev_l"), match.group("dev_n")) # type: ignore[assignment]
374391
self._local = _parse_local_version(match.group("local"))
375392

376393
# Key which will be used for sorting
377394
self._key_cache = None
378395

396+
@classmethod
397+
def from_parts(
398+
cls,
399+
*,
400+
epoch: int = 0,
401+
release: tuple[int, ...],
402+
pre: tuple[str, int] | None = None,
403+
post: int | None = None,
404+
dev: int | None = None,
405+
local: str | None = None,
406+
) -> Self:
407+
_epoch = _validate_epoch(epoch)
408+
_release = _validate_release(release)
409+
_pre = _validate_pre(pre) if pre is not None else None
410+
_post = _validate_post(post) if post is not None else None
411+
_dev = _validate_dev(dev) if dev is not None else None
412+
_local = _validate_local(local) if local is not None else None
413+
414+
new_version = cls.__new__(cls)
415+
new_version._key_cache = None
416+
new_version._epoch = _epoch
417+
new_version._release = _release
418+
new_version._pre = _pre
419+
new_version._post = _post
420+
new_version._dev = _dev
421+
new_version._local = _local
422+
423+
return new_version
424+
379425
def __replace__(self, **kwargs: Unpack[_VersionReplace]) -> Self:
380426
epoch = _validate_epoch(kwargs["epoch"]) if "epoch" in kwargs else self._epoch
381427
release = (
@@ -512,7 +558,7 @@ def release(self) -> tuple[int, ...]:
512558
return self._release
513559

514560
@property
515-
def pre(self) -> tuple[str, int] | None:
561+
def pre(self) -> tuple[Literal["a", "b", "rc"], int] | None:
516562
"""The pre-release segment of the version.
517563
518564
>>> print(Version("1.2.3").pre)

tests/test_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -966,7 +966,7 @@ def test_replace_invalid_pre_negative(self) -> None:
966966
def test_replace_invalid_pre_type(self) -> None:
967967
v = Version("1.2.3")
968968
with pytest.raises(InvalidVersion, match="pre must be a tuple"):
969-
replace(v, pre=("x", 1)) # type: ignore[arg-type]
969+
replace(v, pre=("x", 1))
970970

971971
def test_replace_invalid_pre_format(self) -> None:
972972
v = Version("1.2.3")

0 commit comments

Comments
 (0)