codekingpro/portable-devtools
115k
1from __future__ import annotations2 3import re4from collections.abc import Mapping, Sequence5 6from .errors import _ErrorCollector7from .requirements import Requirement8 9__all__ = [10 "CyclicDependencyGroup",11 "DependencyGroupInclude",12 "DependencyGroupResolver",13 "DuplicateGroupNames",14 "InvalidDependencyGroupObject",15 "resolve_dependency_groups",16]17 18 19def __dir__() -> list[str]:20 return __all__21 22 23# -----------24# Error Types25# -----------26 27 28class DuplicateGroupNames(ValueError):29 """30 The same dependency groups were defined twice, with different non-normalized names.31 """32 33 34class CyclicDependencyGroup(ValueError):35 """36 The dependency group includes form a cycle.37 """38 39 def __init__(self, requested_group: str, group: str, include_group: str) -> None:40 self.requested_group = requested_group41 self.group = group42 self.include_group = include_group43 44 if include_group == group:45 reason = f"{group} includes itself"46 else:47 reason = f"{include_group} -> {group}, {group} -> {include_group}"48 super().__init__(49 "Cyclic dependency group include while resolving "50 f"{requested_group}: {reason}"51 )52 53 54# in the PEP 735 spec, the tables in dependency group lists were described as55# "Dependency Object Specifiers", but the only defined type of object was a56# "Dependency Group Include" -- hence the naming of this error as "Object"57class InvalidDependencyGroupObject(ValueError):58 """59 A member of a dependency group was identified as a dict, but was not in a valid60 format.61 """62 63 64# ------------------------65# Object Model & Interface66# ------------------------67 68 69class DependencyGroupInclude:70 __slots__ = ("include_group",)71 72 def __init__(self, include_group: str) -> None:73 """74 Initialize a DependencyGroupInclude.75 76 :param include_group: The name of the group referred to by this include.77 """78 self.include_group = include_group79 80 def __repr__(self) -> str:81 return f"{self.__class__.__name__}({self.include_group!r})"82 83 84class DependencyGroupResolver:85 """86 A resolver for Dependency Group data.87 88 This class handles caching, name normalization, cycle detection, and other89 parsing requirements. There are only two public methods for exploring the data:90 ``lookup()`` and ``resolve()``.91 92 :param dependency_groups: A mapping, as provided via pyproject93 ``[dependency-groups]``.94 """95 96 def __init__(97 self,98 dependency_groups: Mapping[str, Sequence[str | Mapping[str, str]]],99 ) -> None:100 errors = _ErrorCollector()101 102 self.dependency_groups = _normalize_group_names(dependency_groups, errors)103 104 # a map of group names to parsed data105 self._parsed_groups: dict[106 str, tuple[Requirement | DependencyGroupInclude, ...]107 ] = {}108 # a map of group names to their ancestors, used for cycle detection109 self._include_graph_ancestors: dict[str, tuple[str, ...]] = {}110 # a cache of completed resolutions to Requirement lists111 self._resolve_cache: dict[str, tuple[Requirement, ...]] = {}112 113 errors.finalize("[dependency-groups] data was invalid")114 115 def lookup(self, group: str) -> tuple[Requirement | DependencyGroupInclude, ...]:116 """117 Lookup a group name, returning the parsed dependency data for that group.118 This will not resolve includes.119 120 :param group: the name of the group to lookup121 """122 group = _normalize_name(group)123 124 with _ErrorCollector().on_exit(125 f"[dependency-groups] data for {group!r} was malformed"126 ) as errors:127 return self._parse_group(group, errors)128 129 def resolve(self, group: str) -> tuple[Requirement, ...]:130 """131 Resolve a dependency group to a list of requirements.132 133 :param group: the name of the group to resolve134 """135 group = _normalize_name(group)136 137 with _ErrorCollector().on_exit(138 f"[dependency-groups] data for {group!r} was malformed"139 ) as errors:140 return self._resolve(group, group, errors)141 142 def _resolve(143 self, group: str, requested_group: str, errors: _ErrorCollector144 ) -> tuple[Requirement, ...]:145 """146 This is a helper for cached resolution to strings. It preserves the name of the147 group which the user initially requested in order to present a clearer error in148 the event that a cycle is detected.149 150 :param group: The normalized name of the group to resolve.151 :param requested_group: The group which was used in the original, user-facing152 request.153 """154 if group in self._resolve_cache:155 return self._resolve_cache[group]156 157 parsed = self._parse_group(group, errors)158 159 resolved_group = []160 161 for item in parsed:162 if isinstance(item, Requirement):163 resolved_group.append(item)164 elif isinstance(item, DependencyGroupInclude):165 include_group = _normalize_name(item.include_group)166 167 # if a group is cyclic, record the error168 # otherwise, follow the include_group reference169 #170 # this allows us to examine all includes in a group, even in the171 # presence of errors172 if include_group in self._include_graph_ancestors.get(group, ()):173 errors.error(174 CyclicDependencyGroup(175 requested_group, group, item.include_group176 )177 )178 else:179 self._include_graph_ancestors[include_group] = (180 *self._include_graph_ancestors.get(group, ()),181 group,182 )183 resolved_group.extend(184 self._resolve(include_group, requested_group, errors)185 )186 else: # pragma: no cover187 raise NotImplementedError(188 f"Invalid dependency group item after parse: {item}"189 )190 191 # in the event that errors were detected, present the group as empty and do not192 # cache the result193 # this ensures that repeated access to a cyclic group will raise multiple errors194 if errors.errors:195 return ()196 197 self._resolve_cache[group] = tuple(resolved_group)198 return self._resolve_cache[group]199 200 def _parse_group(201 self, group: str, errors: _ErrorCollector202 ) -> tuple[Requirement | DependencyGroupInclude, ...]:203 # short circuit -- never do the work twice204 if group in self._parsed_groups:205 return self._parsed_groups[group]206 207 if group not in self.dependency_groups:208 errors.error(LookupError(f"Dependency group '{group}' not found"))209 return ()210 211 raw_group = self.dependency_groups[group]212 if isinstance(raw_group, str):213 errors.error(214 TypeError(215 f"Dependency group {group!r} contained a string rather than a list."216 )217 )218 return ()219 220 if not isinstance(raw_group, Sequence):221 errors.error(222 TypeError(f"Dependency group {group!r} is not a sequence type.")223 )224 return ()225 226 elements: list[Requirement | DependencyGroupInclude] = []227 for item in raw_group:228 if isinstance(item, str):229 # packaging.requirements.Requirement parsing ensures that this is a230 # valid PEP 508 Dependency Specifier231 # raises InvalidRequirement on failure232 elements.append(Requirement(item))233 elif isinstance(item, Mapping):234 if tuple(item.keys()) != ("include-group",):235 errors.error(236 InvalidDependencyGroupObject(237 f"Invalid dependency group item: {item!r}"238 )239 )240 else:241 include_group = item["include-group"]242 elements.append(DependencyGroupInclude(include_group=include_group))243 else:244 errors.error(TypeError(f"Invalid dependency group item: {item!r}"))245 246 self._parsed_groups[group] = tuple(elements)247 return self._parsed_groups[group]248 249 250# --------------------251# Functional Interface252# --------------------253 254 255def resolve_dependency_groups(256 dependency_groups: Mapping[str, Sequence[str | Mapping[str, str]]], /, *groups: str257) -> tuple[str, ...]:258 """259 Resolve a dependency group to a tuple of requirements, as strings.260 261 :param dependency_groups: the parsed contents of the ``[dependency-groups]`` table262 from ``pyproject.toml``263 :param groups: the name of the group(s) to resolve264 """265 resolver = DependencyGroupResolver(dependency_groups)266 return tuple(str(r) for group in groups for r in resolver.resolve(group))267 268 269# ----------------270# internal helpers271# ----------------272 273 274_NORMALIZE_PATTERN = re.compile(r"[-_.]+")275 276 277def _normalize_name(name: str) -> str:278 return _NORMALIZE_PATTERN.sub("-", name).lower()279 280 281def _normalize_group_names(282 dependency_groups: Mapping[str, Sequence[str | Mapping[str, str]]],283 errors: _ErrorCollector,284) -> dict[str, Sequence[str | Mapping[str, str]]]:285 original_names: dict[str, list[str]] = {}286 normalized_groups: dict[str, Sequence[str | Mapping[str, str]]] = {}287 288 for group_name, value in dependency_groups.items():289 normed_group_name = _normalize_name(group_name)290 original_names.setdefault(normed_group_name, []).append(group_name)291 normalized_groups[normed_group_name] = value292 293 for normed_name, names in original_names.items():294 if len(names) > 1:295 errors.error(296 DuplicateGroupNames(297 "Duplicate dependency group names: "298 f"{normed_name} ({', '.join(names)})"299 )300 )301 302 return normalized_groups303 