-
Notifications
You must be signed in to change notification settings - Fork 283
Add a new packaging.dependency_groups module, based on the existing dependency-groups package
#1065
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
sirosen
wants to merge
5
commits into
pypa:main
Choose a base branch
from
sirosen:integrate-dependency-groups
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+869
−0
Open
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
77c04e9
Add 'dependency_groups', a port of the prior art
sirosen 5694c48
Convert dependency_groups to exception groups
sirosen af3378e
Adjust DependencyGroupInclude repr
sirosen 174c798
Work around pytest `group_contains`
sirosen ce46ca8
Minor adjustments to dependency-groups per review
sirosen File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| Dependency Groups | ||
| ================= | ||
|
|
||
| .. currentmodule:: packaging.dependency_groups | ||
|
|
||
| Package data as defined in ``pyproject.toml`` may include lists of dependencies | ||
| in named groups. This is described by the | ||
| :ref:`dependency groups specification <pypug:dependency-groups>`, which defines | ||
| the ``[dependency-groups]`` table. | ||
|
|
||
| This module provides tools for resolving group names to lists of requirements, | ||
| most notably expanding ``include-group`` directives. | ||
|
|
||
| Usage | ||
| ----- | ||
|
|
||
| Two primary interfaces are offered. An object-based one which caches results and | ||
| provides ``Requirements`` as its results: | ||
|
|
||
| .. doctest:: | ||
|
|
||
| >>> from packaging.dependency_groups import DependencyGroupResolver | ||
| >>> coverage = ["coverage"] | ||
| >>> test = ["pytest", {"include-group": "coverage"}] | ||
| >>> # A resolver is defined on a mapping of group names to group data, as | ||
| >>> # you might get by loading the [dependency-groups] TOML table. | ||
| >>> resolver = DependencyGroupResolver({"test": test, "coverage": coverage}) | ||
| >>> # resolvers support expanding group names to Requirements | ||
| >>> resolver.resolve("coverage") | ||
| (<Requirement('coverage')>,) | ||
| >>> resolver.resolve("test") | ||
| (<Requirement('pytest')>, <Requirement('coverage')>) | ||
| >>> # resolvers can also be used to lookup the dependency groups without | ||
| >>> # expanding includes | ||
| >>> resolver.lookup("test") | ||
| (<Requirement('pytest')>, DependencyGroupInclude('coverage')) | ||
|
|
||
| And a simpler functional interface which responds with strings: | ||
|
|
||
| .. doctest:: | ||
|
|
||
| >>> from packaging.dependency_groups import resolve_dependency_groups | ||
| >>> coverage = ["coverage"] | ||
| >>> test = ["pytest", {"include-group": "coverage"}] | ||
| >>> groups = {"test": test, "coverage": coverage} | ||
| >>> resolve_dependency_groups(groups, "test") | ||
| ('pytest', 'coverage') | ||
|
|
||
| Reference | ||
| --------- | ||
|
|
||
| Functional Interface | ||
| '''''''''''''''''''' | ||
|
|
||
| .. autofunction:: resolve_dependency_groups | ||
|
|
||
|
|
||
| Object Model Interface | ||
| '''''''''''''''''''''' | ||
|
|
||
| .. autoclass:: DependencyGroupInclude | ||
| :members: | ||
|
|
||
| .. autoclass:: DependencyGroupResolver | ||
| :members: | ||
|
|
||
| Exceptions | ||
| '''''''''' | ||
|
|
||
| .. autoclass:: DuplicateGroupNames | ||
| :members: | ||
|
|
||
| .. autoclass:: CyclicDependencyGroup | ||
| :members: | ||
|
|
||
| .. autoclass:: InvalidDependencyGroupObject | ||
| :members: |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,302 @@ | ||
| from __future__ import annotations | ||
|
|
||
| import re | ||
| from collections.abc import Mapping, Sequence | ||
|
|
||
| from .errors import _ErrorCollector | ||
| from .requirements import Requirement | ||
|
|
||
| __all__ = [ | ||
| "CyclicDependencyGroup", | ||
| "DependencyGroupInclude", | ||
| "DependencyGroupResolver", | ||
| "DuplicateGroupNames", | ||
| "InvalidDependencyGroupObject", | ||
| "resolve_dependency_groups", | ||
| ] | ||
|
|
||
|
|
||
| def __dir__() -> list[str]: | ||
| return __all__ | ||
|
|
||
|
|
||
| # ----------- | ||
| # Error Types | ||
| # ----------- | ||
|
|
||
|
|
||
| class DuplicateGroupNames(ValueError): | ||
| """ | ||
| The same dependency groups were defined twice, with different non-normalized names. | ||
| """ | ||
|
|
||
|
|
||
| class CyclicDependencyGroup(ValueError): | ||
| """ | ||
| The dependency group includes form a cycle. | ||
| """ | ||
|
|
||
| def __init__(self, requested_group: str, group: str, include_group: str) -> None: | ||
| self.requested_group = requested_group | ||
| self.group = group | ||
| self.include_group = include_group | ||
|
|
||
| if include_group == group: | ||
| reason = f"{group} includes itself" | ||
| else: | ||
| reason = f"{include_group} -> {group}, {group} -> {include_group}" | ||
| super().__init__( | ||
| "Cyclic dependency group include while resolving " | ||
| f"{requested_group}: {reason}" | ||
| ) | ||
|
|
||
|
|
||
| # in the PEP 735 spec, the tables in dependency group lists were described as | ||
| # "Dependency Object Specifiers", but the only defined type of object was a | ||
| # "Dependency Group Include" -- hence the naming of this error as "Object" | ||
| class InvalidDependencyGroupObject(ValueError): | ||
| """ | ||
| A member of a dependency group was identified as a dict, but was not in a valid | ||
| format. | ||
| """ | ||
|
|
||
|
|
||
| # ------------------------ | ||
| # Object Model & Interface | ||
| # ------------------------ | ||
|
|
||
|
|
||
| class DependencyGroupInclude: | ||
| __slots__ = ("include_group",) | ||
|
|
||
| def __init__(self, include_group: str) -> None: | ||
| """ | ||
| Initialize a DependencyGroupInclude. | ||
|
|
||
| :param include_group: The name of the group referred to by this include. | ||
| """ | ||
| self.include_group = include_group | ||
|
|
||
| def __repr__(self) -> str: | ||
| return f"{self.__class__.__name__}({self.include_group!r})" | ||
|
|
||
|
|
||
| class DependencyGroupResolver: | ||
| """ | ||
| A resolver for Dependency Group data. | ||
|
|
||
| This class handles caching, name normalization, cycle detection, and other | ||
| parsing requirements. There are only two public methods for exploring the data: | ||
| ``lookup()`` and ``resolve()``. | ||
|
|
||
| :param dependency_groups: A mapping, as provided via pyproject | ||
| ``[dependency-groups]``. | ||
| """ | ||
|
|
||
| def __init__( | ||
| self, | ||
| dependency_groups: Mapping[str, Sequence[str | Mapping[str, str]]], | ||
| ) -> None: | ||
| errors = _ErrorCollector() | ||
|
|
||
| self.dependency_groups = _normalize_group_names(dependency_groups, errors) | ||
|
|
||
| # a map of group names to parsed data | ||
| self._parsed_groups: dict[ | ||
| str, tuple[Requirement | DependencyGroupInclude, ...] | ||
| ] = {} | ||
| # a map of group names to their ancestors, used for cycle detection | ||
| self._include_graph_ancestors: dict[str, tuple[str, ...]] = {} | ||
| # a cache of completed resolutions to Requirement lists | ||
| self._resolve_cache: dict[str, tuple[Requirement, ...]] = {} | ||
|
|
||
| errors.finalize("[dependency-groups] data was invalid") | ||
|
|
||
| def lookup(self, group: str) -> tuple[Requirement | DependencyGroupInclude, ...]: | ||
| """ | ||
| Lookup a group name, returning the parsed dependency data for that group. | ||
| This will not resolve includes. | ||
|
|
||
| :param group: the name of the group to lookup | ||
| """ | ||
| group = _normalize_name(group) | ||
|
|
||
| with _ErrorCollector().on_exit( | ||
| f"[dependency-groups] data for {group!r} was malformed" | ||
| ) as errors: | ||
| return self._parse_group(group, errors) | ||
|
|
||
| def resolve(self, group: str) -> tuple[Requirement, ...]: | ||
| """ | ||
| Resolve a dependency group to a list of requirements. | ||
|
|
||
| :param group: the name of the group to resolve | ||
| """ | ||
| group = _normalize_name(group) | ||
|
|
||
| with _ErrorCollector().on_exit( | ||
| f"[dependency-groups] data for {group!r} was malformed" | ||
| ) as errors: | ||
| return self._resolve(group, group, errors) | ||
|
|
||
| def _resolve( | ||
| self, group: str, requested_group: str, errors: _ErrorCollector | ||
| ) -> tuple[Requirement, ...]: | ||
| """ | ||
| This is a helper for cached resolution to strings. It preserves the name of the | ||
| group which the user initially requested in order to present a clearer error in | ||
| the event that a cycle is detected. | ||
|
|
||
| :param group: The normalized name of the group to resolve. | ||
| :param requested_group: The group which was used in the original, user-facing | ||
| request. | ||
| """ | ||
| if group in self._resolve_cache: | ||
| return self._resolve_cache[group] | ||
|
|
||
| parsed = self._parse_group(group, errors) | ||
|
|
||
| resolved_group = [] | ||
|
|
||
| for item in parsed: | ||
| if isinstance(item, Requirement): | ||
| resolved_group.append(item) | ||
| elif isinstance(item, DependencyGroupInclude): | ||
| include_group = _normalize_name(item.include_group) | ||
|
|
||
| # if a group is cyclic, record the error | ||
| # otherwise, follow the include_group reference | ||
| # | ||
| # this allows us to examine all includes in a group, even in the | ||
| # presence of errors | ||
| if include_group in self._include_graph_ancestors.get(group, ()): | ||
| errors.error( | ||
| CyclicDependencyGroup( | ||
| requested_group, group, item.include_group | ||
| ) | ||
| ) | ||
| else: | ||
| self._include_graph_ancestors[include_group] = ( | ||
| *self._include_graph_ancestors.get(group, ()), | ||
| group, | ||
| ) | ||
| resolved_group.extend( | ||
| self._resolve(include_group, requested_group, errors) | ||
| ) | ||
| else: # pragma: no cover | ||
| raise NotImplementedError( | ||
| f"Invalid dependency group item after parse: {item}" | ||
| ) | ||
|
|
||
| # in the event that errors were detected, present the group as empty and do not | ||
| # cache the result | ||
| # this ensures that repeated access to a cyclic group will raise multiple errors | ||
| if errors.errors: | ||
| return () | ||
|
|
||
| self._resolve_cache[group] = tuple(resolved_group) | ||
| return self._resolve_cache[group] | ||
|
|
||
| def _parse_group( | ||
| self, group: str, errors: _ErrorCollector | ||
| ) -> tuple[Requirement | DependencyGroupInclude, ...]: | ||
| # short circuit -- never do the work twice | ||
| if group in self._parsed_groups: | ||
| return self._parsed_groups[group] | ||
|
|
||
| if group not in self.dependency_groups: | ||
| errors.error(LookupError(f"Dependency group '{group}' not found")) | ||
| return () | ||
|
|
||
| raw_group = self.dependency_groups[group] | ||
| if isinstance(raw_group, str): | ||
| errors.error( | ||
| TypeError( | ||
| f"Dependency group {group!r} contained a string rather than a list." | ||
| ) | ||
| ) | ||
| return () | ||
|
|
||
| if not isinstance(raw_group, Sequence): | ||
| errors.error( | ||
| TypeError(f"Dependency group {group!r} is not a sequence type.") | ||
| ) | ||
| return () | ||
|
|
||
| elements: list[Requirement | DependencyGroupInclude] = [] | ||
| for item in raw_group: | ||
| if isinstance(item, str): | ||
| # packaging.requirements.Requirement parsing ensures that this is a | ||
| # valid PEP 508 Dependency Specifier | ||
| # raises InvalidRequirement on failure | ||
| elements.append(Requirement(item)) | ||
| elif isinstance(item, Mapping): | ||
| if tuple(item.keys()) != ("include-group",): | ||
| errors.error( | ||
| InvalidDependencyGroupObject( | ||
| f"Invalid dependency group item: {item!r}" | ||
| ) | ||
| ) | ||
| else: | ||
| include_group = item["include-group"] | ||
| elements.append(DependencyGroupInclude(include_group=include_group)) | ||
| else: | ||
| errors.error(TypeError(f"Invalid dependency group item: {item!r}")) | ||
|
|
||
| self._parsed_groups[group] = tuple(elements) | ||
| return self._parsed_groups[group] | ||
|
|
||
|
|
||
| # -------------------- | ||
| # Functional Interface | ||
| # -------------------- | ||
|
|
||
|
|
||
| def resolve_dependency_groups( | ||
| dependency_groups: Mapping[str, Sequence[str | Mapping[str, str]]], /, *groups: str | ||
| ) -> tuple[str, ...]: | ||
| """ | ||
| Resolve a dependency group to a tuple of requirements, as strings. | ||
|
|
||
| :param dependency_groups: the parsed contents of the ``[dependency-groups]`` table | ||
| from ``pyproject.toml`` | ||
| :param groups: the name of the group(s) to resolve | ||
| """ | ||
| resolver = DependencyGroupResolver(dependency_groups) | ||
| return tuple(str(r) for group in groups for r in resolver.resolve(group)) | ||
|
|
||
|
|
||
| # ---------------- | ||
| # internal helpers | ||
| # ---------------- | ||
|
|
||
|
|
||
| _NORMALIZE_PATTERN = re.compile(r"[-_.]+") | ||
|
|
||
|
|
||
| def _normalize_name(name: str) -> str: | ||
| return _NORMALIZE_PATTERN.sub("-", name).lower() | ||
|
|
||
|
|
||
| def _normalize_group_names( | ||
| dependency_groups: Mapping[str, Sequence[str | Mapping[str, str]]], | ||
| errors: _ErrorCollector, | ||
| ) -> dict[str, Sequence[str | Mapping[str, str]]]: | ||
| original_names: dict[str, list[str]] = {} | ||
| normalized_groups: dict[str, Sequence[str | Mapping[str, str]]] = {} | ||
|
|
||
| for group_name, value in dependency_groups.items(): | ||
| normed_group_name = _normalize_name(group_name) | ||
| original_names.setdefault(normed_group_name, []).append(group_name) | ||
| normalized_groups[normed_group_name] = value | ||
|
|
||
| for normed_name, names in original_names.items(): | ||
| if len(names) > 1: | ||
| errors.error( | ||
| DuplicateGroupNames( | ||
| "Duplicate dependency group names: " | ||
| f"{normed_name} ({', '.join(names)})" | ||
| ) | ||
| ) | ||
|
|
||
| return normalized_groups | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.