utils.py 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174
  1. # This file is dual licensed under the terms of the Apache License, Version
  2. # 2.0, and the BSD License. See the LICENSE file in the root of this repository
  3. # for complete details.
  4. from __future__ import annotations
  5. import re
  6. from typing import NewType, Tuple, Union, cast
  7. from .tags import Tag, parse_tag
  8. from .version import InvalidVersion, Version
  9. BuildTag = Union[Tuple[()], Tuple[int, str]]
  10. NormalizedName = NewType("NormalizedName", str)
  11. class InvalidName(ValueError):
  12. """
  13. An invalid distribution name; users should refer to the packaging user guide.
  14. """
  15. class InvalidWheelFilename(ValueError):
  16. """
  17. An invalid wheel filename was found, users should refer to PEP 427.
  18. """
  19. class InvalidSdistFilename(ValueError):
  20. """
  21. An invalid sdist filename was found, users should refer to the packaging user guide.
  22. """
  23. # Core metadata spec for `Name`
  24. _validate_regex = re.compile(
  25. r"^([A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9])$", re.IGNORECASE
  26. )
  27. _canonicalize_regex = re.compile(r"[-_.]+")
  28. _normalized_regex = re.compile(r"^([a-z0-9]|[a-z0-9]([a-z0-9-](?!--))*[a-z0-9])$")
  29. # PEP 427: The build number must start with a digit.
  30. _build_tag_regex = re.compile(r"(\d+)(.*)")
  31. def canonicalize_name(name: str, *, validate: bool = False) -> NormalizedName:
  32. if validate and not _validate_regex.match(name):
  33. raise InvalidName(f"name is invalid: {name!r}")
  34. # This is taken from PEP 503.
  35. value = _canonicalize_regex.sub("-", name).lower()
  36. return cast(NormalizedName, value)
  37. def is_normalized_name(name: str) -> bool:
  38. return _normalized_regex.match(name) is not None
  39. def canonicalize_version(
  40. version: Version | str, *, strip_trailing_zero: bool = True
  41. ) -> str:
  42. """
  43. This is very similar to Version.__str__, but has one subtle difference
  44. with the way it handles the release segment.
  45. """
  46. if isinstance(version, str):
  47. try:
  48. parsed = Version(version)
  49. except InvalidVersion:
  50. # Legacy versions cannot be normalized
  51. return version
  52. else:
  53. parsed = version
  54. parts = []
  55. # Epoch
  56. if parsed.epoch != 0:
  57. parts.append(f"{parsed.epoch}!")
  58. # Release segment
  59. release_segment = ".".join(str(x) for x in parsed.release)
  60. if strip_trailing_zero:
  61. # NB: This strips trailing '.0's to normalize
  62. release_segment = re.sub(r"(\.0)+$", "", release_segment)
  63. parts.append(release_segment)
  64. # Pre-release
  65. if parsed.pre is not None:
  66. parts.append("".join(str(x) for x in parsed.pre))
  67. # Post-release
  68. if parsed.post is not None:
  69. parts.append(f".post{parsed.post}")
  70. # Development release
  71. if parsed.dev is not None:
  72. parts.append(f".dev{parsed.dev}")
  73. # Local version segment
  74. if parsed.local is not None:
  75. parts.append(f"+{parsed.local}")
  76. return "".join(parts)
  77. def parse_wheel_filename(
  78. filename: str,
  79. ) -> tuple[NormalizedName, Version, BuildTag, frozenset[Tag]]:
  80. if not filename.endswith(".whl"):
  81. raise InvalidWheelFilename(
  82. f"Invalid wheel filename (extension must be '.whl'): {filename}"
  83. )
  84. filename = filename[:-4]
  85. dashes = filename.count("-")
  86. if dashes not in (4, 5):
  87. raise InvalidWheelFilename(
  88. f"Invalid wheel filename (wrong number of parts): {filename}"
  89. )
  90. parts = filename.split("-", dashes - 2)
  91. name_part = parts[0]
  92. # See PEP 427 for the rules on escaping the project name.
  93. if "__" in name_part or re.match(r"^[\w\d._]*$", name_part, re.UNICODE) is None:
  94. raise InvalidWheelFilename(f"Invalid project name: {filename}")
  95. name = canonicalize_name(name_part)
  96. try:
  97. version = Version(parts[1])
  98. except InvalidVersion as e:
  99. raise InvalidWheelFilename(
  100. f"Invalid wheel filename (invalid version): {filename}"
  101. ) from e
  102. if dashes == 5:
  103. build_part = parts[2]
  104. build_match = _build_tag_regex.match(build_part)
  105. if build_match is None:
  106. raise InvalidWheelFilename(
  107. f"Invalid build number: {build_part} in '{filename}'"
  108. )
  109. build = cast(BuildTag, (int(build_match.group(1)), build_match.group(2)))
  110. else:
  111. build = ()
  112. tags = parse_tag(parts[-1])
  113. return (name, version, build, tags)
  114. def parse_sdist_filename(filename: str) -> tuple[NormalizedName, Version]:
  115. if filename.endswith(".tar.gz"):
  116. file_stem = filename[: -len(".tar.gz")]
  117. elif filename.endswith(".zip"):
  118. file_stem = filename[: -len(".zip")]
  119. else:
  120. raise InvalidSdistFilename(
  121. f"Invalid sdist filename (extension must be '.tar.gz' or '.zip'):"
  122. f" {filename}"
  123. )
  124. # We are requiring a PEP 440 version, which cannot contain dashes,
  125. # so we split on the last dash.
  126. name_part, sep, version_part = file_stem.rpartition("-")
  127. if not sep:
  128. raise InvalidSdistFilename(f"Invalid sdist filename: {filename}")
  129. name = canonicalize_name(name_part)
  130. try:
  131. version = Version(version_part)
  132. except InvalidVersion as e:
  133. raise InvalidSdistFilename(
  134. f"Invalid sdist filename (invalid version): {filename}"
  135. ) from e
  136. return (name, version)