Skip to content

Commit eab10c1

Browse files
committed
fix: don't duplicate if not needed
Signed-off-by: Henry Schreiner <[email protected]>
1 parent ab91cd8 commit eab10c1

File tree

2 files changed

+100
-83
lines changed

2 files changed

+100
-83
lines changed

src/packaging/version.py

Lines changed: 89 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,68 @@ def __ne__(self, other: object) -> bool:
181181
_LOCAL_PATTERN = re.compile(r"[a-z0-9]+(?:[._-][a-z0-9]+)*", re.IGNORECASE)
182182

183183

184+
def _validate_epoch(value: object, /) -> int:
185+
epoch = value or 0
186+
if isinstance(epoch, int) and epoch >= 0:
187+
return epoch
188+
msg = f"epoch must be non-negative integer, got {epoch}"
189+
raise InvalidVersion(msg)
190+
191+
192+
def _validate_release(value: object, /) -> tuple[int, ...]:
193+
release = (0,) if value is None else value
194+
if (
195+
isinstance(release, tuple)
196+
and len(release) > 0
197+
and all(isinstance(i, int) and i >= 0 for i in release)
198+
):
199+
return release
200+
msg = f"release must be a non-empty tuple of non-negative integers, got {release}"
201+
raise InvalidVersion(msg)
202+
203+
204+
def _validate_pre(value: object, /) -> tuple[Literal["a", "b", "rc"], int] | None:
205+
if value is None:
206+
return value
207+
if (
208+
isinstance(value, tuple)
209+
and len(value) == 2
210+
and value[0] in ("a", "b", "rc")
211+
and isinstance(value[1], int)
212+
and value[1] >= 0
213+
):
214+
return value
215+
msg = f"pre must be a tuple of ('a'|'b'|'rc', non-negative int), got {value}"
216+
raise InvalidVersion(msg)
217+
218+
219+
def _validate_post(value: object, /) -> tuple[Literal["post"], int] | None:
220+
if value is None:
221+
return value
222+
if isinstance(value, int) and value >= 0:
223+
return ("post", value)
224+
msg = f"post must be non-negative integer, got {value}"
225+
raise InvalidVersion(msg)
226+
227+
228+
def _validate_dev(value: object, /) -> tuple[Literal["dev"], int] | None:
229+
if value is None:
230+
return value
231+
if isinstance(value, int) and value >= 0:
232+
return ("dev", value)
233+
msg = f"dev must be non-negative integer, got {value}"
234+
raise InvalidVersion(msg)
235+
236+
237+
def _validate_local(value: object, /) -> LocalType | None:
238+
if value is None:
239+
return value
240+
if isinstance(value, str) and _LOCAL_PATTERN.fullmatch(value):
241+
return _parse_local_version(value)
242+
msg = f"local must be a valid version string, got {value!r}"
243+
raise InvalidVersion(msg)
244+
245+
184246
class Version(_BaseVersion):
185247
"""This class abstracts handling of a project's versions.
186248
@@ -245,91 +307,35 @@ def __init__(self, version: str) -> None:
245307
self._key_cache = None
246308

247309
def __replace__(self, **kwargs: Unpack[_VersionReplace]) -> Self:
310+
epoch = _validate_epoch(kwargs["epoch"]) if "epoch" in kwargs else self._epoch
311+
release = (
312+
_validate_release(kwargs["release"])
313+
if "release" in kwargs
314+
else self._release
315+
)
316+
pre = _validate_pre(kwargs["pre"]) if "pre" in kwargs else self._pre
317+
post = _validate_post(kwargs["post"]) if "post" in kwargs else self._post
318+
dev = _validate_dev(kwargs["dev"]) if "dev" in kwargs else self._dev
319+
local = _validate_local(kwargs["local"]) if "local" in kwargs else self._local
320+
321+
if (
322+
epoch == self._epoch
323+
and release == self._release
324+
and pre == self._pre
325+
and post == self._post
326+
and dev == self._dev
327+
and local == self._local
328+
):
329+
return self
330+
248331
new_version = self.__class__.__new__(self.__class__)
249332
new_version._key_cache = None
250-
if "epoch" in kwargs:
251-
epoch = kwargs["epoch"] or 0
252-
if isinstance(epoch, int) and epoch >= 0: # type: ignore[redundant-expr]
253-
new_version._epoch = epoch
254-
else:
255-
msg = f"epoch must be non-negative integer, got {epoch}"
256-
raise InvalidVersion(msg)
257-
else:
258-
new_version._epoch = self._epoch
259-
260-
if "release" in kwargs:
261-
release = (0,) if kwargs["release"] is None else kwargs["release"]
262-
if (
263-
isinstance(release, tuple) # type: ignore[redundant-expr]
264-
and len(release) > 0
265-
and all(isinstance(i, int) and i >= 0 for i in release) # type: ignore[redundant-expr]
266-
):
267-
new_version._release = release
268-
else:
269-
msg = (
270-
"release must be a non-empty tuple of non-negative integers,"
271-
f" got {release}"
272-
)
273-
raise InvalidVersion(msg)
274-
else:
275-
new_version._release = self._release
276-
277-
if "pre" in kwargs:
278-
pre = kwargs["pre"]
279-
if pre is None or (
280-
(
281-
isinstance(pre, tuple) # type: ignore[redundant-expr]
282-
and len(pre) == 2 # type: ignore[redundant-expr]
283-
and pre[0] in ("a", "b", "rc")
284-
and isinstance(pre[1], int)
285-
)
286-
and pre[1] >= 0
287-
):
288-
new_version._pre = pre
289-
else:
290-
msg = (
291-
"pre must be a tuple of ('a'|'b'|'rc', non-negative int),"
292-
f" got {pre}"
293-
)
294-
raise InvalidVersion(msg)
295-
else:
296-
new_version._pre = self._pre
297-
298-
if "post" in kwargs:
299-
post = kwargs["post"]
300-
if post is None:
301-
new_version._post = None
302-
elif isinstance(post, int) and post >= 0: # type: ignore[redundant-expr]
303-
new_version._post = ("post", post)
304-
else:
305-
msg = f"post must be non-negative integer, got {post}"
306-
raise InvalidVersion(msg)
307-
else:
308-
new_version._post = self._post
309-
310-
if "dev" in kwargs:
311-
dev = kwargs["dev"]
312-
if dev is None:
313-
new_version._dev = None
314-
elif isinstance(dev, int) and dev >= 0: # type: ignore[redundant-expr]
315-
new_version._dev = ("dev", dev)
316-
else:
317-
msg = f"dev must be non-negative integer, got {dev}"
318-
raise InvalidVersion(msg)
319-
else:
320-
new_version._dev = self._dev
321-
322-
if "local" in kwargs:
323-
local = kwargs["local"]
324-
if local is None:
325-
new_version._local = None
326-
elif isinstance(local, str) and _LOCAL_PATTERN.fullmatch(local): # type: ignore[redundant-expr]
327-
new_version._local = _parse_local_version(local)
328-
else:
329-
msg = f"local must be a valid version string, got {local!r}"
330-
raise InvalidVersion(msg)
331-
else:
332-
new_version._local = self._local
333+
new_version._epoch = epoch
334+
new_version._release = release
335+
new_version._pre = pre
336+
new_version._post = post
337+
new_version._dev = dev
338+
new_version._local = local
333339

334340
return new_version
335341

tests/test_version.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -902,6 +902,17 @@ def test_replace_preserves_hash(self) -> None:
902902
v3 = replace(v1, release=(2, 0, 0))
903903
assert hash(v1) != hash(v3)
904904

905+
def test_replace_returns_same_instance_when_unchanged(self) -> None:
906+
"""replace() returns the exact same object when no components change"""
907+
v = Version("1.2.3a1.post2.dev3+local")
908+
assert replace(v) is v
909+
assert replace(v, epoch=0) is v
910+
assert replace(v, release=(1, 2, 3)) is v
911+
assert replace(v, pre=("a", 1)) is v
912+
assert replace(v, post=2) is v
913+
assert replace(v, dev=3) is v
914+
assert replace(v, local="local") is v
915+
905916
def test_replace_change_pre_type(self) -> None:
906917
"""Can change from one pre-release type to another"""
907918
v = Version("1.2.3a1")

0 commit comments

Comments
 (0)