utils.py 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158
  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, _TrimmedRelease
  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(r"[A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9]", re.IGNORECASE)
  25. _normalized_regex = re.compile(r"[a-z0-9]|[a-z0-9]([a-z0-9-](?!--))*[a-z0-9]")
  26. # PEP 427: The build number must start with a digit.
  27. _build_tag_regex = re.compile(r"(\d+)(.*)")
  28. def canonicalize_name(name: str, *, validate: bool = False) -> NormalizedName:
  29. if validate and not _validate_regex.fullmatch(name):
  30. raise InvalidName(f"name is invalid: {name!r}")
  31. # Ensure all ``.`` and ``_`` are ``-``
  32. # Emulates ``re.sub(r"[-_.]+", "-", name).lower()`` from PEP 503
  33. # Much faster than re, and even faster than str.translate
  34. value = name.lower().replace("_", "-").replace(".", "-")
  35. # Condense repeats (faster than regex)
  36. while "--" in value:
  37. value = value.replace("--", "-")
  38. return cast("NormalizedName", value)
  39. def is_normalized_name(name: str) -> bool:
  40. return _normalized_regex.fullmatch(name) is not None
  41. def canonicalize_version(
  42. version: Version | str, *, strip_trailing_zero: bool = True
  43. ) -> str:
  44. """
  45. Return a canonical form of a version as a string.
  46. >>> canonicalize_version('1.0.1')
  47. '1.0.1'
  48. Per PEP 625, versions may have multiple canonical forms, differing
  49. only by trailing zeros.
  50. >>> canonicalize_version('1.0.0')
  51. '1'
  52. >>> canonicalize_version('1.0.0', strip_trailing_zero=False)
  53. '1.0.0'
  54. Invalid versions are returned unaltered.
  55. >>> canonicalize_version('foo bar baz')
  56. 'foo bar baz'
  57. """
  58. if isinstance(version, str):
  59. try:
  60. version = Version(version)
  61. except InvalidVersion:
  62. return str(version)
  63. return str(_TrimmedRelease(version) if strip_trailing_zero else version)
  64. def parse_wheel_filename(
  65. filename: str,
  66. ) -> tuple[NormalizedName, Version, BuildTag, frozenset[Tag]]:
  67. if not filename.endswith(".whl"):
  68. raise InvalidWheelFilename(
  69. f"Invalid wheel filename (extension must be '.whl'): {filename!r}"
  70. )
  71. filename = filename[:-4]
  72. dashes = filename.count("-")
  73. if dashes not in (4, 5):
  74. raise InvalidWheelFilename(
  75. f"Invalid wheel filename (wrong number of parts): {filename!r}"
  76. )
  77. parts = filename.split("-", dashes - 2)
  78. name_part = parts[0]
  79. # See PEP 427 for the rules on escaping the project name.
  80. if "__" in name_part or re.match(r"^[\w\d._]*$", name_part, re.UNICODE) is None:
  81. raise InvalidWheelFilename(f"Invalid project name: {filename!r}")
  82. name = canonicalize_name(name_part)
  83. try:
  84. version = Version(parts[1])
  85. except InvalidVersion as e:
  86. raise InvalidWheelFilename(
  87. f"Invalid wheel filename (invalid version): {filename!r}"
  88. ) from e
  89. if dashes == 5:
  90. build_part = parts[2]
  91. build_match = _build_tag_regex.match(build_part)
  92. if build_match is None:
  93. raise InvalidWheelFilename(
  94. f"Invalid build number: {build_part} in {filename!r}"
  95. )
  96. build = cast("BuildTag", (int(build_match.group(1)), build_match.group(2)))
  97. else:
  98. build = ()
  99. tags = parse_tag(parts[-1])
  100. return (name, version, build, tags)
  101. def parse_sdist_filename(filename: str) -> tuple[NormalizedName, Version]:
  102. if filename.endswith(".tar.gz"):
  103. file_stem = filename[: -len(".tar.gz")]
  104. elif filename.endswith(".zip"):
  105. file_stem = filename[: -len(".zip")]
  106. else:
  107. raise InvalidSdistFilename(
  108. f"Invalid sdist filename (extension must be '.tar.gz' or '.zip'):"
  109. f" {filename!r}"
  110. )
  111. # We are requiring a PEP 440 version, which cannot contain dashes,
  112. # so we split on the last dash.
  113. name_part, sep, version_part = file_stem.rpartition("-")
  114. if not sep:
  115. raise InvalidSdistFilename(f"Invalid sdist filename: {filename!r}")
  116. name = canonicalize_name(name_part)
  117. try:
  118. version = Version(version_part)
  119. except InvalidVersion as e:
  120. raise InvalidSdistFilename(
  121. f"Invalid sdist filename (invalid version): {filename!r}"
  122. ) from e
  123. return (name, version)