dependency_groups.py 10.0 KB

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