base.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330
  1. import email.message
  2. import json
  3. import logging
  4. import re
  5. import zipfile
  6. from typing import (
  7. IO,
  8. TYPE_CHECKING,
  9. Collection,
  10. Container,
  11. Iterable,
  12. Iterator,
  13. List,
  14. Optional,
  15. Union,
  16. )
  17. from pip._vendor.packaging.requirements import Requirement
  18. from pip._vendor.packaging.specifiers import InvalidSpecifier, SpecifierSet
  19. from pip._vendor.packaging.utils import NormalizedName
  20. from pip._vendor.packaging.version import LegacyVersion, Version
  21. from pip._internal.models.direct_url import (
  22. DIRECT_URL_METADATA_NAME,
  23. DirectUrl,
  24. DirectUrlValidationError,
  25. )
  26. from pip._internal.utils.compat import stdlib_pkgs # TODO: Move definition here.
  27. from pip._internal.utils.egg_link import egg_link_path_from_sys_path
  28. from pip._internal.utils.urls import url_to_path
  29. if TYPE_CHECKING:
  30. from typing import Protocol
  31. else:
  32. Protocol = object
  33. DistributionVersion = Union[LegacyVersion, Version]
  34. logger = logging.getLogger(__name__)
  35. class BaseEntryPoint(Protocol):
  36. @property
  37. def name(self) -> str:
  38. raise NotImplementedError()
  39. @property
  40. def value(self) -> str:
  41. raise NotImplementedError()
  42. @property
  43. def group(self) -> str:
  44. raise NotImplementedError()
  45. class BaseDistribution(Protocol):
  46. def __repr__(self) -> str:
  47. return f"{self.raw_name} {self.version} ({self.location})"
  48. def __str__(self) -> str:
  49. return f"{self.raw_name} {self.version}"
  50. @property
  51. def location(self) -> Optional[str]:
  52. """Where the distribution is loaded from.
  53. A string value is not necessarily a filesystem path, since distributions
  54. can be loaded from other sources, e.g. arbitrary zip archives. ``None``
  55. means the distribution is created in-memory.
  56. Do not canonicalize this value with e.g. ``pathlib.Path.resolve()``. If
  57. this is a symbolic link, we want to preserve the relative path between
  58. it and files in the distribution.
  59. """
  60. raise NotImplementedError()
  61. @property
  62. def editable_project_location(self) -> Optional[str]:
  63. """The project location for editable distributions.
  64. This is the directory where pyproject.toml or setup.py is located.
  65. None if the distribution is not installed in editable mode.
  66. """
  67. # TODO: this property is relatively costly to compute, memoize it ?
  68. direct_url = self.direct_url
  69. if direct_url:
  70. if direct_url.is_local_editable():
  71. return url_to_path(direct_url.url)
  72. else:
  73. # Search for an .egg-link file by walking sys.path, as it was
  74. # done before by dist_is_editable().
  75. egg_link_path = egg_link_path_from_sys_path(self.raw_name)
  76. if egg_link_path:
  77. # TODO: get project location from second line of egg_link file
  78. # (https://github.com/pypa/pip/issues/10243)
  79. return self.location
  80. return None
  81. @property
  82. def info_directory(self) -> Optional[str]:
  83. """Location of the .[egg|dist]-info directory.
  84. Similarly to ``location``, a string value is not necessarily a
  85. filesystem path. ``None`` means the distribution is created in-memory.
  86. For a modern .dist-info installation on disk, this should be something
  87. like ``{location}/{raw_name}-{version}.dist-info``.
  88. Do not canonicalize this value with e.g. ``pathlib.Path.resolve()``. If
  89. this is a symbolic link, we want to preserve the relative path between
  90. it and other files in the distribution.
  91. """
  92. raise NotImplementedError()
  93. @property
  94. def canonical_name(self) -> NormalizedName:
  95. raise NotImplementedError()
  96. @property
  97. def version(self) -> DistributionVersion:
  98. raise NotImplementedError()
  99. @property
  100. def direct_url(self) -> Optional[DirectUrl]:
  101. """Obtain a DirectUrl from this distribution.
  102. Returns None if the distribution has no `direct_url.json` metadata,
  103. or if `direct_url.json` is invalid.
  104. """
  105. try:
  106. content = self.read_text(DIRECT_URL_METADATA_NAME)
  107. except FileNotFoundError:
  108. return None
  109. try:
  110. return DirectUrl.from_json(content)
  111. except (
  112. UnicodeDecodeError,
  113. json.JSONDecodeError,
  114. DirectUrlValidationError,
  115. ) as e:
  116. logger.warning(
  117. "Error parsing %s for %s: %s",
  118. DIRECT_URL_METADATA_NAME,
  119. self.canonical_name,
  120. e,
  121. )
  122. return None
  123. @property
  124. def installer(self) -> str:
  125. raise NotImplementedError()
  126. @property
  127. def editable(self) -> bool:
  128. return bool(self.editable_project_location)
  129. @property
  130. def local(self) -> bool:
  131. raise NotImplementedError()
  132. @property
  133. def in_usersite(self) -> bool:
  134. raise NotImplementedError()
  135. @property
  136. def in_site_packages(self) -> bool:
  137. raise NotImplementedError()
  138. def read_text(self, name: str) -> str:
  139. """Read a file in the .dist-info (or .egg-info) directory.
  140. Should raise ``FileNotFoundError`` if ``name`` does not exist in the
  141. metadata directory.
  142. """
  143. raise NotImplementedError()
  144. def iter_entry_points(self) -> Iterable[BaseEntryPoint]:
  145. raise NotImplementedError()
  146. @property
  147. def metadata(self) -> email.message.Message:
  148. """Metadata of distribution parsed from e.g. METADATA or PKG-INFO."""
  149. raise NotImplementedError()
  150. @property
  151. def metadata_version(self) -> Optional[str]:
  152. """Value of "Metadata-Version:" in distribution metadata, if available."""
  153. return self.metadata.get("Metadata-Version")
  154. @property
  155. def raw_name(self) -> str:
  156. """Value of "Name:" in distribution metadata."""
  157. # The metadata should NEVER be missing the Name: key, but if it somehow
  158. # does, fall back to the known canonical name.
  159. return self.metadata.get("Name", self.canonical_name)
  160. @property
  161. def requires_python(self) -> SpecifierSet:
  162. """Value of "Requires-Python:" in distribution metadata.
  163. If the key does not exist or contains an invalid value, an empty
  164. SpecifierSet should be returned.
  165. """
  166. value = self.metadata.get("Requires-Python")
  167. if value is None:
  168. return SpecifierSet()
  169. try:
  170. # Convert to str to satisfy the type checker; this can be a Header object.
  171. spec = SpecifierSet(str(value))
  172. except InvalidSpecifier as e:
  173. message = "Package %r has an invalid Requires-Python: %s"
  174. logger.warning(message, self.raw_name, e)
  175. return SpecifierSet()
  176. return spec
  177. def iter_dependencies(self, extras: Collection[str] = ()) -> Iterable[Requirement]:
  178. """Dependencies of this distribution.
  179. For modern .dist-info distributions, this is the collection of
  180. "Requires-Dist:" entries in distribution metadata.
  181. """
  182. raise NotImplementedError()
  183. def iter_provided_extras(self) -> Iterable[str]:
  184. """Extras provided by this distribution.
  185. For modern .dist-info distributions, this is the collection of
  186. "Provides-Extra:" entries in distribution metadata.
  187. """
  188. raise NotImplementedError()
  189. class BaseEnvironment:
  190. """An environment containing distributions to introspect."""
  191. @classmethod
  192. def default(cls) -> "BaseEnvironment":
  193. raise NotImplementedError()
  194. @classmethod
  195. def from_paths(cls, paths: Optional[List[str]]) -> "BaseEnvironment":
  196. raise NotImplementedError()
  197. def get_distribution(self, name: str) -> Optional["BaseDistribution"]:
  198. """Given a requirement name, return the installed distributions."""
  199. raise NotImplementedError()
  200. def _iter_distributions(self) -> Iterator["BaseDistribution"]:
  201. """Iterate through installed distributions.
  202. This function should be implemented by subclass, but never called
  203. directly. Use the public ``iter_distribution()`` instead, which
  204. implements additional logic to make sure the distributions are valid.
  205. """
  206. raise NotImplementedError()
  207. def iter_distributions(self) -> Iterator["BaseDistribution"]:
  208. """Iterate through installed distributions."""
  209. for dist in self._iter_distributions():
  210. # Make sure the distribution actually comes from a valid Python
  211. # packaging distribution. Pip's AdjacentTempDirectory leaves folders
  212. # e.g. ``~atplotlib.dist-info`` if cleanup was interrupted. The
  213. # valid project name pattern is taken from PEP 508.
  214. project_name_valid = re.match(
  215. r"^([A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9])$",
  216. dist.canonical_name,
  217. flags=re.IGNORECASE,
  218. )
  219. if not project_name_valid:
  220. logger.warning(
  221. "Ignoring invalid distribution %s (%s)",
  222. dist.canonical_name,
  223. dist.location,
  224. )
  225. continue
  226. yield dist
  227. def iter_installed_distributions(
  228. self,
  229. local_only: bool = True,
  230. skip: Container[str] = stdlib_pkgs,
  231. include_editables: bool = True,
  232. editables_only: bool = False,
  233. user_only: bool = False,
  234. ) -> Iterator[BaseDistribution]:
  235. """Return a list of installed distributions.
  236. :param local_only: If True (default), only return installations
  237. local to the current virtualenv, if in a virtualenv.
  238. :param skip: An iterable of canonicalized project names to ignore;
  239. defaults to ``stdlib_pkgs``.
  240. :param include_editables: If False, don't report editables.
  241. :param editables_only: If True, only report editables.
  242. :param user_only: If True, only report installations in the user
  243. site directory.
  244. """
  245. it = self.iter_distributions()
  246. if local_only:
  247. it = (d for d in it if d.local)
  248. if not include_editables:
  249. it = (d for d in it if not d.editable)
  250. if editables_only:
  251. it = (d for d in it if d.editable)
  252. if user_only:
  253. it = (d for d in it if d.in_usersite)
  254. return (d for d in it if d.canonical_name not in skip)
  255. class Wheel(Protocol):
  256. location: str
  257. def as_zipfile(self) -> zipfile.ZipFile:
  258. raise NotImplementedError()
  259. class FilesystemWheel(Wheel):
  260. def __init__(self, location: str) -> None:
  261. self.location = location
  262. def as_zipfile(self) -> zipfile.ZipFile:
  263. return zipfile.ZipFile(self.location, allowZip64=True)
  264. class MemoryWheel(Wheel):
  265. def __init__(self, location: str, stream: IO[bytes]) -> None:
  266. self.location = location
  267. self.stream = stream
  268. def as_zipfile(self) -> zipfile.ZipFile:
  269. return zipfile.ZipFile(self.stream, allowZip64=True)