././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1787366575.0844486 dependency_groups-1.3.2/CHANGELOG.rst0000644000000000000000000000456015242206257014213 0ustar00CHANGELOG ========= Unreleased ---------- 1.3.2 ----- - Add support for Python 3.14 and 3.15 - Remove support for Python 3.8 - Correct the type of the ``dependency_groups`` parameter to ``resolve()``, ``resolve_all()``, and ``DependencyGroupsResolver()``. - Raise a clear ``TypeError`` when an ``include-group`` value is not a string (was a cryptic ``TypeError`` from name normalization), and accept any ``Mapping`` for include items, not only ``dict``. - Fix a bug in which ``pip-install-dependency-groups`` failed when the resolved groups were empty. It now prints ``Nothing to install`` and exits normally. - `dependency-groups --list` now respects the `-o`/`--output` option. 1.3.1 ----- - Fix a bug in which names in includes were not normalized before comparisons, resulting in spurious ``LookupError``\s. - Optimize the behavior of the ``resolve()`` function on multiple groups. 1.3.0 ----- - Bugfix: raise a ``TypeError`` on non-list groups (was ``ValueError``). Thanks :user:`henryiii`! - Several improvements to the CLI interfaces! Thanks :user:`henryiii`! - Add support for a ``cli`` extra, as in ``pip install "dependency-groups[cli]"``, which ensures that ``tomli`` is present on older Pythons. - Add support for ``dependency-groups`` as an entrypoint, as an alias of ``python -m dependency_groups``. - The ``dependency-groups`` command now supports a ``--list`` flag to list groups instead of resolving them. 1.2.0 ----- - Switch to ``flit-core`` as the build backend - Add support for supplying multiple dependency groups to the functional ``resolve()`` API: ``resolve(dependency_groups, *groups: str)``. Thanks :user:`henryiii`! 1.1.0 ----- - Add support for Python 3.8 1.0.0 ----- - Update metadata to 1.0.0 and "Production" status - Support Python 3.13 0.3.0 ----- - Add a new command, ``pip-install-dependency-groups``, which is capable of installing dependency groups by invoking ``pip`` 0.2.2 ----- - The pre-commit hook sets ``pass_filenames: false`` - The error presentation in the lint CLI has been improved 0.2.1 ----- - Bugfix to pre-commit config 0.2.0 ----- - Add a new CLI component, ``lint-dependency-groups``, which can be used to lint dependency groups. - Provide a pre-commit hook, named ``lint-dependency-groups`` 0.1.1 ----- - Fix a bug in cycle detection for nontrivial cycles 0.1.0 ----- - Initial Release ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1787366575.0844486 dependency_groups-1.3.2/LICENSE.txt0000644000000000000000000000211315242206257014005 0ustar00MIT License Copyright (c) 2024-present Stephen Rosen Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1787366575.0844486 dependency_groups-1.3.2/README.rst0000644000000000000000000000203515242206257013654 0ustar00Dependency Groups ================= An implementation of Dependency Groups (`PEP 735 `_). This is a library which is able to parse dependency groups, following includes, and provide that data as output. Interfaces ---------- ``dependency-groups`` provides the following: - A ``DependencyGroupResolver`` which implements efficient resolution of dependency groups - A ``resolve()`` function which converts a dependency group name to a list of strings (powered by the resolver) - Three CLI commands: - ``python -m dependency_groups GROUPNAME`` prints a dependency group's contents - ``lint-dependency-groups`` loads all dependency groups to check for correctness - ``pip-install-dependency-groups GROUPNAME...`` wraps a ``pip`` invocation to install the contents of a dependency group - A pre-commit hooks which runs ``lint-dependency-groups`` Documentation ------------- Full documentation is available on `the Dependency Groups doc site `_. ././@PaxHeader0000000000000000000000000000003200000000000010210 xustar0026 mtime=1787366575.08505 dependency_groups-1.3.2/pyproject.toml0000644000000000000000000001271515242206257015107 0ustar00[build-system] requires = ["flit-core>=3.11"] build-backend = "flit_core.buildapi" [dependency-groups] coverage = ["coverage[toml]"] test = ["pytest", {include-group = "coverage"}] docs = ["sphinx>=8.1", "sphinx-issues>=5", "furo"] lint = ["pre-commit"] typing = ["mypy", "packaging", "pytest"] build = ["twine", "build"] dev = [{include-group = "test"}] [project] name = "dependency-groups" version = "1.3.2" description = 'A tool for resolving PEP 735 Dependency Group data' readme = "README.rst" requires-python = ">=3.9" license = "MIT" license-files = ["LICENSE.txt"] keywords = [] authors = [ { name = "Stephen Rosen", email = "sirosen0@gmail.com" }, ] classifiers = [ "Development Status :: 5 - Production/Stable", "Programming Language :: Python", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Programming Language :: Python :: 3.15", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", ] dependencies = [ "packaging", "tomli;python_version<'3.11'", ] [project.scripts] lint-dependency-groups = "dependency_groups._lint_dependency_groups:main" pip-install-dependency-groups = "dependency_groups._pip_wrapper:main" dependency-groups = "dependency_groups.__main__:main" [project.optional-dependencies] cli = ["tomli; python_version<'3.11'"] [project.urls] source = "https://github.com/pypa/dependency-groups" changelog = "https://github.com/pypa/dependency-groups/blob/main/CHANGELOG.rst" documentation = "https://dependency-groups.readthedocs.io/" [tool.flit.sdist] include = ["LICENSE.txt", "CHANGELOG.rst", "tests/*.py", "tox.ini"] [tool.uv] environments = [ "python_version >= '3.10'", ] [tool.pytest.ini_options] minversion = "6.0" addopts = ["-ra", "--showlocals", "--strict-markers", "--strict-config"] xfail_strict = true filterwarnings = ["error"] log_level = "INFO" testpaths = [ "tests", ] [tool.coverage.run] parallel = true source = ["dependency_groups"] [tool.coverage.paths] source = [ "src/", "*/site-packages/", ] [tool.coverage.report] show_missing = true skip_covered = true exclude_lines = [ # the pragma to disable coverage "pragma: no cover", # don't complain if tests don't hit unimplemented methods/modes "raise NotImplementedError", # don't check on executable components of importable modules "if __name__ == .__main__.:", # mypy-only code segments "if t.TYPE_CHECKING:", # type-checking overloads "@t.overload" ] [tool.mypy] strict = true ignore_missing_imports = true disallow_subclassing_any = false files = ["src", "tests"] [tool.ruff] line-length = 88 show-fixes = true [tool.ruff.lint] select = ["E4", "E7", "E9", "F", "B", "C4", "I", "TC", "UP"] ignore = ["E203"] [tool.ruff.lint.isort] known-first-party = ["dependency_groups"] [tool.check-sdist] git-only = [".*", "Makefile", "docs/*", "scripts/*"] [tool.tox] requires = ["tox>=4.22"] env_list = [ "lint", "mypy", "covclean", "covcombine", "covreport", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14", "3.15", ] [tool.tox.labels] ci = ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14", "3.15", "covcombine", "covreport"] ci-mypy = ["mypy-py39", "mypy-py313"] ci-package-check = ["twine-check"] [tool.tox.env_run_base] description = "Run tests and coverage" package = "wheel" wheel_build_env = "build_wheel" dependency_groups = ["test"] commands = [["coverage", "run", "-m", "pytest", "-v", {replace = "posargs", extend = true}]] depends = ["clean"] [tool.tox.env.covclean] description = "Clean coverage outputs" skip_install = true dependency_groups = ["coverage"] commands = [["coverage", "erase"]] [tool.tox.env.covcombine] description = "Combine coverage outputs" skip_install = true dependency_groups = ["coverage"] commands = [["coverage", "combine"]] depends = ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14", "3.15"] [tool.tox.env.covreport] description = "Report on combined coverage outputs" skip_install = true dependency_groups = ["coverage"] commands_pre = [["coverage", "html", "--fail-under=0"]] commands = [["coverage", "report"]] depends = ["covcombine"] [tool.tox.env.lint] description = "Run linterse and formatters" dependency_groups = ["lint"] commands = [["pre-commit", "run", "-a", {replace = "posargs", extend = true}]] depends = [] [tool.tox.env.mypy] package = "skip" description = "Run static type checking under {base_python}" dependency_groups = ["typing"] commands = [["mypy", {replace = "posargs", extend = true} ]] depends = [] [tool.tox.env.mypy-py39] base = ["tool.tox.env.mypy"] [tool.tox.env.mypy-py313] base = ["env.mypy"] [tool.tox.env.twine-check] description = "check the metadata on a package build" allowlist_externals = ["rm"] dependency_groups = ["build"] commands_pre = [["rm", "-rf", "dist/"]] # check that twine validating package data works commands = [["python", "-m", "build"], ["twine", "check", "dist/*"]] [tool.tox.env.docs] description = "build docs with sphinx" basepython = ["python3.12"] dependency_groups = ["docs"] allowlist_externals = ["rm"] changedir = "docs/" # clean the build dir before rebuilding commands_pre = [["rm", "-rf", "_build/"]] commands = [["sphinx-build", "-d", "_build/doctrees", "-b", "dirhtml", "-W", ".", "_build/dirhtml", {replace = "posargs", extend = true}]] ././@PaxHeader0000000000000000000000000000003100000000000010207 xustar0025 mtime=1787366575.0856 dependency_groups-1.3.2/src/dependency_groups/__init__.py0000644000000000000000000000043615242206257020605 0ustar00from ._implementation import ( CyclicDependencyError, DependencyGroupInclude, DependencyGroupResolver, resolve, resolve_all, ) __all__ = ( "CyclicDependencyError", "DependencyGroupInclude", "DependencyGroupResolver", "resolve", "resolve_all", ) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1787366575.0856662 dependency_groups-1.3.2/src/dependency_groups/__main__.py0000644000000000000000000000347315242206257020572 0ustar00from __future__ import annotations import sys from ._argparse_compat import ArgumentParser from ._implementation import resolve from ._toml_compat import tomllib def main(*, argv: list[str] | None = None) -> None: if tomllib is None: print( "Usage error: dependency-groups CLI requires tomli or Python 3.11+", file=sys.stderr, ) raise SystemExit(2) parser = ArgumentParser( description=( "A dependency-groups CLI. Prints out a resolved group, newline-delimited." ) ) parser.add_argument( "GROUP_NAME", nargs="*", help="The dependency group(s) to resolve." ) parser.add_argument( "-f", "--pyproject-file", default="pyproject.toml", help="The pyproject.toml file. Defaults to trying in the current directory.", ) parser.add_argument( "-o", "--output", help="An output file. Defaults to stdout.", ) parser.add_argument( "-l", "--list", action="store_true", help="List the available dependency groups", ) args = parser.parse_args(argv if argv is not None else sys.argv[1:]) with open(args.pyproject_file, "rb") as fp: pyproject = tomllib.load(fp) dependency_groups_raw = pyproject.get("dependency-groups", {}) if args.list: content = " ".join(dependency_groups_raw.keys()) elif not args.GROUP_NAME: print("A GROUP_NAME is required", file=sys.stderr) raise SystemExit(3) else: content = "\n".join(resolve(dependency_groups_raw, *args.GROUP_NAME)) if args.output is None or args.output == "-": print(content) else: with open(args.output, "w", encoding="utf-8") as fp: print(content, file=fp) if __name__ == "__main__": main() ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1787366575.0856662 dependency_groups-1.3.2/src/dependency_groups/_argparse_compat.py0000644000000000000000000000054615242206257022356 0ustar00from __future__ import annotations import argparse import functools import sys __all__ = ["ArgumentParser"] def __dir__() -> list[str]: return __all__ ArgumentParser = functools.partial(argparse.ArgumentParser) if sys.version_info >= (3, 14): ArgumentParser = functools.partial( ArgumentParser, color=True, suggest_on_error=True ) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1787366575.0856662 dependency_groups-1.3.2/src/dependency_groups/_implementation.py0000644000000000000000000002564115242206257022237 0ustar00from __future__ import annotations import dataclasses import re from collections.abc import Mapping, Sequence from packaging.requirements import Requirement def _normalize_name(name: str) -> str: return re.sub(r"[-_.]+", "-", name).lower() def _normalize_group_names( dependency_groups: Mapping[str, Sequence[str | Mapping[str, str]]], ) -> tuple[Mapping[str, Sequence[str | Mapping[str, str]]], Mapping[str, str]]: """ Normalize group names and return both normalized groups and reverse mapping. Returns a tuple of (normalized_groups, normalized_to_original). """ 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 errors = [ f"{normed_name} ({', '.join(names)})" for normed_name, names in original_names.items() if len(names) > 1 ] if errors: raise ValueError(f"Duplicate dependency group names: {', '.join(errors)}") normalized_to_original = { normed_name: names[0] for normed_name, names in original_names.items() } return normalized_groups, normalized_to_original @dataclasses.dataclass class DependencyGroupInclude: include_group: str class CyclicDependencyError(ValueError): """ An error representing the detection of 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}" ) 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: if not isinstance(dependency_groups, Mapping): raise TypeError("Dependency Groups table is not a mapping") self.dependency_groups, self._normalized_to_original = _normalize_group_names( dependency_groups ) # 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, ...]] = {} 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 :raises ValueError: if the data does not appear to be valid dependency group data :raises TypeError: if the data is not a string :raises LookupError: if group name is absent :raises packaging.requirements.InvalidRequirement: if a specifier is not valid """ if not isinstance(group, str): raise TypeError("Dependency group name is not a str") group = _normalize_name(group) return self._parse_group(group) 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 :raises TypeError: if the inputs appear to be the wrong types :raises ValueError: if the data does not appear to be valid dependency group data :raises LookupError: if group name is absent :raises packaging.requirements.InvalidRequirement: if a specifier is not valid """ if not isinstance(group, str): raise TypeError("Dependency group name is not a str") group = _normalize_name(group) return self._resolve(group, group) def _parse_group( self, group: str ) -> 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: raise LookupError(f"Dependency group '{group}' not found") raw_group = self.dependency_groups[group] if isinstance(raw_group, str): raise TypeError( f"Dependency group '{group}' contained a string rather than a sequence." ) if not isinstance(raw_group, Sequence): raise TypeError(f"Dependency group '{group}' is not a sequence") 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",): raise ValueError(f"Invalid dependency group item: {item}") include_group = next(iter(item.values())) if not isinstance(include_group, str): raise TypeError( f"Invalid include-group value, must be a string: {item}" ) elements.append(DependencyGroupInclude(include_group=include_group)) else: raise ValueError(f"Invalid dependency group item: {item}") parsed = tuple(elements) self._parsed_groups[group] = parsed return parsed def _resolve(self, group: str, requested_group: str) -> tuple[Requirement, ...]: """ This is a helper for cached resolution to strings. :param group: The 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) 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 include_group in self._include_graph_ancestors.get(group, ()): raise CyclicDependencyError( requested_group, group, item.include_group ) self._include_graph_ancestors[include_group] = ( *self._include_graph_ancestors.get(group, ()), group, ) resolved_group.extend(self._resolve(include_group, requested_group)) else: # unreachable raise NotImplementedError( f"Invalid dependency group item after parse: {item}" ) resolved = tuple(resolved_group) self._resolve_cache[group] = resolved return resolved def resolve_all(self) -> Mapping[str, tuple[Requirement, ...]]: """ Resolve all dependency groups, returning a mapping of normalized group names to resolved requirements. This is more efficient than calling resolve() on each group individually because it avoids repeated work when groups share common includes. :raises TypeError: if the data appears to be the wrong types :raises ValueError: if the data does not appear to be valid dependency group data :raises packaging.requirements.InvalidRequirement: if a specifier is not valid """ # Resolve all groups that haven't been resolved yet for group in self.dependency_groups: self._resolve(group, group) return dict(self._resolve_cache) def resolve( 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 :raises TypeError: if the inputs appear to be the wrong types :raises ValueError: if the data does not appear to be valid dependency group data :raises LookupError: if group name is absent :raises packaging.requirements.InvalidRequirement: if a specifier is not valid """ resolver = DependencyGroupResolver(dependency_groups) return tuple(str(r) for group in groups for r in resolver.resolve(group)) def resolve_all( dependency_groups: Mapping[str, Sequence[str | Mapping[str, str]]], /, *, normalize: bool = False, ) -> Mapping[str, tuple[str, ...]]: """ Resolve all dependency groups, returning a mapping of group names to resolved requirements. :param dependency_groups: the parsed contents of the ``[dependency-groups]`` table from ``pyproject.toml`` :param normalize: if True normalize names, otherwise use original names when returning keys, but still normalize for lookup. Defaults to False. :raises TypeError: if the inputs appear to be the wrong types :raises ValueError: if the data does not appear to be valid dependency group data :raises packaging.requirements.InvalidRequirement: if a specifier is not valid Example usage:: resolved = dependency_groups.resolve_all(dep_groups) # {'test': ('pytest', 'sqlalchemy'), 'runtime': ('sqlalchemy',)} """ resolver = DependencyGroupResolver(dependency_groups) resolved = resolver.resolve_all() if normalize: return { group: tuple(str(r) for r in requirements) for group, requirements in resolved.items() } else: # Map back to original names return { resolver._normalized_to_original.get(group, group): tuple( str(r) for r in requirements ) for group, requirements in resolved.items() } ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1787366575.0856662 dependency_groups-1.3.2/src/dependency_groups/_lint_dependency_groups.py0000644000000000000000000000330215242206257023743 0ustar00from __future__ import annotations import sys from ._argparse_compat import ArgumentParser from ._implementation import DependencyGroupResolver from ._toml_compat import tomllib def main(*, argv: list[str] | None = None) -> None: if tomllib is None: print( "Usage error: dependency-groups CLI requires tomli or Python 3.11+", file=sys.stderr, ) raise SystemExit(2) parser = ArgumentParser( description=( "Lint Dependency Groups for validity. " "This will eagerly load and check all of your Dependency Groups." ) ) parser.add_argument( "-f", "--pyproject-file", default="pyproject.toml", help="The pyproject.toml file. Defaults to trying in the current directory.", ) args = parser.parse_args(argv if argv is not None else sys.argv[1:]) with open(args.pyproject_file, "rb") as fp: pyproject = tomllib.load(fp) dependency_groups_raw = pyproject.get("dependency-groups", {}) errors: list[str] = [] try: resolver = DependencyGroupResolver(dependency_groups_raw) except (ValueError, TypeError) as e: errors.append(f"{type(e).__name__}: {e}") else: for groupname in resolver.dependency_groups: try: resolver.resolve(groupname) except (LookupError, ValueError, TypeError) as e: errors.append(f"{type(e).__name__}: {e}") if errors: print("errors encountered while examining dependency groups:") for msg in errors: print(f" {msg}") sys.exit(1) else: print("ok") sys.exit(0) if __name__ == "__main__": main() ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1787366575.0856662 dependency_groups-1.3.2/src/dependency_groups/_pip_wrapper.py0000644000000000000000000000364615242206257021543 0ustar00from __future__ import annotations import subprocess import sys from ._argparse_compat import ArgumentParser from ._implementation import DependencyGroupResolver from ._toml_compat import tomllib def _invoke_pip(deps: list[str]) -> None: subprocess.check_call([sys.executable, "-m", "pip", "install", *deps]) def main(*, argv: list[str] | None = None) -> None: if tomllib is None: print( "Usage error: dependency-groups CLI requires tomli or Python 3.11+", file=sys.stderr, ) raise SystemExit(2) parser = ArgumentParser(description="Install Dependency Groups.") parser.add_argument( "DEPENDENCY_GROUP", nargs="+", help="The dependency groups to install." ) parser.add_argument( "-f", "--pyproject-file", default="pyproject.toml", help="The pyproject.toml file. Defaults to trying in the current directory.", ) args = parser.parse_args(argv if argv is not None else sys.argv[1:]) with open(args.pyproject_file, "rb") as fp: pyproject = tomllib.load(fp) dependency_groups_raw = pyproject.get("dependency-groups", {}) errors: list[str] = [] resolved: list[str] = [] try: resolver = DependencyGroupResolver(dependency_groups_raw) except (ValueError, TypeError) as e: errors.append(f"{type(e).__name__}: {e}") else: for groupname in args.DEPENDENCY_GROUP: try: resolved.extend(str(r) for r in resolver.resolve(groupname)) except (LookupError, ValueError, TypeError) as e: errors.append(f"{type(e).__name__}: {e}") if errors: print("errors encountered while examining dependency groups:") for msg in errors: print(f" {msg}") sys.exit(1) if not resolved: print("Nothing to install") return _invoke_pip(resolved) if __name__ == "__main__": main() ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1787366575.0856662 dependency_groups-1.3.2/src/dependency_groups/_toml_compat.py0000644000000000000000000000041415242206257021517 0ustar00try: import tomllib except ImportError: try: import tomli as tomllib # type: ignore[no-redef, unused-ignore] except ModuleNotFoundError: # pragma: no cover tomllib = None # type: ignore[assignment, unused-ignore] __all__ = ("tomllib",) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1787366575.0856662 dependency_groups-1.3.2/src/dependency_groups/py.typed0000644000000000000000000000000015242206257020156 0ustar00././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1787366575.0856662 dependency_groups-1.3.2/tests/conftest.py0000644000000000000000000000304015242206257015523 0ustar00from __future__ import annotations import dataclasses import functools import typing as t import pytest if t.TYPE_CHECKING: import os from collections.abc import Mapping, Sequence # the "dependency_groups" argument type of the library APIs Groups = Mapping[str, Sequence[t.Union[str, Mapping[str, str]]]] class CliEntryPoint(t.Protocol): def __call__(self, *, argv: list[str] | None = None) -> None: ... @dataclasses.dataclass class CLIResult: code: int stdout: str stderr: str @dataclasses.dataclass class CliRunner: entry_point: CliEntryPoint capsys: pytest.CaptureFixture[str] def __call__( self, *argv: str | os.PathLike[str], expect_exit_code: int | None = 0 ) -> CLIResult: __tracebackhide__ = True rc = 0 try: self.entry_point(argv=[str(arg) for arg in argv]) except SystemExit as e: assert isinstance(e.code, int), f"CLI exited with non-int code: {e.code!r}" rc = e.code stdio = self.capsys.readouterr() result = CLIResult(rc, stdio.out, stdio.err) if expect_exit_code is not None and result.code != expect_exit_code: pytest.fail( f"Expected exit({expect_exit_code}), saw exit({result.code}). " f"Result: {result}" ) return result RunnerFactory = t.Callable[[CliEntryPoint], CliRunner] @pytest.fixture def runner_factory(capsys: pytest.CaptureFixture[str]) -> RunnerFactory: return functools.partial(CliRunner, capsys=capsys) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1787366575.0860956 dependency_groups-1.3.2/tests/test_lint_cli.py0000644000000000000000000000352315242206257016540 0ustar00from __future__ import annotations import typing as t import pytest if t.TYPE_CHECKING: import pathlib from conftest import CliRunner, RunnerFactory @pytest.fixture def run(runner_factory: RunnerFactory) -> CliRunner: from dependency_groups._lint_dependency_groups import main as cli_main return runner_factory(cli_main) def test_lint_no_groups_ok(run: CliRunner, tmp_path: pathlib.Path) -> None: tomlfile = tmp_path / "pyproject.toml" tomlfile.write_text("[project]\n") res = run("-f", tomlfile) assert res.stdout == "ok\n" assert res.stderr == "" def test_lint_bad_group_item(run: CliRunner, tmp_path: pathlib.Path) -> None: tomlfile = tmp_path / "pyproject.toml" tomlfile.write_text( """\ [dependency-groups] foo = [{badkey = "value"}] """ ) res = run("-f", tomlfile, expect_exit_code=1) assert ( res.stdout == """\ errors encountered while examining dependency groups: ValueError: Invalid dependency group item: {'badkey': 'value'} """ ) assert res.stderr == "" def test_no_toml_failure( run: CliRunner, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setattr("dependency_groups._lint_dependency_groups.tomllib", None) tomlfile = tmp_path / "pyproject.toml" tomlfile.write_text("") res = run("-f", tomlfile, expect_exit_code=2) assert "requires tomli or Python 3.11+" in res.stderr def test_dependency_groups_list_format(run: CliRunner, tmp_path: pathlib.Path) -> None: tomlfile = tmp_path / "pyproject.toml" tomlfile.write_text("[[dependency-groups]]") res = run("-f", tomlfile, expect_exit_code=1) assert ( res.stdout == """\ errors encountered while examining dependency groups: TypeError: Dependency Groups table is not a mapping """ ) assert res.stderr == "" ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1787366575.0860956 dependency_groups-1.3.2/tests/test_main_cli.py0000644000000000000000000000171315242206257016515 0ustar00from __future__ import annotations import typing as t import pytest if t.TYPE_CHECKING: import pathlib from conftest import CliRunner, RunnerFactory PYPROJECT = """\ [dependency-groups] test = ["pytest"] docs = ["sphinx"] """ @pytest.fixture def run(runner_factory: RunnerFactory) -> CliRunner: from dependency_groups.__main__ import main as cli_main return runner_factory(cli_main) def test_list_to_stdout(run: CliRunner, tmp_path: pathlib.Path) -> None: tomlfile = tmp_path / "pyproject.toml" tomlfile.write_text(PYPROJECT) res = run("-f", tomlfile, "--list") assert res.stdout == "test docs\n" def test_list_respects_output_file(run: CliRunner, tmp_path: pathlib.Path) -> None: tomlfile = tmp_path / "pyproject.toml" tomlfile.write_text(PYPROJECT) outfile = tmp_path / "out.txt" res = run("-f", tomlfile, "--list", "-o", outfile) assert res.stdout == "" assert outfile.read_text() == "test docs\n" ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1787366575.0860956 dependency_groups-1.3.2/tests/test_pip_wrapper_cli.py0000644000000000000000000000167615242206257020131 0ustar00from __future__ import annotations import typing as t import pytest if t.TYPE_CHECKING: import pathlib from conftest import CliRunner, RunnerFactory @pytest.fixture def invoked_pip_args(monkeypatch: pytest.MonkeyPatch) -> list[list[str]]: calls: list[list[str]] = [] monkeypatch.setattr("dependency_groups._pip_wrapper._invoke_pip", calls.append) return calls @pytest.fixture def run(runner_factory: RunnerFactory, invoked_pip_args: list[list[str]]) -> CliRunner: from dependency_groups._pip_wrapper import main as cli_main return runner_factory(cli_main) def test_empty_group_skips_pip( run: CliRunner, invoked_pip_args: list[list[str]], tmp_path: pathlib.Path ) -> None: tomlfile = tmp_path / "pyproject.toml" tomlfile.write_text( """\ [dependency-groups] empty = [] """ ) res = run("-f", tomlfile, "empty") assert invoked_pip_args == [] assert "Nothing to install" in res.stdout ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1787366575.0860956 dependency_groups-1.3.2/tests/test_resolve_func.py0000644000000000000000000002301215242206257017430 0ustar00from __future__ import annotations import typing as t import pytest from dependency_groups import resolve, resolve_all if t.TYPE_CHECKING: from conftest import Groups def test_empty_group() -> None: groups: Groups = {"test": []} assert resolve(groups, "test") == () def test_str_list_group() -> None: groups = {"test": ["pytest"]} assert resolve(groups, "test") == ("pytest",) def test_single_include_group() -> None: groups: Groups = { "test": [ "pytest", {"include-group": "runtime"}, ], "runtime": ["sqlalchemy"], } assert set(resolve(groups, "test")) == {"pytest", "sqlalchemy"} def test_sdual_include_group() -> None: groups = { "test": [ "pytest", ], "runtime": ["sqlalchemy"], } assert set(resolve(groups, "test", "runtime")) == {"pytest", "sqlalchemy"} def test_normalized_group_name() -> None: groups = { "TEST": ["pytest"], } assert resolve(groups, "test") == ("pytest",) def test_malformed_group_data() -> None: groups: t.Any = [{"test": ["pytest"]}] with pytest.raises(TypeError, match="Dependency Groups table is not a mapping"): resolve(groups, "test") def test_malformed_group_query() -> None: groups = {"test": ["pytest"]} with pytest.raises(TypeError, match="Dependency group name is not a str"): resolve(groups, 0) # type: ignore[arg-type] def test_no_such_group_name() -> None: groups = { "test": ["pytest"], } with pytest.raises(LookupError, match="'testing' not found"): resolve(groups, "testing") def test_duplicate_normalized_name() -> None: groups = { "test": ["pytest"], "TEST": ["nose2"], } with pytest.raises( ValueError, match=r"Duplicate dependency group names: test \((test, TEST)|(TEST, test)\)", ): resolve(groups, "test") def test_cyclic_include() -> None: groups: Groups = { "group1": [ {"include-group": "group2"}, ], "group2": [ {"include-group": "group1"}, ], } with pytest.raises( ValueError, match=( "Cyclic dependency group include while resolving group1: " "group1 -> group2, group2 -> group1" ), ): resolve(groups, "group1") def test_cyclic_include_many_steps() -> None: groups: Groups = {} for i in range(100): groups[f"group{i}"] = [{"include-group": f"group{i + 1}"}] groups["group100"] = [{"include-group": "group0"}] with pytest.raises( ValueError, match="Cyclic dependency group include while resolving group0:", ): resolve(groups, "group0") def test_cyclic_include_self() -> None: groups: Groups = { "group1": [ {"include-group": "group1"}, ], } with pytest.raises( ValueError, match=( "Cyclic dependency group include while resolving group1: " "group1 includes itself" ), ): resolve(groups, "group1") def test_cyclic_include_ring_under_root() -> None: groups: Groups = { "root": [ {"include-group": "group1"}, ], "group1": [ {"include-group": "group2"}, ], "group2": [ {"include-group": "group1"}, ], } with pytest.raises( ValueError, match=( "Cyclic dependency group include while resolving root: " "group1 -> group2, group2 -> group1" ), ): resolve(groups, "root") def test_non_list_data() -> None: groups: t.Any = {"test": "pytest, coverage"} with pytest.raises( TypeError, match="Dependency group 'test' contained a string rather than a sequence.", ): resolve(groups, "test") @pytest.mark.parametrize( "item", ( {}, {"foo": "bar"}, {"include-group": "testing", "foo": "bar"}, object(), ), ) def test_unknown_object_shape(item: object) -> None: groups: t.Any = {"test": [item]} with pytest.raises(ValueError, match="Invalid dependency group item:"): resolve(groups, "test") def test_non_str_include_group_value() -> None: groups: t.Any = {"test": [{"include-group": 5}]} with pytest.raises( TypeError, match="Invalid include-group value, must be a string:" ): resolve(groups, "test") def test_mapping_include_group_item() -> None: import types groups: Groups = { "test": [ "pytest", types.MappingProxyType({"include-group": "runtime"}), ], "runtime": ["sqlalchemy"], } assert set(resolve(groups, "test")) == {"pytest", "sqlalchemy"} def test_resolve_all_empty() -> None: groups: Groups = {} assert resolve_all(groups) == {} def test_resolve_all_single_group() -> None: groups = {"test": ["pytest"]} assert resolve_all(groups) == {"test": ("pytest",)} def test_resolve_all_multiple_groups() -> None: groups: Groups = { "test": ["pytest", {"include-group": "runtime"}], "runtime": ["sqlalchemy"], } result = resolve_all(groups) assert "test" in result assert "runtime" in result assert set(result["test"]) == {"pytest", "sqlalchemy"} assert result["runtime"] == ("sqlalchemy",) def test_resolve_all_with_normalize_false() -> None: groups = { "TEST": ["pytest"], } result = resolve_all(groups, normalize=False) assert "TEST" in result assert result["TEST"] == ("pytest",) def test_resolve_all_with_normalize_true() -> None: groups = { "TEST": ["pytest"], } result = resolve_all(groups, normalize=True) assert "test" in result assert result["test"] == ("pytest",) def test_resolve_all_default_preserves_names() -> None: """Test that normalize=False is the default.""" groups = { "TEST": ["pytest"], } result = resolve_all(groups) assert "TEST" in result assert "test" not in result assert result["TEST"] == ("pytest",) def test_resolve_all_shared_includes() -> None: """Test that resolve_all correctly handles groups with shared includes. Structure: dev = [{include-group = "test"}, {include-group = "lint"}] test = [{include-group = "pytest"}, {include-group = "coverage"}] lint = ["prek", {include-group = "typing"}] typing = ["mypy"] pytest = ["pytest>=7"] coverage = ["coverage[toml]"] """ groups: Groups = { "dev": [{"include-group": "test"}, {"include-group": "lint"}], "test": [{"include-group": "pytest"}, {"include-group": "coverage"}], "lint": ["prek", {"include-group": "typing"}], "typing": ["mypy"], "pytest": ["pytest>=7"], "coverage": ["coverage[toml]"], } result = resolve_all(groups) # Verify all groups are present assert set(result.keys()) == {"dev", "test", "lint", "typing", "pytest", "coverage"} # Verify each group has correct contents assert set(result["typing"]) == {"mypy"} assert set(result["pytest"]) == {"pytest>=7"} assert set(result["coverage"]) == {"coverage[toml]"} assert set(result["lint"]) == {"prek", "mypy"} assert set(result["test"]) == {"pytest>=7", "coverage[toml]"} assert set(result["dev"]) == {"pytest>=7", "coverage[toml]", "prek", "mypy"} def test_resolve_all_uses_cache() -> None: """Test that resolve_all uses the resolver's internal cache properly. This tests that calling resolve_all uses the optimized resolve_all method which avoids duplicate work when groups share includes. """ from dependency_groups._implementation import DependencyGroupResolver groups: Groups = { "dev": [{"include-group": "test"}, {"include-group": "lint"}], "test": [{"include-group": "pytest"}, {"include-group": "coverage"}], "lint": ["prek", {"include-group": "typing"}], "typing": ["mypy"], "pytest": ["pytest>=7"], "coverage": ["coverage[toml]"], } # Create a resolver and check that resolve_all populates the cache resolver = DependencyGroupResolver(groups) assert len(resolver._resolve_cache) == 0 resolved = resolver.resolve_all() # All groups should now be in the cache assert len(resolver._resolve_cache) == len(groups) for group in resolver.dependency_groups: assert group in resolver._resolve_cache # Verify the results are correct (resolve_all returns Requirement objects) assert {str(r) for r in resolved["dev"]} == { "pytest>=7", "coverage[toml]", "prek", "mypy", } assert {str(r) for r in resolved["test"]} == {"pytest>=7", "coverage[toml]"} assert {str(r) for r in resolved["lint"]} == {"prek", "mypy"} def test_resolve_all_vs_individual_resolve() -> None: """Test that resolve_all produces the same results as individual resolve calls.""" groups: Groups = { "dev": [{"include-group": "test"}, {"include-group": "lint"}], "test": [{"include-group": "pytest"}, {"include-group": "coverage"}], "lint": ["prek", {"include-group": "typing"}], "typing": ["mypy"], "pytest": ["pytest>=7"], "coverage": ["coverage[toml]"], } # Using resolve_all result_all = resolve_all(groups) # Using individual resolve calls result_individual = {group: resolve(groups, group) for group in groups} assert set(result_all.keys()) == set(result_individual.keys()) for group in groups: assert set(result_all[group]) == set(result_individual[group]) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1787366575.0860956 dependency_groups-1.3.2/tests/test_resolver_class.py0000644000000000000000000001224515242206257017772 0ustar00from __future__ import annotations import typing as t import unittest.mock import pytest from packaging.requirements import Requirement from dependency_groups import DependencyGroupInclude, DependencyGroupResolver if t.TYPE_CHECKING: from conftest import Groups def test_resolver_init_handles_bad_type() -> None: with pytest.raises(TypeError): DependencyGroupResolver([]) # type: ignore[arg-type] def test_resolver_init_catches_normalization_conflict() -> None: groups = {"test": ["pytest"], "Test": ["pytest", "coverage"]} with pytest.raises(ValueError, match="Duplicate dependency group names"): DependencyGroupResolver(groups) def test_lookup_catches_bad_type() -> None: groups = {"test": ["pytest"]} resolver = DependencyGroupResolver(groups) with pytest.raises(TypeError): resolver.lookup(0) # type: ignore[arg-type] def test_lookup_on_trivial_normalization() -> None: groups = {"test": ["pytest"]} resolver = DependencyGroupResolver(groups) parsed_group = resolver.lookup("Test") assert len(parsed_group) == 1 assert isinstance(parsed_group[0], Requirement) req = parsed_group[0] assert req.name == "pytest" def test_lookup_with_include_result() -> None: groups: Groups = { "test": ["pytest", {"include-group": "runtime"}], "runtime": ["click"], } resolver = DependencyGroupResolver(groups) parsed_group = resolver.lookup("test") assert len(parsed_group) == 2 assert isinstance(parsed_group[0], Requirement) assert parsed_group[0].name == "pytest" assert isinstance(parsed_group[1], DependencyGroupInclude) assert parsed_group[1].include_group == "runtime" def test_lookup_does_not_trigger_cyclic_include() -> None: groups: Groups = { "group1": [{"include-group": "group2"}], "group2": [{"include-group": "group1"}], } resolver = DependencyGroupResolver(groups) parsed_group = resolver.lookup("group1") assert len(parsed_group) == 1 assert isinstance(parsed_group[0], DependencyGroupInclude) assert parsed_group[0].include_group == "group2" def test_expand_contract_model_only_does_inner_lookup_once() -> None: groups: Groups = { "root": [ {"include-group": "mid1"}, {"include-group": "mid2"}, {"include-group": "mid3"}, {"include-group": "mid4"}, ], "mid1": [{"include-group": "contract"}], "mid2": [{"include-group": "contract"}], "mid3": [{"include-group": "contract"}], "mid4": [{"include-group": "contract"}], "contract": [{"include-group": "leaf"}], "leaf": ["attrs"], } resolver = DependencyGroupResolver(groups) real_inner_resolve = resolver._resolve with unittest.mock.patch( "dependency_groups.DependencyGroupResolver._resolve", side_effect=real_inner_resolve, ) as spy: resolved = resolver.resolve("root") assert len(resolved) == 4 assert all(item.name == "attrs" for item in resolved) # each of the `mid` nodes will call resolution with `contract`, but only the # first of those evaluations should call for resolution of `leaf` -- after that, # `contract` will be in the cache and `leaf` will not need to be resolved spy.assert_any_call("leaf", "root") leaf_calls = [c for c in spy.mock_calls if c.args[0] == "leaf"] assert len(leaf_calls) == 1 def test_no_double_parse() -> None: groups: Groups = { "test": [{"include-group": "runtime"}], "runtime": ["click"], } resolver = DependencyGroupResolver(groups) parse = resolver.lookup("test") assert len(parse) == 1 assert isinstance(parse[0], DependencyGroupInclude) assert parse[0].include_group == "runtime" mock_include = DependencyGroupInclude(include_group="perfidy") with unittest.mock.patch( "dependency_groups._implementation.DependencyGroupInclude", return_value=mock_include, ): # rerunning with that resolver will not re-resolve reparse = resolver.lookup("test") assert len(reparse) == 1 assert isinstance(reparse[0], DependencyGroupInclude) assert reparse[0].include_group == "runtime" # but verify that a fresh resolver (no cache) will get the mock deceived_resolver = DependencyGroupResolver(groups) deceived_parse = deceived_resolver.lookup("test") assert len(deceived_parse) == 1 assert isinstance(deceived_parse[0], DependencyGroupInclude) assert deceived_parse[0].include_group == "perfidy" @pytest.mark.parametrize("group_name_declared", ("foo-bar", "foo_bar", "foo..bar")) @pytest.mark.parametrize("group_name_used", ("foo-bar", "foo_bar", "foo..bar")) def test_normalized_name_is_used_for_include_group_lookups( group_name_declared: str, group_name_used: str ) -> None: groups: Groups = { group_name_declared: ["spam"], "eggs": [{"include-group": group_name_used}], } resolver = DependencyGroupResolver(groups) result = resolver.resolve("eggs") assert len(result) == 1 assert isinstance(result[0], Requirement) req = result[0] assert req.name == "spam" dependency_groups-1.3.2/PKG-INFO0000644000000000000000000000445700000000000013230 0ustar00Metadata-Version: 2.5 Name: dependency-groups Version: 1.3.2 Summary: A tool for resolving PEP 735 Dependency Group data Keywords: Author-email: Stephen Rosen Requires-Python: >=3.9 Description-Content-Type: text/x-rst License-Expression: MIT Classifier: Development Status :: 5 - Production/Stable Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: 3.9 Classifier: Programming Language :: Python :: 3.10 Classifier: Programming Language :: Python :: 3.11 Classifier: Programming Language :: Python :: 3.12 Classifier: Programming Language :: Python :: 3.13 Classifier: Programming Language :: Python :: 3.14 Classifier: Programming Language :: Python :: 3.15 Classifier: Programming Language :: Python :: Implementation :: CPython Classifier: Programming Language :: Python :: Implementation :: PyPy License-File: LICENSE.txt Requires-Dist: packaging Requires-Dist: tomli;python_version<'3.11' Requires-Dist: tomli ; extra == "cli" and ( python_version<'3.11') Project-URL: changelog, https://github.com/pypa/dependency-groups/blob/main/CHANGELOG.rst Project-URL: documentation, https://dependency-groups.readthedocs.io/ Project-URL: source, https://github.com/pypa/dependency-groups Provides-Extra: cli Import-Name: dependency_groups Dependency Groups ================= An implementation of Dependency Groups (`PEP 735 `_). This is a library which is able to parse dependency groups, following includes, and provide that data as output. Interfaces ---------- ``dependency-groups`` provides the following: - A ``DependencyGroupResolver`` which implements efficient resolution of dependency groups - A ``resolve()`` function which converts a dependency group name to a list of strings (powered by the resolver) - Three CLI commands: - ``python -m dependency_groups GROUPNAME`` prints a dependency group's contents - ``lint-dependency-groups`` loads all dependency groups to check for correctness - ``pip-install-dependency-groups GROUPNAME...`` wraps a ``pip`` invocation to install the contents of a dependency group - A pre-commit hooks which runs ``lint-dependency-groups`` Documentation ------------- Full documentation is available on `the Dependency Groups doc site `_.