dependency_groups.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335
  1. from __future__ import annotations
  2. import re
  3. from collections.abc import Mapping, Sequence
  4. from .errors import _ErrorCollector
  5. from .requirements import InvalidRequirement, Requirement
  6. __all__ = [
  7. "CyclicDependencyGroup",
  8. "DependencyGroupInclude",
  9. "DependencyGroupResolver",
  10. "DuplicateGroupNames",
  11. "InvalidDependencyGroupObject",
  12. "resolve_dependency_groups",
  13. ]
  14. def __dir__() -> list[str]:
  15. return __all__
  16. # -----------
  17. # Error Types
  18. # -----------
  19. class DuplicateGroupNames(ValueError):
  20. """
  21. The same dependency groups were defined twice, with different non-normalized names.
  22. .. versionadded:: 26.1
  23. """
  24. class CyclicDependencyGroup(ValueError):
  25. """
  26. The dependency group includes form a cycle.
  27. .. versionadded:: 26.1
  28. """
  29. def __init__(self, requested_group: str, group: str, include_group: str) -> None:
  30. self.requested_group = requested_group
  31. self.group = group
  32. self.include_group = include_group
  33. if include_group == group:
  34. reason = f"{group} includes itself"
  35. else:
  36. reason = f"{include_group} -> {group}, {group} -> {include_group}"
  37. super().__init__(
  38. "Cyclic dependency group include while resolving "
  39. f"{requested_group}: {reason}"
  40. )
  41. # Support pickling; ``args`` does not match ``__init__``'s signature.
  42. def __reduce__(self) -> tuple[type[CyclicDependencyGroup], tuple[str, str, str]]:
  43. return (self.__class__, (self.requested_group, self.group, self.include_group))
  44. # in the PEP 735 spec, the tables in dependency group lists were described as
  45. # "Dependency Object Specifiers", but the only defined type of object was a
  46. # "Dependency Group Include" -- hence the naming of this error as "Object"
  47. class InvalidDependencyGroupObject(ValueError):
  48. """
  49. A member of a dependency group was identified as a dict, but was not in a valid
  50. format.
  51. .. versionadded:: 26.1
  52. """
  53. # ------------------------
  54. # Object Model & Interface
  55. # ------------------------
  56. class DependencyGroupInclude:
  57. """
  58. A reference to another dependency group by name.
  59. .. versionadded:: 26.1
  60. """
  61. __slots__ = ("include_group",)
  62. def __init__(self, include_group: str) -> None:
  63. """
  64. Initialize a DependencyGroupInclude.
  65. :param include_group: The name of the group referred to by this include.
  66. """
  67. self.include_group = include_group
  68. def __repr__(self) -> str:
  69. return f"{self.__class__.__name__}({self.include_group!r})"
  70. class DependencyGroupResolver:
  71. """
  72. A resolver for Dependency Group data.
  73. This class handles caching, name normalization, cycle detection, and other
  74. parsing requirements. There are only two public methods for exploring the data:
  75. ``lookup()`` and ``resolve()``.
  76. :param dependency_groups: A mapping, as provided via pyproject
  77. ``[dependency-groups]``.
  78. .. versionadded:: 26.1
  79. """
  80. def __init__(
  81. self,
  82. dependency_groups: Mapping[str, Sequence[str | Mapping[str, str]]],
  83. ) -> None:
  84. errors = _ErrorCollector()
  85. self.dependency_groups = _normalize_group_names(dependency_groups, errors)
  86. # a map of group names to parsed data
  87. self._parsed_groups: dict[
  88. str, tuple[Requirement | DependencyGroupInclude, ...]
  89. ] = {}
  90. # a map of group names to their ancestors, used for cycle detection
  91. self._include_graph_ancestors: dict[str, tuple[str, ...]] = {}
  92. # a cache of completed resolutions to Requirement lists
  93. self._resolve_cache: dict[str, tuple[Requirement, ...]] = {}
  94. errors.finalize("[dependency-groups] data was invalid")
  95. def lookup(self, group: str) -> tuple[Requirement | DependencyGroupInclude, ...]:
  96. """
  97. Lookup a group name, returning the parsed dependency data for that group.
  98. This will not resolve includes.
  99. :param group: the name of the group to lookup
  100. """
  101. group = _normalize_name(group)
  102. with _ErrorCollector().on_exit(
  103. f"[dependency-groups] data for {group!r} was malformed"
  104. ) as errors:
  105. return self._parse_group(group, errors)
  106. def resolve(self, group: str) -> tuple[Requirement, ...]:
  107. """
  108. Resolve a dependency group to a list of requirements.
  109. :param group: the name of the group to resolve
  110. """
  111. group = _normalize_name(group)
  112. with _ErrorCollector().on_exit(
  113. f"[dependency-groups] data for {group!r} was malformed"
  114. ) as errors:
  115. return self._resolve(group, group, errors)
  116. def _resolve(
  117. self, group: str, requested_group: str, errors: _ErrorCollector
  118. ) -> tuple[Requirement, ...]:
  119. """
  120. This is a helper for cached resolution to strings. It preserves the name of the
  121. group which the user initially requested in order to present a clearer error in
  122. the event that a cycle is detected.
  123. :param group: The normalized name of the group to resolve.
  124. :param requested_group: The group which was used in the original, user-facing
  125. request.
  126. """
  127. if group in self._resolve_cache:
  128. return self._resolve_cache[group]
  129. parsed = self._parse_group(group, errors)
  130. resolved_group = []
  131. for item in parsed:
  132. if isinstance(item, Requirement):
  133. resolved_group.append(item)
  134. elif isinstance(item, DependencyGroupInclude):
  135. include_group = _normalize_name(item.include_group)
  136. # if a group is cyclic, record the error
  137. # otherwise, follow the include_group reference
  138. #
  139. # this allows us to examine all includes in a group, even in the
  140. # presence of errors
  141. if include_group in self._include_graph_ancestors.get(group, ()):
  142. errors.error(
  143. CyclicDependencyGroup(
  144. requested_group, group, item.include_group
  145. )
  146. )
  147. else:
  148. self._include_graph_ancestors[include_group] = (
  149. *self._include_graph_ancestors.get(group, ()),
  150. group,
  151. )
  152. resolved_group.extend(
  153. self._resolve(include_group, requested_group, errors)
  154. )
  155. else: # pragma: no cover
  156. raise NotImplementedError(
  157. f"Invalid dependency group item after parse: {item}"
  158. )
  159. # in the event that errors were detected, present the group as empty and do not
  160. # cache the result
  161. # this ensures that repeated access to a cyclic group will raise multiple errors
  162. if errors.errors:
  163. return ()
  164. self._resolve_cache[group] = tuple(resolved_group)
  165. return self._resolve_cache[group]
  166. def _parse_group(
  167. self, group: str, errors: _ErrorCollector
  168. ) -> tuple[Requirement | DependencyGroupInclude, ...]:
  169. # short circuit -- never do the work twice
  170. if group in self._parsed_groups:
  171. return self._parsed_groups[group]
  172. if group not in self.dependency_groups:
  173. errors.error(LookupError(f"Dependency group '{group}' not found"))
  174. return ()
  175. raw_group = self.dependency_groups[group]
  176. if isinstance(raw_group, str):
  177. errors.error(
  178. TypeError(
  179. f"Dependency group {group!r} contained a string rather than a list."
  180. )
  181. )
  182. return ()
  183. if not isinstance(raw_group, Sequence):
  184. errors.error(
  185. TypeError(f"Dependency group {group!r} is not a sequence type.")
  186. )
  187. return ()
  188. elements: list[Requirement | DependencyGroupInclude] = []
  189. for item in raw_group:
  190. if isinstance(item, str):
  191. # packaging.requirements.Requirement parsing ensures that this is a
  192. # valid PEP 508 Dependency Specifier. Collect InvalidRequirement
  193. # if it throws that.
  194. with errors.collect(InvalidRequirement):
  195. elements.append(Requirement(item))
  196. elif isinstance(item, Mapping):
  197. if tuple(item.keys()) != ("include-group",):
  198. errors.error(
  199. InvalidDependencyGroupObject(
  200. f"Invalid dependency group item: {item!r}"
  201. )
  202. )
  203. else:
  204. include_group = item["include-group"]
  205. if not isinstance(include_group, str):
  206. msg = (
  207. "Dependency group include-group value is not a string: "
  208. f"{item!r}"
  209. )
  210. errors.error(TypeError(msg))
  211. else:
  212. elements.append(
  213. DependencyGroupInclude(include_group=include_group)
  214. )
  215. else:
  216. errors.error(TypeError(f"Invalid dependency group item: {item!r}"))
  217. if errors.errors:
  218. return ()
  219. self._parsed_groups[group] = tuple(elements)
  220. return self._parsed_groups[group]
  221. # --------------------
  222. # Functional Interface
  223. # --------------------
  224. def resolve_dependency_groups(
  225. dependency_groups: Mapping[str, Sequence[str | Mapping[str, str]]], /, *groups: str
  226. ) -> tuple[str, ...]:
  227. """
  228. Resolve a dependency group to a tuple of requirements, as strings.
  229. :param dependency_groups: the parsed contents of the ``[dependency-groups]`` table
  230. from ``pyproject.toml``
  231. :param groups: the name of the group(s) to resolve
  232. .. versionadded:: 26.1
  233. """
  234. resolver = DependencyGroupResolver(dependency_groups)
  235. return tuple(str(r) for group in groups for r in resolver.resolve(group))
  236. # ----------------
  237. # internal helpers
  238. # ----------------
  239. _NORMALIZE_PATTERN = re.compile(r"[-_.]+")
  240. def _normalize_name(name: str) -> str:
  241. return _NORMALIZE_PATTERN.sub("-", name).lower()
  242. def _normalize_group_names(
  243. dependency_groups: Mapping[str, Sequence[str | Mapping[str, str]]],
  244. errors: _ErrorCollector,
  245. ) -> dict[str, Sequence[str | Mapping[str, str]]]:
  246. original_names: dict[str, list[str]] = {}
  247. normalized_groups: dict[str, Sequence[str | Mapping[str, str]]] = {}
  248. for group_name, value in dependency_groups.items():
  249. normed_group_name = _normalize_name(group_name)
  250. original_names.setdefault(normed_group_name, []).append(group_name)
  251. normalized_groups[normed_group_name] = value
  252. for normed_name, names in original_names.items():
  253. if len(names) > 1:
  254. errors.error(
  255. DuplicateGroupNames(
  256. "Duplicate dependency group names: "
  257. f"{normed_name} ({', '.join(names)})"
  258. )
  259. )
  260. return normalized_groups