././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1746146036.9602509 dependency_groups-1.3.1/CHANGELOG.rst0000644000000000000000000000334615005011365014202 0ustar00CHANGELOG ========= Unreleased ---------- 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=1746146036.9602509 dependency_groups-1.3.1/LICENSE.txt0000644000000000000000000000211315005011365013773 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. ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1746146036.961251 dependency_groups-1.3.1/README.rst0000644000000000000000000000203515005011365013642 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 `_. ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1746146036.961251 dependency_groups-1.3.1/pyproject.toml0000644000000000000000000000520515005011365015071 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"] build = ["twine", "build"] dev = [{include-group = "test"}] [project] name = "dependency-groups" version = "1.3.1" description = 'A tool for resolving PEP 735 Dependency Group data' readme = "README.rst" requires-python = ">=3.8" 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.8", "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 :: 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.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"] [tool.isort] profile = "black" known_first_party = ["mddj"] [tool.check-sdist] git-only = [".*", "Makefile", "docs/*", "scripts/*"] ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1746146036.961251 dependency_groups-1.3.1/src/dependency_groups/__init__.py0000644000000000000000000000037215005011365020572 0ustar00from ._implementation import ( CyclicDependencyError, DependencyGroupInclude, DependencyGroupResolver, resolve, ) __all__ = ( "CyclicDependencyError", "DependencyGroupInclude", "DependencyGroupResolver", "resolve", ) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1746146036.961251 dependency_groups-1.3.1/src/dependency_groups/__main__.py0000644000000000000000000000325515005011365020556 0ustar00import argparse import sys from ._implementation import resolve from ._toml_compat import tomllib def main() -> None: if tomllib is None: print( "Usage error: dependency-groups CLI requires tomli or Python 3.11+", file=sys.stderr, ) raise SystemExit(2) parser = argparse.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() with open(args.pyproject_file, "rb") as fp: pyproject = tomllib.load(fp) dependency_groups_raw = pyproject.get("dependency-groups", {}) if args.list: print(*dependency_groups_raw.keys()) return if not args.GROUP_NAME: print("A GROUP_NAME is required", file=sys.stderr) raise SystemExit(3) 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() ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1746146036.962251 dependency_groups-1.3.1/src/dependency_groups/_implementation.py0000644000000000000000000001753515005011365022230 0ustar00from __future__ import annotations import dataclasses import re from collections.abc import Mapping 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, str | Mapping[str, str]], ) -> Mapping[str, str | Mapping[str, str]]: original_names: dict[str, list[str]] = {} normalized_groups = {} 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 = [] for normed_name, names in original_names.items(): if len(names) > 1: errors.append(f"{normed_name} ({', '.join(names)})") if errors: raise ValueError(f"Duplicate dependency group names: {', '.join(errors)}") return normalized_groups @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, str | Mapping[str, str]], ) -> None: if not isinstance(dependency_groups, Mapping): raise TypeError("Dependency Groups table is not a mapping") self.dependency_groups = _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 not isinstance(raw_group, list): raise TypeError(f"Dependency group '{group}' is not a list") 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, dict): if tuple(item.keys()) != ("include-group",): raise ValueError(f"Invalid dependency group item: {item}") include_group = next(iter(item.values())) elements.append(DependencyGroupInclude(include_group=include_group)) else: raise ValueError(f"Invalid dependency group item: {item}") self._parsed_groups[group] = tuple(elements) return self._parsed_groups[group] 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}" ) self._resolve_cache[group] = tuple(resolved_group) return self._resolve_cache[group] def resolve( dependency_groups: Mapping[str, 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)) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1746146036.962251 dependency_groups-1.3.1/src/dependency_groups/_lint_dependency_groups.py0000644000000000000000000000325615005011365023741 0ustar00from __future__ import annotations import argparse import sys 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 = argparse.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() ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1746146036.962251 dependency_groups-1.3.1/src/dependency_groups/_pip_wrapper.py0000644000000000000000000000351115005011365021520 0ustar00from __future__ import annotations import argparse import subprocess import sys 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 = argparse.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) _invoke_pip(resolved) if __name__ == "__main__": main() ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1746146036.962251 dependency_groups-1.3.1/src/dependency_groups/_toml_compat.py0000644000000000000000000000041415005011365021505 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",) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1746146036.962251 dependency_groups-1.3.1/src/dependency_groups/py.typed0000644000000000000000000000000015005011365020144 0ustar00././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1746146036.962251 dependency_groups-1.3.1/tests/test_lint_cli.py0000644000000000000000000000357215005011365016532 0ustar00import dataclasses import pytest @dataclasses.dataclass class CLIResult: code: int stdout: str stderr: str @pytest.fixture def run(capsys): from dependency_groups._lint_dependency_groups import main as cli_main def _run(*argv): try: cli_main(argv=[str(arg) for arg in argv]) rc = 0 except SystemExit as e: rc = e.code stdio = capsys.readouterr() return CLIResult(rc, stdio.out, stdio.err) return _run def test_lint_no_groups_ok(run, tmp_path): tomlfile = tmp_path / "pyproject.toml" tomlfile.write_text("[project]\n") res = run("-f", tomlfile) assert res.code == 0 assert res.stdout == "ok\n" assert res.stderr == "" def test_lint_bad_group_item(run, tmp_path): tomlfile = tmp_path / "pyproject.toml" tomlfile.write_text( """\ [dependency-groups] foo = [{badkey = "value"}] """ ) res = run("-f", tomlfile) assert res.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, tmp_path, monkeypatch): monkeypatch.setattr("dependency_groups._lint_dependency_groups.tomllib", None) tomlfile = tmp_path / "pyproject.toml" tomlfile.write_text("") res = run("-f", tomlfile) assert res.code == 2 assert "requires tomli or Python 3.11+" in res.stderr def test_dependency_groups_list_format(run, tmp_path): tomlfile = tmp_path / "pyproject.toml" tomlfile.write_text("[[dependency-groups]]") res = run("-f", tomlfile) assert res.code == 1 assert ( res.stdout == """\ errors encountered while examining dependency groups: TypeError: Dependency Groups table is not a mapping """ ) assert res.stderr == "" ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1746146036.962251 dependency_groups-1.3.1/tests/test_resolve_func.py0000644000000000000000000000742315005011365017426 0ustar00import pytest from dependency_groups import resolve def test_empty_group(): groups = {"test": []} assert resolve(groups, "test") == () def test_str_list_group(): groups = {"test": ["pytest"]} assert resolve(groups, "test") == ("pytest",) def test_single_include_group(): groups = { "test": [ "pytest", {"include-group": "runtime"}, ], "runtime": ["sqlalchemy"], } assert set(resolve(groups, "test")) == {"pytest", "sqlalchemy"} def test_sdual_include_group(): groups = { "test": [ "pytest", ], "runtime": ["sqlalchemy"], } assert set(resolve(groups, "test", "runtime")) == {"pytest", "sqlalchemy"} def test_normalized_group_name(): groups = { "TEST": ["pytest"], } assert resolve(groups, "test") == ("pytest",) def test_malformed_group_data(): groups = [{"test": ["pytest"]}] with pytest.raises(TypeError, match="Dependency Groups table is not a mapping"): resolve(groups, "test") def test_malformed_group_query(): groups = {"test": ["pytest"]} with pytest.raises(TypeError, match="Dependency group name is not a str"): resolve(groups, 0) def test_no_such_group_name(): groups = { "test": ["pytest"], } with pytest.raises(LookupError, match="'testing' not found"): resolve(groups, "testing") def test_duplicate_normalized_name(): 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(): 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(): 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(): 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(): 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(): groups = {"test": "pytest, coverage"} with pytest.raises(TypeError, match="Dependency group 'test' is not a list"): resolve(groups, "test") @pytest.mark.parametrize( "item", ( {}, {"foo": "bar"}, {"include-group": "testing", "foo": "bar"}, object(), ), ) def test_unknown_object_shape(item): groups = {"test": [item]} with pytest.raises(ValueError, match="Invalid dependency group item:"): resolve(groups, "test") ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1746146036.962251 dependency_groups-1.3.1/tests/test_resolver_class.py0000644000000000000000000001161315005011365017756 0ustar00import unittest.mock import pytest from packaging.requirements import Requirement from dependency_groups import DependencyGroupInclude, DependencyGroupResolver def test_resolver_init_handles_bad_type(): with pytest.raises(TypeError): DependencyGroupResolver([]) def test_resolver_init_catches_normalization_conflict(): groups = {"test": ["pytest"], "Test": ["pytest", "coverage"]} with pytest.raises(ValueError, match="Duplicate dependency group names"): DependencyGroupResolver(groups) def test_lookup_catches_bad_type(): groups = {"test": ["pytest"]} resolver = DependencyGroupResolver(groups) with pytest.raises(TypeError): resolver.lookup(0) def test_lookup_on_trivial_normalization(): 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(): 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(): 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(): 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(): 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, group_name_used ): 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" ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1746146036.962251 dependency_groups-1.3.1/tox.ini0000644000000000000000000000303115005011365013463 0ustar00[tox] env_list = lint mypy covclean py{38,39,310,311,312,313} covcombine covreport labels = ci = py{38,39,310,311,312,313}, covcombine, covreport ci-mypy = mypy-py38, mypy-py313 ci-package-check = twine-check minversion = 4.22.0 [testenv] package = wheel wheel_build_env = build_wheel dependency_groups = test commands = coverage run -m pytest -v {posargs} depends = py{38,39,310,311,312},py: clean covcombine: py,py{38,39,310,311,312} covreport: covcombine [testenv:covclean] skip_install = true dependency_groups = coverage commands = coverage erase [testenv:covcombine] skip_install = true dependency_groups = coverage commands = coverage combine [testenv:covreport] skip_install = true dependency_groups = coverage commands_pre = coverage html --fail-under=0 commands = coverage report [testenv:lint] dependency_groups = lint commands = pre-commit run -a [testenv:mypy,mypy-{py38,py313}] dependency_groups = typing commands = mypy src/ [testenv: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/* [testenv: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 {posargs} dependency_groups-1.3.1/PKG-INFO0000644000000000000000000000433400000000000013221 0ustar00Metadata-Version: 2.4 Name: dependency-groups Version: 1.3.1 Summary: A tool for resolving PEP 735 Dependency Group data Keywords: Author-email: Stephen Rosen Requires-Python: >=3.8 Description-Content-Type: text/x-rst License-Expression: MIT Classifier: Development Status :: 5 - Production/Stable Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: 3.8 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 :: 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 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 `_.