├── docs
├── .gitignore
├── changelog.rst
├── functional_interface.rst
├── index.rst
├── conf.py
├── resolver.rst
└── cli_usage.rst
├── src
└── dependency_groups
│ ├── py.typed
│ ├── __init__.py
│ ├── _toml_compat.py
│ ├── _argparse_compat.py
│ ├── _lint_dependency_groups.py
│ ├── __main__.py
│ ├── _pip_wrapper.py
│ └── _implementation.py
├── .flake8
├── .docs-requirements.txt
├── .gitignore
├── .github
├── dependabot.yaml
└── workflows
│ ├── readthedocs-pr-links.yaml
│ ├── publish_to_pypi.yaml
│ ├── publish_to_test_pypi.yaml
│ └── build.yaml
├── .readthedocs.yml
├── .pre-commit-hooks.yaml
├── Makefile
├── LICENSE.txt
├── README.rst
├── scripts
├── set-dev-version.py
└── bump-version.py
├── .pre-commit-config.yaml
├── CHANGELOG.rst
├── tests
├── test_lint_cli.py
├── test_resolve_func.py
└── test_resolver_class.py
└── pyproject.toml
/docs/.gitignore:
--------------------------------------------------------------------------------
1 | /_build
2 |
--------------------------------------------------------------------------------
/src/dependency_groups/py.typed:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs/changelog.rst:
--------------------------------------------------------------------------------
1 | .. include:: ../CHANGELOG.rst
2 |
--------------------------------------------------------------------------------
/.flake8:
--------------------------------------------------------------------------------
1 | [flake8]
2 | exclude = .git,.tox,__pycache__,.eggs,dist,.venv*
3 | max-line-length = 88
4 | extend-ignore = W503,W504,E203
5 |
--------------------------------------------------------------------------------
/.docs-requirements.txt:
--------------------------------------------------------------------------------
1 | # created with
2 | #
3 | # python -m dependency_groups docs > .docs_requirements.txt
4 | #
5 | sphinx>=8.1
6 | sphinx-issues>=5
7 | furo
8 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | build
2 | _build
3 | MANIFEST
4 | dist
5 | *.pyc
6 | .tox
7 | *.egg-info
8 | eggs/
9 | .eggs/
10 |
11 | # pytest
12 | .pytest_cache
13 | .coverage
14 | .coverage.*
15 |
--------------------------------------------------------------------------------
/.github/dependabot.yaml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: "github-actions"
4 | directory: "/"
5 | schedule:
6 | interval: "monthly"
7 | groups:
8 | github-actions:
9 | patterns:
10 | - "*"
11 |
--------------------------------------------------------------------------------
/.readthedocs.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 |
3 | sphinx:
4 | configuration: docs/conf.py
5 | fail_on_warning: true
6 |
7 | build:
8 | os: "ubuntu-22.04"
9 | tools:
10 | python: "3.12"
11 |
12 | python:
13 | install:
14 | - method: pip
15 | path: .
16 | - requirements: ".docs-requirements.txt"
17 |
--------------------------------------------------------------------------------
/.pre-commit-hooks.yaml:
--------------------------------------------------------------------------------
1 | - id: lint-dependency-groups
2 | name: "Check '[dependency-groups]' in pyproject.toml"
3 | description: 'Read all Dependency Groups and validate that their contents can resolve'
4 | entry: lint-dependency-groups
5 | language: python
6 | types: [toml]
7 | files: ^pyproject\.toml$
8 | pass_filenames: false
9 |
--------------------------------------------------------------------------------
/src/dependency_groups/__init__.py:
--------------------------------------------------------------------------------
1 | from ._implementation import (
2 | CyclicDependencyError,
3 | DependencyGroupInclude,
4 | DependencyGroupResolver,
5 | resolve,
6 | )
7 |
8 | __all__ = (
9 | "CyclicDependencyError",
10 | "DependencyGroupInclude",
11 | "DependencyGroupResolver",
12 | "resolve",
13 | )
14 |
--------------------------------------------------------------------------------
/src/dependency_groups/_toml_compat.py:
--------------------------------------------------------------------------------
1 | try:
2 | import tomllib
3 | except ImportError:
4 | try:
5 | import tomli as tomllib # type: ignore[no-redef, unused-ignore]
6 | except ModuleNotFoundError: # pragma: no cover
7 | tomllib = None # type: ignore[assignment, unused-ignore]
8 |
9 | __all__ = ("tomllib",)
10 |
--------------------------------------------------------------------------------
/.github/workflows/readthedocs-pr-links.yaml:
--------------------------------------------------------------------------------
1 | name: Read the Docs Pull Request Preview
2 | on:
3 | pull_request_target:
4 | types:
5 | - opened
6 |
7 | permissions:
8 | pull-requests: write
9 |
10 | jobs:
11 | documentation-links:
12 | runs-on: ubuntu-latest
13 | steps:
14 | - uses: readthedocs/actions/preview@b8bba1484329bda1a3abe986df7ebc80a8950333 # v1.5
15 | with:
16 | project-slug: "dependency-groups"
17 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | PKG_VERSION=$(shell grep '^version' pyproject.toml | head -n1 | cut -d '"' -f2)
2 |
3 | .PHONY: test release showvars
4 | test:
5 | tox run
6 | showvars:
7 | @echo "PKG_VERSION=$(PKG_VERSION)"
8 | release:
9 | git tag -s "$(PKG_VERSION)" -m "v$(PKG_VERSION)"
10 | -git push $(shell git rev-parse --abbrev-ref @{push} | cut -d '/' -f1) refs/tags/$(PKG_VERSION)
11 |
12 | .PHONY: clean
13 | clean:
14 | rm -rf dist build *.egg-info .tox .coverage.*
15 |
--------------------------------------------------------------------------------
/src/dependency_groups/_argparse_compat.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import argparse
4 | import functools
5 | import sys
6 |
7 | __all__ = ["ArgumentParser"]
8 |
9 |
10 | def __dir__() -> list[str]:
11 | return __all__
12 |
13 |
14 | ArgumentParser = functools.partial(argparse.ArgumentParser)
15 |
16 | if sys.version_info >= (3, 14):
17 | ArgumentParser = functools.partial(
18 | ArgumentParser, color=True, suggest_on_error=True
19 | )
20 |
--------------------------------------------------------------------------------
/docs/functional_interface.rst:
--------------------------------------------------------------------------------
1 | Functional Interface
2 | ====================
3 |
4 | .. autofunction:: dependency_groups.resolve
5 |
6 | Example usage:
7 |
8 | .. code-block:: toml
9 |
10 | # in pyproject.toml
11 | [dependency-groups]
12 | test = ["pytest", {include-group = "runtime"}]
13 | runtime = ["flask"]
14 |
15 | .. code-block:: python
16 |
17 | from dependency_groups import resolve
18 | import tomllib
19 |
20 | with open("pyproject.toml", "rb") as fp:
21 | pyproject = tomllib.load(fp)
22 |
23 | groups = pyproject["dependency-groups"]
24 |
25 | resolve(groups, "test") # ['pytest', 'flask']
26 |
--------------------------------------------------------------------------------
/docs/index.rst:
--------------------------------------------------------------------------------
1 | Dependency Groups
2 | =================
3 |
4 | .. module:: dependency_groups
5 |
6 | An implementation of Dependency Groups (`PEP 735 `_).
7 |
8 | The source code is hosted in `a GitHub repo
9 | `_, and bugs and
10 | features are tracked in the associated `issue tracker
11 | `_.
12 |
13 | .. toctree::
14 | :maxdepth: 2
15 | :caption: Contents:
16 |
17 | cli_usage
18 | functional_interface
19 | resolver
20 |
21 | .. toctree::
22 | :maxdepth: 1
23 | :caption: Change History:
24 |
25 | changelog
26 |
27 |
28 | License
29 | -------
30 |
31 | ``dependency-groups`` is distributed under the terms of the
32 | `MIT `_ license.
33 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024-present Stephen Rosen
4 |
5 | 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:
6 |
7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8 |
9 | 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.
10 |
--------------------------------------------------------------------------------
/README.rst:
--------------------------------------------------------------------------------
1 | Dependency Groups
2 | =================
3 |
4 | An implementation of Dependency Groups (`PEP 735 `_).
5 |
6 | This is a library which is able to parse dependency groups, following includes, and provide that data as output.
7 |
8 | Interfaces
9 | ----------
10 |
11 | ``dependency-groups`` provides the following:
12 |
13 | - A ``DependencyGroupResolver`` which implements efficient resolution of
14 | dependency groups
15 |
16 | - A ``resolve()`` function which converts a dependency group name to a list of
17 | strings (powered by the resolver)
18 |
19 | - Three CLI commands:
20 |
21 | - ``python -m dependency_groups GROUPNAME`` prints a dependency group's
22 | contents
23 |
24 | - ``lint-dependency-groups`` loads all dependency groups to check for
25 | correctness
26 |
27 | - ``pip-install-dependency-groups GROUPNAME...`` wraps a ``pip`` invocation
28 | to install the contents of a dependency group
29 |
30 | - A pre-commit hooks which runs ``lint-dependency-groups``
31 |
32 | Documentation
33 | -------------
34 |
35 | Full documentation is available on `the Dependency Groups doc site `_.
36 |
--------------------------------------------------------------------------------
/docs/conf.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import importlib.metadata
3 |
4 | project = "dependency-groups"
5 | copyright = f"2023-{datetime.datetime.today().strftime('%Y')}, Stephen Rosen"
6 | author = "Stephen Rosen"
7 |
8 | # The full version, including alpha/beta/rc tags
9 | release = importlib.metadata.version("dependency_groups")
10 |
11 | extensions = [
12 | "sphinx.ext.autodoc",
13 | "sphinx.ext.intersphinx",
14 | "sphinx.ext.viewcode",
15 | "sphinx_issues",
16 | ]
17 |
18 | intersphinx_mapping = {
19 | "python": ("https://docs.python.org/3", None),
20 | }
21 |
22 | issues_github_path = "pypa/dependency-groups"
23 |
24 | # List of patterns, relative to source directory, that match files and
25 | # directories to ignore when looking for source files.
26 | # This pattern also affects html_static_path and html_extra_path.
27 | exclude_patterns = ["_build"]
28 |
29 | # HTML theme options
30 | html_theme = "furo"
31 | pygments_style = "friendly"
32 | pygments_dark_style = "monokai" # this is a furo-specific option
33 | html_theme_options = {
34 | "source_repository": "https://github.com/pypa/dependency-groups/",
35 | "source_branch": "main",
36 | "source_directory": "docs/",
37 | }
38 |
--------------------------------------------------------------------------------
/docs/resolver.rst:
--------------------------------------------------------------------------------
1 | Resolver
2 | ========
3 |
4 | The library provides its resolution machinery via an object oriented interface,
5 | which allows users to explore the structure of data before or during
6 | resolution using ``DependencyGroupInclude`` and ``DependencyGroupResolver``.
7 |
8 | For example,
9 |
10 | .. code-block:: python
11 |
12 | from dependency_groups import DependencyGroupResolver
13 |
14 | groups = {
15 | "test": ["pytest", {"include-group": "runtime"}],
16 | "runtime": ["flask"],
17 | }
18 |
19 | resolver = DependencyGroupResolver(groups)
20 |
21 | # you can lookup a group without resolving it
22 | resolver.lookup("test") # [Requirement('pytest'), DependencyGroupInclude('runtime')]
23 |
24 | # and resolve() produces packaging Requirements
25 | resolver.resolve("test") # [Requirement('pytest'), Requirement('flask')]
26 |
27 | Models
28 | ------
29 |
30 | .. autoclass:: dependency_groups.DependencyGroupInclude
31 | :members:
32 |
33 | Resolver
34 | --------
35 |
36 | .. autoclass:: dependency_groups.DependencyGroupResolver
37 | :members:
38 |
39 | Errors
40 | ------
41 |
42 | .. autoclass:: dependency_groups.CyclicDependencyError
43 |
--------------------------------------------------------------------------------
/scripts/set-dev-version.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | import argparse
3 | import re
4 |
5 |
6 | def get_old_version():
7 | with open("pyproject.toml", encoding="utf-8") as fp:
8 | content = fp.read()
9 | match = re.search(r'^version = "(\d+\.\d+\.\d+)"$', content, flags=re.MULTILINE)
10 | assert match
11 | return match.group(1)
12 |
13 |
14 | def replace_version(filename, formatstr, old_version, new_version):
15 | print(f"updating {filename}")
16 | with open(filename, encoding="utf-8") as fp:
17 | content = fp.read()
18 | old_str = formatstr.format(old_version)
19 | new_str = formatstr.format(new_version)
20 | content = content.replace(old_str, new_str)
21 | with open(filename, "w", encoding="utf-8") as fp:
22 | fp.write(content)
23 |
24 |
25 | def main():
26 | parser = argparse.ArgumentParser()
27 | parser.add_argument(
28 | "-n", "--number", help="dev number to use, defaults to 1", type=int, default=1
29 | )
30 | args = parser.parse_args()
31 |
32 | old_version = get_old_version()
33 | new_version = old_version + f".dev{args.number}"
34 |
35 | replace_version("pyproject.toml", 'version = "{}"', old_version, new_version)
36 | print("done")
37 |
38 |
39 | if __name__ == "__main__":
40 | main()
41 |
--------------------------------------------------------------------------------
/.github/workflows/publish_to_pypi.yaml:
--------------------------------------------------------------------------------
1 | name: Publish PyPI Release
2 |
3 | on:
4 | release:
5 | types: [published]
6 |
7 | jobs:
8 | build-dists:
9 | runs-on: ubuntu-latest
10 |
11 | steps:
12 | - uses: actions/checkout@v6
13 | - uses: actions/setup-python@v6
14 | with:
15 | python-version: "3.12"
16 |
17 | - run: python -m pip install build twine
18 |
19 | - name: Build Dists
20 | run: python -m build .
21 |
22 | - name: Check Dists (twine)
23 | run: twine check dist/*
24 |
25 | - uses: actions/upload-artifact@v5
26 | with:
27 | name: packages
28 | path: dist/*
29 |
30 | publish_pypi:
31 | needs: [build-dists]
32 | runs-on: ubuntu-latest
33 | environment: publish
34 | permissions:
35 | id-token: write
36 |
37 | steps:
38 | - uses: actions/download-artifact@v6
39 | with:
40 | name: packages
41 | path: dist
42 |
43 | - name: Publish to PyPI
44 | uses: pypa/gh-action-pypi-publish@release/v1
45 |
46 | publish_release_asset:
47 | needs: [build-dists]
48 | runs-on: ubuntu-latest
49 |
50 | steps:
51 | - uses: actions/download-artifact@v6
52 | with:
53 | name: packages
54 | path: dist
55 |
56 | - name: Publish to GitHub Release
57 | env:
58 | GH_TOKEN: ${{ github.token }}
59 | GH_REPO: ${{ github.repository }}
60 | run: gh release upload "${{ github.ref_name }}" dist/*
61 |
--------------------------------------------------------------------------------
/docs/cli_usage.rst:
--------------------------------------------------------------------------------
1 | CLI Usage
2 | =========
3 |
4 | There are three CLI tools provided by ``dependency-groups``.
5 |
6 | Viewing Groups
7 | --------------
8 |
9 | ``dependency-groups`` is a CLI command, provided by the package.
10 | It can parse a pyproject.toml file and print a dependency group's contents back
11 | out, newline separated.
12 | This data is therefore valid for use as a ``requirements.txt`` file.
13 |
14 | ``dependency-groups --list`` can be used to list the available dependency
15 | groups.
16 |
17 | Use ``dependency-groups --help`` for details!
18 |
19 |
20 | Module Usage
21 | ^^^^^^^^^^^^
22 |
23 | ``dependency-groups`` provides a module-level entrypoint, identical to the
24 | ``dependency-groups`` CLI.
25 |
26 | e.g., ``python -m dependency_groups --list`` can be used to list groups.
27 |
28 | Installer
29 | ---------
30 |
31 | ``dependency-groups`` includes a ``pip`` wrapper, ``pip-install-dependency-groups``.
32 |
33 | Usage is simple, just ``pip-install-dependency-groups groupname`` to install!
34 |
35 | Use ``pip-install-dependency-groups --help`` for more details.
36 |
37 | Linter
38 | ------
39 |
40 | ``dependency-groups`` includes a linter, ``lint-dependency-groups``, as a separate
41 | CLI entrypoint.
42 |
43 | Use ``lint-dependency-groups --help`` for details.
44 |
45 | The ``lint-dependency-groups`` CLI is also available as a pre-commit hook:
46 |
47 | .. code-block:: yaml
48 |
49 | repos:
50 | - repo: https://github.com/pypa/dependency-groups
51 | rev: 1.3.1
52 | hooks:
53 | - id: lint-dependency-groups
54 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | ci:
2 | autoupdate_schedule: "quarterly"
3 |
4 | repos:
5 | - repo: https://github.com/pre-commit/pre-commit-hooks
6 | rev: v5.0.0
7 | hooks:
8 | - id: check-merge-conflict
9 | - id: trailing-whitespace
10 | - repo: https://github.com/sirosen/dependency-groups
11 | rev: 1.3.0
12 | hooks:
13 | - id: lint-dependency-groups
14 | - repo: https://github.com/python-jsonschema/check-jsonschema
15 | rev: 0.31.0
16 | hooks:
17 | - id: check-github-workflows
18 | - repo: https://github.com/asottile/pyupgrade
19 | rev: v3.19.1
20 | hooks:
21 | - id: pyupgrade
22 | args: ["--py39-plus"]
23 | - repo: https://github.com/psf/black-pre-commit-mirror
24 | rev: 24.10.0
25 | hooks:
26 | - id: black
27 | - repo: https://github.com/PyCQA/flake8
28 | rev: 7.1.1
29 | hooks:
30 | - id: flake8
31 | additional_dependencies:
32 | - 'flake8-bugbear==24.12.12'
33 | - 'flake8-comprehensions==3.16.0'
34 | - 'flake8-typing-as-t==1.0.0'
35 | - repo: https://github.com/PyCQA/isort
36 | rev: 5.13.2
37 | hooks:
38 | - id: isort
39 | - repo: https://github.com/sirosen/slyp
40 | rev: 0.8.1
41 | hooks:
42 | - id: slyp
43 | - repo: https://github.com/codespell-project/codespell
44 | rev: v2.3.0
45 | hooks:
46 | - id: codespell
47 | - repo: https://github.com/henryiii/check-sdist
48 | rev: v1.2.0
49 | hooks:
50 | - id: check-sdist
51 | args: [--inject-junk]
52 | additional_dependencies: ["flit-core"]
53 |
--------------------------------------------------------------------------------
/.github/workflows/publish_to_test_pypi.yaml:
--------------------------------------------------------------------------------
1 | name: Publish Test PyPI Release
2 |
3 | on:
4 | workflow_dispatch:
5 | inputs:
6 | devNumber:
7 | required: false
8 | type: number
9 | description: 'The number to use as a ".devN" suffix. Defaults to 1.'
10 |
11 | push:
12 | tags: ["*"]
13 |
14 | jobs:
15 | build-dists:
16 | runs-on: ubuntu-latest
17 |
18 | steps:
19 | - uses: actions/checkout@v6
20 | - uses: actions/setup-python@v6
21 | with:
22 | python-version: "3.11"
23 |
24 | - run: python -m pip install build twine
25 |
26 | - name: Set dev version prior to upload (auto)
27 | if: ${{ github.event.inputs.devNumber == '' }}
28 | run: python ./scripts/set-dev-version.py
29 |
30 | - name: Set dev version prior to upload (workflow_dispatch)
31 | if: ${{ github.event.inputs.devNumber != '' }}
32 | run: python ./scripts/set-dev-version.py -n ${{ github.event.inputs.devNumber }}
33 |
34 | - name: Build Dists
35 | run: python -m build .
36 |
37 | - name: Check Dists (twine)
38 | run: twine check dist/*
39 |
40 | - uses: actions/upload-artifact@v5
41 | with:
42 | name: packages
43 | path: dist/*
44 |
45 |
46 | publish:
47 | needs: [build-dists]
48 | runs-on: ubuntu-latest
49 | environment: publish-testpypi
50 | permissions:
51 | id-token: write
52 |
53 | steps:
54 | - uses: actions/download-artifact@v6
55 | with:
56 | name: packages
57 | path: dist
58 |
59 | - name: Publish to TestPyPI
60 | uses: pypa/gh-action-pypi-publish@release/v1
61 | with:
62 | repository-url: https://test.pypi.org/legacy/
63 |
--------------------------------------------------------------------------------
/src/dependency_groups/_lint_dependency_groups.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import sys
4 |
5 | from ._argparse_compat import ArgumentParser
6 | from ._implementation import DependencyGroupResolver
7 | from ._toml_compat import tomllib
8 |
9 |
10 | def main(*, argv: list[str] | None = None) -> None:
11 | if tomllib is None:
12 | print(
13 | "Usage error: dependency-groups CLI requires tomli or Python 3.11+",
14 | file=sys.stderr,
15 | )
16 | raise SystemExit(2)
17 |
18 | parser = ArgumentParser(
19 | description=(
20 | "Lint Dependency Groups for validity. "
21 | "This will eagerly load and check all of your Dependency Groups."
22 | )
23 | )
24 | parser.add_argument(
25 | "-f",
26 | "--pyproject-file",
27 | default="pyproject.toml",
28 | help="The pyproject.toml file. Defaults to trying in the current directory.",
29 | )
30 | args = parser.parse_args(argv if argv is not None else sys.argv[1:])
31 |
32 | with open(args.pyproject_file, "rb") as fp:
33 | pyproject = tomllib.load(fp)
34 | dependency_groups_raw = pyproject.get("dependency-groups", {})
35 |
36 | errors: list[str] = []
37 | try:
38 | resolver = DependencyGroupResolver(dependency_groups_raw)
39 | except (ValueError, TypeError) as e:
40 | errors.append(f"{type(e).__name__}: {e}")
41 | else:
42 | for groupname in resolver.dependency_groups:
43 | try:
44 | resolver.resolve(groupname)
45 | except (LookupError, ValueError, TypeError) as e:
46 | errors.append(f"{type(e).__name__}: {e}")
47 |
48 | if errors:
49 | print("errors encountered while examining dependency groups:")
50 | for msg in errors:
51 | print(f" {msg}")
52 | sys.exit(1)
53 | else:
54 | print("ok")
55 | sys.exit(0)
56 |
57 |
58 | if __name__ == "__main__":
59 | main()
60 |
--------------------------------------------------------------------------------
/src/dependency_groups/__main__.py:
--------------------------------------------------------------------------------
1 | import sys
2 |
3 | from ._argparse_compat import ArgumentParser
4 | from ._implementation import resolve
5 | from ._toml_compat import tomllib
6 |
7 |
8 | def main() -> None:
9 | if tomllib is None:
10 | print(
11 | "Usage error: dependency-groups CLI requires tomli or Python 3.11+",
12 | file=sys.stderr,
13 | )
14 | raise SystemExit(2)
15 |
16 | parser = ArgumentParser(
17 | description=(
18 | "A dependency-groups CLI. Prints out a resolved group, newline-delimited."
19 | )
20 | )
21 | parser.add_argument(
22 | "GROUP_NAME", nargs="*", help="The dependency group(s) to resolve."
23 | )
24 | parser.add_argument(
25 | "-f",
26 | "--pyproject-file",
27 | default="pyproject.toml",
28 | help="The pyproject.toml file. Defaults to trying in the current directory.",
29 | )
30 | parser.add_argument(
31 | "-o",
32 | "--output",
33 | help="An output file. Defaults to stdout.",
34 | )
35 | parser.add_argument(
36 | "-l",
37 | "--list",
38 | action="store_true",
39 | help="List the available dependency groups",
40 | )
41 | args = parser.parse_args()
42 |
43 | with open(args.pyproject_file, "rb") as fp:
44 | pyproject = tomllib.load(fp)
45 |
46 | dependency_groups_raw = pyproject.get("dependency-groups", {})
47 |
48 | if args.list:
49 | print(*dependency_groups_raw.keys())
50 | return
51 | if not args.GROUP_NAME:
52 | print("A GROUP_NAME is required", file=sys.stderr)
53 | raise SystemExit(3)
54 |
55 | content = "\n".join(resolve(dependency_groups_raw, *args.GROUP_NAME))
56 |
57 | if args.output is None or args.output == "-":
58 | print(content)
59 | else:
60 | with open(args.output, "w", encoding="utf-8") as fp:
61 | print(content, file=fp)
62 |
63 |
64 | if __name__ == "__main__":
65 | main()
66 |
--------------------------------------------------------------------------------
/scripts/bump-version.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | import re
3 | import sys
4 |
5 |
6 | def get_old_version():
7 | with open("pyproject.toml", encoding="utf-8") as fp:
8 | content = fp.read()
9 | match = re.search(r'^version = "(\d+\.\d+\.\d+)"$', content, flags=re.MULTILINE)
10 | assert match
11 | return match.group(1)
12 |
13 |
14 | def replace_version(filename, formatstr, old_version, new_version):
15 | print(f"updating {filename}")
16 | with open(filename, encoding="utf-8") as fp:
17 | content = fp.read()
18 | old_str = formatstr.format(old_version)
19 | new_str = formatstr.format(new_version)
20 | content = content.replace(old_str, new_str)
21 | with open(filename, "w", encoding="utf-8") as fp:
22 | fp.write(content)
23 |
24 |
25 | def update_changelog(new_version):
26 | print("updating CHANGELOG.rst")
27 | with open("CHANGELOG.rst", encoding="utf-8") as fp:
28 | content = fp.read()
29 |
30 | content = re.sub(
31 | r"""
32 | Unreleased
33 | ----------
34 | (\s*\n)+""",
35 | f"""
36 | Unreleased
37 | ----------
38 |
39 | {new_version}
40 | {'-' * len(new_version)}
41 |
42 | """,
43 | content,
44 | )
45 | with open("CHANGELOG.rst", "w", encoding="utf-8") as fp:
46 | fp.write(content)
47 |
48 |
49 | def parse_version(s):
50 | vals = s.split(".")
51 | assert len(vals) == 3
52 | return tuple(int(x) for x in vals)
53 |
54 |
55 | def comparse_versions(old_version, new_version):
56 | assert parse_version(new_version) > parse_version(old_version)
57 |
58 |
59 | def main():
60 | if len(sys.argv) != 2:
61 | sys.exit(2)
62 |
63 | new_version = sys.argv[1]
64 | old_version = get_old_version()
65 | print(f"old = {old_version}, new = {new_version}")
66 | comparse_versions(old_version, new_version)
67 |
68 | replace_version("pyproject.toml", 'version = "{}"', old_version, new_version)
69 | replace_version("docs/cli_usage.rst", "rev: {}", old_version, new_version)
70 | update_changelog(new_version)
71 |
72 |
73 | if __name__ == "__main__":
74 | main()
75 |
--------------------------------------------------------------------------------
/src/dependency_groups/_pip_wrapper.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import subprocess
4 | import sys
5 |
6 | from ._argparse_compat import ArgumentParser
7 | from ._implementation import DependencyGroupResolver
8 | from ._toml_compat import tomllib
9 |
10 |
11 | def _invoke_pip(deps: list[str]) -> None:
12 | subprocess.check_call([sys.executable, "-m", "pip", "install", *deps])
13 |
14 |
15 | def main(*, argv: list[str] | None = None) -> None:
16 | if tomllib is None:
17 | print(
18 | "Usage error: dependency-groups CLI requires tomli or Python 3.11+",
19 | file=sys.stderr,
20 | )
21 | raise SystemExit(2)
22 |
23 | parser = ArgumentParser(description="Install Dependency Groups.")
24 | parser.add_argument(
25 | "DEPENDENCY_GROUP", nargs="+", help="The dependency groups to install."
26 | )
27 | parser.add_argument(
28 | "-f",
29 | "--pyproject-file",
30 | default="pyproject.toml",
31 | help="The pyproject.toml file. Defaults to trying in the current directory.",
32 | )
33 | args = parser.parse_args(argv if argv is not None else sys.argv[1:])
34 |
35 | with open(args.pyproject_file, "rb") as fp:
36 | pyproject = tomllib.load(fp)
37 | dependency_groups_raw = pyproject.get("dependency-groups", {})
38 |
39 | errors: list[str] = []
40 | resolved: list[str] = []
41 | try:
42 | resolver = DependencyGroupResolver(dependency_groups_raw)
43 | except (ValueError, TypeError) as e:
44 | errors.append(f"{type(e).__name__}: {e}")
45 | else:
46 | for groupname in args.DEPENDENCY_GROUP:
47 | try:
48 | resolved.extend(str(r) for r in resolver.resolve(groupname))
49 | except (LookupError, ValueError, TypeError) as e:
50 | errors.append(f"{type(e).__name__}: {e}")
51 |
52 | if errors:
53 | print("errors encountered while examining dependency groups:")
54 | for msg in errors:
55 | print(f" {msg}")
56 | sys.exit(1)
57 |
58 | _invoke_pip(resolved)
59 |
60 |
61 | if __name__ == "__main__":
62 | main()
63 |
--------------------------------------------------------------------------------
/CHANGELOG.rst:
--------------------------------------------------------------------------------
1 | CHANGELOG
2 | =========
3 |
4 | Unreleased
5 | ----------
6 |
7 | - Add support for Python 3.14
8 | - Remove support for Python 3.8
9 |
10 | 1.3.1
11 | -----
12 |
13 | - Fix a bug in which names in includes were not normalized before comparisons,
14 | resulting in spurious ``LookupError``\s.
15 | - Optimize the behavior of the ``resolve()`` function on multiple groups.
16 |
17 | 1.3.0
18 | -----
19 |
20 | - Bugfix: raise a ``TypeError`` on non-list groups (was ``ValueError``).
21 | Thanks :user:`henryiii`!
22 |
23 | - Several improvements to the CLI interfaces! Thanks :user:`henryiii`!
24 |
25 | - Add support for a ``cli`` extra, as in
26 | ``pip install "dependency-groups[cli]"``, which ensures that ``tomli`` is
27 | present on older Pythons.
28 |
29 | - Add support for ``dependency-groups`` as an entrypoint, as an alias of
30 | ``python -m dependency_groups``.
31 |
32 | - The ``dependency-groups`` command now supports a ``--list`` flag to list
33 | groups instead of resolving them.
34 |
35 | 1.2.0
36 | -----
37 |
38 | - Switch to ``flit-core`` as the build backend
39 | - Add support for supplying multiple dependency groups to the functional
40 | ``resolve()`` API: ``resolve(dependency_groups, *groups: str)``. Thanks
41 | :user:`henryiii`!
42 |
43 | 1.1.0
44 | -----
45 |
46 | - Add support for Python 3.8
47 |
48 | 1.0.0
49 | -----
50 |
51 | - Update metadata to 1.0.0 and "Production" status
52 | - Support Python 3.13
53 |
54 | 0.3.0
55 | -----
56 |
57 | - Add a new command, ``pip-install-dependency-groups``, which is capable of
58 | installing dependency groups by invoking ``pip``
59 |
60 | 0.2.2
61 | -----
62 |
63 | - The pre-commit hook sets ``pass_filenames: false``
64 | - The error presentation in the lint CLI has been improved
65 |
66 | 0.2.1
67 | -----
68 |
69 | - Bugfix to pre-commit config
70 |
71 | 0.2.0
72 | -----
73 |
74 | - Add a new CLI component, ``lint-dependency-groups``, which can be used to lint
75 | dependency groups.
76 | - Provide a pre-commit hook, named ``lint-dependency-groups``
77 |
78 | 0.1.1
79 | -----
80 |
81 | - Fix a bug in cycle detection for nontrivial cycles
82 |
83 | 0.1.0
84 | -----
85 |
86 | - Initial Release
87 |
--------------------------------------------------------------------------------
/.github/workflows/build.yaml:
--------------------------------------------------------------------------------
1 | name: build
2 | on:
3 | push:
4 | pull_request:
5 |
6 | jobs:
7 |
8 | ci-test-matrix:
9 | strategy:
10 | matrix:
11 | os: [ubuntu-latest, windows-latest, macos-latest]
12 | name: "Run tests on ${{ matrix.os }}"
13 | runs-on: ${{ matrix.os }}
14 | steps:
15 | - uses: actions/checkout@v6
16 |
17 | - uses: actions/setup-python@v6
18 | id: setup-python
19 | with:
20 | allow-prereleases: true
21 | python-version: |
22 | 3.9
23 | 3.10
24 | 3.11
25 | 3.12
26 | 3.13
27 | 3.14
28 |
29 | # get the week of the year (1-52) for cache control
30 | # this ensures that at least weekly we'll test with a clear cache
31 | - name: set .weeknum.txt
32 | run: /bin/date -u "+%U" > .weeknum.txt
33 | shell: bash
34 |
35 | - uses: astral-sh/setup-uv@v7
36 | with:
37 | enable-cache: true
38 | cache-dependency-glob: ".weeknum.txt"
39 |
40 | - run: uv tool install tox --with tox-uv
41 |
42 | - name: test
43 | run: tox run -m ci
44 |
45 | other-tox-checks:
46 | strategy:
47 | matrix:
48 | include:
49 | - pythons: ["3.9", "3.13"]
50 | tox_label: "ci-mypy"
51 | - pythons: ["3.13"]
52 | tox_label: "ci-package-check"
53 |
54 | runs-on: ubuntu-latest
55 | name: "Run '${{ matrix.tox_label }}'"
56 | steps:
57 | - uses: actions/checkout@v6
58 |
59 | - uses: actions/setup-python@v6
60 | id: setup-python
61 | with:
62 | python-version: "${{ join( matrix.pythons, '\n') }}"
63 |
64 | # get the week of the year (1-52) for cache control
65 | # this ensures that at least weekly we'll test with a clear cache
66 | - name: set .weeknum.txt
67 | run: /bin/date -u "+%U" > .weeknum.txt
68 | shell: bash
69 |
70 | - uses: astral-sh/setup-uv@v7
71 | with:
72 | enable-cache: true
73 | cache-dependency-glob: ".weeknum.txt"
74 |
75 | - run: uv tool install tox --with tox-uv
76 |
77 | - run: tox run -m "${{ matrix.tox_label }}"
78 |
--------------------------------------------------------------------------------
/tests/test_lint_cli.py:
--------------------------------------------------------------------------------
1 | import dataclasses
2 |
3 | import pytest
4 |
5 |
6 | @dataclasses.dataclass
7 | class CLIResult:
8 | code: int
9 | stdout: str
10 | stderr: str
11 |
12 |
13 | @pytest.fixture
14 | def run(capsys):
15 | from dependency_groups._lint_dependency_groups import main as cli_main
16 |
17 | def _run(*argv):
18 | try:
19 | cli_main(argv=[str(arg) for arg in argv])
20 | rc = 0
21 | except SystemExit as e:
22 | rc = e.code
23 |
24 | stdio = capsys.readouterr()
25 | return CLIResult(rc, stdio.out, stdio.err)
26 |
27 | return _run
28 |
29 |
30 | def test_lint_no_groups_ok(run, tmp_path):
31 | tomlfile = tmp_path / "pyproject.toml"
32 | tomlfile.write_text("[project]\n")
33 |
34 | res = run("-f", tomlfile)
35 | assert res.code == 0
36 | assert res.stdout == "ok\n"
37 | assert res.stderr == ""
38 |
39 |
40 | def test_lint_bad_group_item(run, tmp_path):
41 | tomlfile = tmp_path / "pyproject.toml"
42 | tomlfile.write_text(
43 | """\
44 | [dependency-groups]
45 | foo = [{badkey = "value"}]
46 | """
47 | )
48 |
49 | res = run("-f", tomlfile)
50 | assert res.code == 1
51 | assert (
52 | res.stdout
53 | == """\
54 | errors encountered while examining dependency groups:
55 | ValueError: Invalid dependency group item: {'badkey': 'value'}
56 | """
57 | )
58 | assert res.stderr == ""
59 |
60 |
61 | def test_no_toml_failure(run, tmp_path, monkeypatch):
62 | monkeypatch.setattr("dependency_groups._lint_dependency_groups.tomllib", None)
63 |
64 | tomlfile = tmp_path / "pyproject.toml"
65 | tomlfile.write_text("")
66 |
67 | res = run("-f", tomlfile)
68 | assert res.code == 2
69 | assert "requires tomli or Python 3.11+" in res.stderr
70 |
71 |
72 | def test_dependency_groups_list_format(run, tmp_path):
73 | tomlfile = tmp_path / "pyproject.toml"
74 | tomlfile.write_text("[[dependency-groups]]")
75 |
76 | res = run("-f", tomlfile)
77 | assert res.code == 1
78 | assert (
79 | res.stdout
80 | == """\
81 | errors encountered while examining dependency groups:
82 | TypeError: Dependency Groups table is not a mapping
83 | """
84 | )
85 | assert res.stderr == ""
86 |
--------------------------------------------------------------------------------
/tests/test_resolve_func.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | from dependency_groups import resolve
4 |
5 |
6 | def test_empty_group():
7 | groups = {"test": []}
8 | assert resolve(groups, "test") == ()
9 |
10 |
11 | def test_str_list_group():
12 | groups = {"test": ["pytest"]}
13 | assert resolve(groups, "test") == ("pytest",)
14 |
15 |
16 | def test_single_include_group():
17 | groups = {
18 | "test": [
19 | "pytest",
20 | {"include-group": "runtime"},
21 | ],
22 | "runtime": ["sqlalchemy"],
23 | }
24 | assert set(resolve(groups, "test")) == {"pytest", "sqlalchemy"}
25 |
26 |
27 | def test_sdual_include_group():
28 | groups = {
29 | "test": [
30 | "pytest",
31 | ],
32 | "runtime": ["sqlalchemy"],
33 | }
34 | assert set(resolve(groups, "test", "runtime")) == {"pytest", "sqlalchemy"}
35 |
36 |
37 | def test_normalized_group_name():
38 | groups = {
39 | "TEST": ["pytest"],
40 | }
41 | assert resolve(groups, "test") == ("pytest",)
42 |
43 |
44 | def test_malformed_group_data():
45 | groups = [{"test": ["pytest"]}]
46 | with pytest.raises(TypeError, match="Dependency Groups table is not a mapping"):
47 | resolve(groups, "test")
48 |
49 |
50 | def test_malformed_group_query():
51 | groups = {"test": ["pytest"]}
52 | with pytest.raises(TypeError, match="Dependency group name is not a str"):
53 | resolve(groups, 0)
54 |
55 |
56 | def test_no_such_group_name():
57 | groups = {
58 | "test": ["pytest"],
59 | }
60 | with pytest.raises(LookupError, match="'testing' not found"):
61 | resolve(groups, "testing")
62 |
63 |
64 | def test_duplicate_normalized_name():
65 | groups = {
66 | "test": ["pytest"],
67 | "TEST": ["nose2"],
68 | }
69 | with pytest.raises(
70 | ValueError,
71 | match=r"Duplicate dependency group names: test \((test, TEST)|(TEST, test)\)",
72 | ):
73 | resolve(groups, "test")
74 |
75 |
76 | def test_cyclic_include():
77 | groups = {
78 | "group1": [
79 | {"include-group": "group2"},
80 | ],
81 | "group2": [
82 | {"include-group": "group1"},
83 | ],
84 | }
85 | with pytest.raises(
86 | ValueError,
87 | match=(
88 | "Cyclic dependency group include while resolving group1: "
89 | "group1 -> group2, group2 -> group1"
90 | ),
91 | ):
92 | resolve(groups, "group1")
93 |
94 |
95 | def test_cyclic_include_many_steps():
96 | groups = {}
97 | for i in range(100):
98 | groups[f"group{i}"] = [{"include-group": f"group{i+1}"}]
99 | groups["group100"] = [{"include-group": "group0"}]
100 | with pytest.raises(
101 | ValueError,
102 | match="Cyclic dependency group include while resolving group0:",
103 | ):
104 | resolve(groups, "group0")
105 |
106 |
107 | def test_cyclic_include_self():
108 | groups = {
109 | "group1": [
110 | {"include-group": "group1"},
111 | ],
112 | }
113 | with pytest.raises(
114 | ValueError,
115 | match=(
116 | "Cyclic dependency group include while resolving group1: "
117 | "group1 includes itself"
118 | ),
119 | ):
120 | resolve(groups, "group1")
121 |
122 |
123 | def test_cyclic_include_ring_under_root():
124 | groups = {
125 | "root": [
126 | {"include-group": "group1"},
127 | ],
128 | "group1": [
129 | {"include-group": "group2"},
130 | ],
131 | "group2": [
132 | {"include-group": "group1"},
133 | ],
134 | }
135 | with pytest.raises(
136 | ValueError,
137 | match=(
138 | "Cyclic dependency group include while resolving root: "
139 | "group1 -> group2, group2 -> group1"
140 | ),
141 | ):
142 | resolve(groups, "root")
143 |
144 |
145 | def test_non_list_data():
146 | groups = {"test": "pytest, coverage"}
147 | with pytest.raises(TypeError, match="Dependency group 'test' is not a list"):
148 | resolve(groups, "test")
149 |
150 |
151 | @pytest.mark.parametrize(
152 | "item",
153 | (
154 | {},
155 | {"foo": "bar"},
156 | {"include-group": "testing", "foo": "bar"},
157 | object(),
158 | ),
159 | )
160 | def test_unknown_object_shape(item):
161 | groups = {"test": [item]}
162 | with pytest.raises(ValueError, match="Invalid dependency group item:"):
163 | resolve(groups, "test")
164 |
--------------------------------------------------------------------------------
/tests/test_resolver_class.py:
--------------------------------------------------------------------------------
1 | import unittest.mock
2 |
3 | import pytest
4 | from packaging.requirements import Requirement
5 |
6 | from dependency_groups import DependencyGroupInclude, DependencyGroupResolver
7 |
8 |
9 | def test_resolver_init_handles_bad_type():
10 | with pytest.raises(TypeError):
11 | DependencyGroupResolver([])
12 |
13 |
14 | def test_resolver_init_catches_normalization_conflict():
15 | groups = {"test": ["pytest"], "Test": ["pytest", "coverage"]}
16 | with pytest.raises(ValueError, match="Duplicate dependency group names"):
17 | DependencyGroupResolver(groups)
18 |
19 |
20 | def test_lookup_catches_bad_type():
21 | groups = {"test": ["pytest"]}
22 | resolver = DependencyGroupResolver(groups)
23 | with pytest.raises(TypeError):
24 | resolver.lookup(0)
25 |
26 |
27 | def test_lookup_on_trivial_normalization():
28 | groups = {"test": ["pytest"]}
29 | resolver = DependencyGroupResolver(groups)
30 | parsed_group = resolver.lookup("Test")
31 | assert len(parsed_group) == 1
32 | assert isinstance(parsed_group[0], Requirement)
33 | req = parsed_group[0]
34 | assert req.name == "pytest"
35 |
36 |
37 | def test_lookup_with_include_result():
38 | groups = {
39 | "test": ["pytest", {"include-group": "runtime"}],
40 | "runtime": ["click"],
41 | }
42 | resolver = DependencyGroupResolver(groups)
43 | parsed_group = resolver.lookup("test")
44 | assert len(parsed_group) == 2
45 |
46 | assert isinstance(parsed_group[0], Requirement)
47 | assert parsed_group[0].name == "pytest"
48 |
49 | assert isinstance(parsed_group[1], DependencyGroupInclude)
50 | assert parsed_group[1].include_group == "runtime"
51 |
52 |
53 | def test_lookup_does_not_trigger_cyclic_include():
54 | groups = {
55 | "group1": [{"include-group": "group2"}],
56 | "group2": [{"include-group": "group1"}],
57 | }
58 | resolver = DependencyGroupResolver(groups)
59 | parsed_group = resolver.lookup("group1")
60 | assert len(parsed_group) == 1
61 |
62 | assert isinstance(parsed_group[0], DependencyGroupInclude)
63 | assert parsed_group[0].include_group == "group2"
64 |
65 |
66 | def test_expand_contract_model_only_does_inner_lookup_once():
67 | groups = {
68 | "root": [
69 | {"include-group": "mid1"},
70 | {"include-group": "mid2"},
71 | {"include-group": "mid3"},
72 | {"include-group": "mid4"},
73 | ],
74 | "mid1": [{"include-group": "contract"}],
75 | "mid2": [{"include-group": "contract"}],
76 | "mid3": [{"include-group": "contract"}],
77 | "mid4": [{"include-group": "contract"}],
78 | "contract": [{"include-group": "leaf"}],
79 | "leaf": ["attrs"],
80 | }
81 | resolver = DependencyGroupResolver(groups)
82 |
83 | real_inner_resolve = resolver._resolve
84 | with unittest.mock.patch(
85 | "dependency_groups.DependencyGroupResolver._resolve",
86 | side_effect=real_inner_resolve,
87 | ) as spy:
88 | resolved = resolver.resolve("root")
89 | assert len(resolved) == 4
90 | assert all(item.name == "attrs" for item in resolved)
91 |
92 | # each of the `mid` nodes will call resolution with `contract`, but only the
93 | # first of those evaluations should call for resolution of `leaf` -- after that,
94 | # `contract` will be in the cache and `leaf` will not need to be resolved
95 | spy.assert_any_call("leaf", "root")
96 | leaf_calls = [c for c in spy.mock_calls if c.args[0] == "leaf"]
97 | assert len(leaf_calls) == 1
98 |
99 |
100 | def test_no_double_parse():
101 | groups = {
102 | "test": [{"include-group": "runtime"}],
103 | "runtime": ["click"],
104 | }
105 | resolver = DependencyGroupResolver(groups)
106 |
107 | parse = resolver.lookup("test")
108 | assert len(parse) == 1
109 | assert isinstance(parse[0], DependencyGroupInclude)
110 | assert parse[0].include_group == "runtime"
111 |
112 | mock_include = DependencyGroupInclude(include_group="perfidy")
113 |
114 | with unittest.mock.patch(
115 | "dependency_groups._implementation.DependencyGroupInclude",
116 | return_value=mock_include,
117 | ):
118 | # rerunning with that resolver will not re-resolve
119 | reparse = resolver.lookup("test")
120 | assert len(reparse) == 1
121 | assert isinstance(reparse[0], DependencyGroupInclude)
122 | assert reparse[0].include_group == "runtime"
123 |
124 | # but verify that a fresh resolver (no cache) will get the mock
125 | deceived_resolver = DependencyGroupResolver(groups)
126 | deceived_parse = deceived_resolver.lookup("test")
127 | assert len(deceived_parse) == 1
128 | assert isinstance(deceived_parse[0], DependencyGroupInclude)
129 | assert deceived_parse[0].include_group == "perfidy"
130 |
131 |
132 | @pytest.mark.parametrize("group_name_declared", ("foo-bar", "foo_bar", "foo..bar"))
133 | @pytest.mark.parametrize("group_name_used", ("foo-bar", "foo_bar", "foo..bar"))
134 | def test_normalized_name_is_used_for_include_group_lookups(
135 | group_name_declared, group_name_used
136 | ):
137 | groups = {
138 | group_name_declared: ["spam"],
139 | "eggs": [{"include-group": group_name_used}],
140 | }
141 | resolver = DependencyGroupResolver(groups)
142 |
143 | result = resolver.resolve("eggs")
144 | assert len(result) == 1
145 | assert isinstance(result[0], Requirement)
146 | req = result[0]
147 | assert req.name == "spam"
148 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [build-system]
2 | requires = ["flit-core>=3.11"]
3 | build-backend = "flit_core.buildapi"
4 |
5 | [dependency-groups]
6 | coverage = ["coverage[toml]"]
7 | test = ["pytest", {include-group = "coverage"}]
8 | docs = ["sphinx>=8.1", "sphinx-issues>=5", "furo"]
9 | lint = ["pre-commit"]
10 | typing = ["mypy", "packaging"]
11 | build = ["twine", "build"]
12 | dev = [{include-group = "test"}]
13 |
14 | [project]
15 | name = "dependency-groups"
16 | version = "1.3.1"
17 | description = 'A tool for resolving PEP 735 Dependency Group data'
18 | readme = "README.rst"
19 | requires-python = ">=3.9"
20 | license = "MIT"
21 | license-files = ["LICENSE.txt"]
22 | keywords = []
23 | authors = [
24 | { name = "Stephen Rosen", email = "sirosen0@gmail.com" },
25 | ]
26 | classifiers = [
27 | "Development Status :: 5 - Production/Stable",
28 | "Programming Language :: Python",
29 | "Programming Language :: Python :: 3.9",
30 | "Programming Language :: Python :: 3.10",
31 | "Programming Language :: Python :: 3.11",
32 | "Programming Language :: Python :: 3.12",
33 | "Programming Language :: Python :: 3.13",
34 | "Programming Language :: Python :: 3.14",
35 | "Programming Language :: Python :: Implementation :: CPython",
36 | "Programming Language :: Python :: Implementation :: PyPy",
37 | ]
38 | dependencies = [
39 | "packaging",
40 | "tomli;python_version<'3.11'",
41 | ]
42 |
43 | [project.scripts]
44 | lint-dependency-groups = "dependency_groups._lint_dependency_groups:main"
45 | pip-install-dependency-groups = "dependency_groups._pip_wrapper:main"
46 | dependency-groups = "dependency_groups.__main__:main"
47 |
48 | [project.optional-dependencies]
49 | cli = ["tomli; python_version<'3.11'"]
50 |
51 | [project.urls]
52 | source = "https://github.com/pypa/dependency-groups"
53 | changelog = "https://github.com/pypa/dependency-groups/blob/main/CHANGELOG.rst"
54 | documentation = "https://dependency-groups.readthedocs.io/"
55 |
56 |
57 | [tool.flit.sdist]
58 | include = ["LICENSE.txt", "CHANGELOG.rst", "tests/*.py", "tox.ini"]
59 |
60 |
61 | [tool.uv]
62 | environments = [
63 | "python_version >= '3.10'",
64 | ]
65 |
66 |
67 | [tool.coverage.run]
68 | parallel = true
69 | source = ["dependency_groups"]
70 |
71 | [tool.coverage.paths]
72 | source = [
73 | "src/",
74 | "*/site-packages/",
75 | ]
76 | [tool.coverage.report]
77 | show_missing = true
78 | skip_covered = true
79 | exclude_lines = [
80 | # the pragma to disable coverage
81 | "pragma: no cover",
82 | # don't complain if tests don't hit unimplemented methods/modes
83 | "raise NotImplementedError",
84 | # don't check on executable components of importable modules
85 | "if __name__ == .__main__.:",
86 | # mypy-only code segments
87 | "if t.TYPE_CHECKING:",
88 | # type-checking overloads
89 | "@t.overload"
90 | ]
91 |
92 | [tool.mypy]
93 | strict = true
94 | ignore_missing_imports = true
95 | disallow_subclassing_any = false
96 | files = ["src"]
97 |
98 | [tool.isort]
99 | profile = "black"
100 | known_first_party = ["mddj"]
101 |
102 | [tool.check-sdist]
103 | git-only = [".*", "Makefile", "docs/*", "scripts/*"]
104 |
105 |
106 | [tool.tox]
107 | requires = ["tox>=4.22"]
108 | env_list = [
109 | "lint",
110 | "mypy",
111 | "covclean",
112 | "covcombine",
113 | "covreport",
114 | "3.9",
115 | "3.10",
116 | "3.11",
117 | "3.12",
118 | "3.13",
119 | "3.14",
120 | ]
121 |
122 | [tool.tox.labels]
123 | ci = ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14", "covcombine", "covreport"]
124 | ci-mypy = ["mypy-py39", "mypy-py313"]
125 | ci-package-check = ["twine-check"]
126 |
127 | [tool.tox.env_run_base]
128 | description = "Run tests and coverage"
129 | package = "wheel"
130 | wheel_build_env = "build_wheel"
131 | dependency_groups = ["test"]
132 | commands = [["coverage", "run", "-m", "pytest", "-v", {replace = "posargs", extend = true}]]
133 | depends = ["clean"]
134 |
135 | [tool.tox.env.covclean]
136 | description = "Clean coverage outputs"
137 | skip_install = true
138 | dependency_groups = ["coverage"]
139 | commands = [["coverage", "erase"]]
140 |
141 | [tool.tox.env.covcombine]
142 | description = "Combine coverage outputs"
143 | skip_install = true
144 | dependency_groups = ["coverage"]
145 | commands = [["coverage", "combine"]]
146 | depends = ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
147 |
148 | [tool.tox.env.covreport]
149 | description = "Report on combined coverage outputs"
150 | skip_install = true
151 | dependency_groups = ["coverage"]
152 | commands_pre = [["coverage", "html", "--fail-under=0"]]
153 | commands = [["coverage", "report"]]
154 | depends = ["covcombine"]
155 |
156 | [tool.tox.env.lint]
157 | description = "Run linterse and formatters"
158 | dependency_groups = ["lint"]
159 | commands = [["pre-commit", "run", "-a", {replace = "posargs", extend = true}]]
160 | depends = []
161 |
162 | [tool.tox.env.mypy]
163 | package = "skip"
164 | description = "Run static type checking under {base_python}"
165 | dependency_groups = ["typing"]
166 | commands = [["mypy", "src/", {replace = "posargs", extend = true} ]]
167 | depends = []
168 |
169 | [tool.tox.env.mypy-py39]
170 | base = ["tool.tox.env.mypy"]
171 |
172 | [tool.tox.env.mypy-py313]
173 | base = ["env.mypy"]
174 |
175 | [tool.tox.env.twine-check]
176 | description = "check the metadata on a package build"
177 | allowlist_externals = ["rm"]
178 | dependency_groups = ["build"]
179 | commands_pre = [["rm", "-rf", "dist/"]]
180 | # check that twine validating package data works
181 | commands = [["python", "-m", "build"],
182 | ["twine", "check", "dist/*"]]
183 |
184 |
185 | [tool.tox.env.docs]
186 | description = "build docs with sphinx"
187 | basepython = ["python3.12"]
188 | dependency_groups = ["docs"]
189 | allowlist_externals = ["rm"]
190 | changedir = "docs/"
191 | # clean the build dir before rebuilding
192 | commands_pre = [["rm", "-rf", "_build/"]]
193 | commands = [["sphinx-build", "-d", "_build/doctrees", "-b", "dirhtml", "-W", ".", "_build/dirhtml", {replace = "posargs", extend = true}]]
194 |
--------------------------------------------------------------------------------
/src/dependency_groups/_implementation.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import dataclasses
4 | import re
5 | from collections.abc import Mapping
6 |
7 | from packaging.requirements import Requirement
8 |
9 |
10 | def _normalize_name(name: str) -> str:
11 | return re.sub(r"[-_.]+", "-", name).lower()
12 |
13 |
14 | def _normalize_group_names(
15 | dependency_groups: Mapping[str, str | Mapping[str, str]],
16 | ) -> Mapping[str, str | Mapping[str, str]]:
17 | original_names: dict[str, list[str]] = {}
18 | normalized_groups = {}
19 |
20 | for group_name, value in dependency_groups.items():
21 | normed_group_name = _normalize_name(group_name)
22 | original_names.setdefault(normed_group_name, []).append(group_name)
23 | normalized_groups[normed_group_name] = value
24 |
25 | errors = []
26 | for normed_name, names in original_names.items():
27 | if len(names) > 1:
28 | errors.append(f"{normed_name} ({', '.join(names)})")
29 | if errors:
30 | raise ValueError(f"Duplicate dependency group names: {', '.join(errors)}")
31 |
32 | return normalized_groups
33 |
34 |
35 | @dataclasses.dataclass
36 | class DependencyGroupInclude:
37 | include_group: str
38 |
39 |
40 | class CyclicDependencyError(ValueError):
41 | """
42 | An error representing the detection of a cycle.
43 | """
44 |
45 | def __init__(self, requested_group: str, group: str, include_group: str) -> None:
46 | self.requested_group = requested_group
47 | self.group = group
48 | self.include_group = include_group
49 |
50 | if include_group == group:
51 | reason = f"{group} includes itself"
52 | else:
53 | reason = f"{include_group} -> {group}, {group} -> {include_group}"
54 | super().__init__(
55 | "Cyclic dependency group include while resolving "
56 | f"{requested_group}: {reason}"
57 | )
58 |
59 |
60 | class DependencyGroupResolver:
61 | """
62 | A resolver for Dependency Group data.
63 |
64 | This class handles caching, name normalization, cycle detection, and other
65 | parsing requirements. There are only two public methods for exploring the data:
66 | ``lookup()`` and ``resolve()``.
67 |
68 | :param dependency_groups: A mapping, as provided via pyproject
69 | ``[dependency-groups]``.
70 | """
71 |
72 | def __init__(
73 | self,
74 | dependency_groups: Mapping[str, str | Mapping[str, str]],
75 | ) -> None:
76 | if not isinstance(dependency_groups, Mapping):
77 | raise TypeError("Dependency Groups table is not a mapping")
78 | self.dependency_groups = _normalize_group_names(dependency_groups)
79 | # a map of group names to parsed data
80 | self._parsed_groups: dict[
81 | str, tuple[Requirement | DependencyGroupInclude, ...]
82 | ] = {}
83 | # a map of group names to their ancestors, used for cycle detection
84 | self._include_graph_ancestors: dict[str, tuple[str, ...]] = {}
85 | # a cache of completed resolutions to Requirement lists
86 | self._resolve_cache: dict[str, tuple[Requirement, ...]] = {}
87 |
88 | def lookup(self, group: str) -> tuple[Requirement | DependencyGroupInclude, ...]:
89 | """
90 | Lookup a group name, returning the parsed dependency data for that group.
91 | This will not resolve includes.
92 |
93 | :param group: the name of the group to lookup
94 |
95 | :raises ValueError: if the data does not appear to be valid dependency group
96 | data
97 | :raises TypeError: if the data is not a string
98 | :raises LookupError: if group name is absent
99 | :raises packaging.requirements.InvalidRequirement: if a specifier is not valid
100 | """
101 | if not isinstance(group, str):
102 | raise TypeError("Dependency group name is not a str")
103 | group = _normalize_name(group)
104 | return self._parse_group(group)
105 |
106 | def resolve(self, group: str) -> tuple[Requirement, ...]:
107 | """
108 | Resolve a dependency group to a list of requirements.
109 |
110 | :param group: the name of the group to resolve
111 |
112 | :raises TypeError: if the inputs appear to be the wrong types
113 | :raises ValueError: if the data does not appear to be valid dependency group
114 | data
115 | :raises LookupError: if group name is absent
116 | :raises packaging.requirements.InvalidRequirement: if a specifier is not valid
117 | """
118 | if not isinstance(group, str):
119 | raise TypeError("Dependency group name is not a str")
120 | group = _normalize_name(group)
121 | return self._resolve(group, group)
122 |
123 | def _parse_group(
124 | self, group: str
125 | ) -> tuple[Requirement | DependencyGroupInclude, ...]:
126 | # short circuit -- never do the work twice
127 | if group in self._parsed_groups:
128 | return self._parsed_groups[group]
129 |
130 | if group not in self.dependency_groups:
131 | raise LookupError(f"Dependency group '{group}' not found")
132 |
133 | raw_group = self.dependency_groups[group]
134 | if not isinstance(raw_group, list):
135 | raise TypeError(f"Dependency group '{group}' is not a list")
136 |
137 | elements: list[Requirement | DependencyGroupInclude] = []
138 | for item in raw_group:
139 | if isinstance(item, str):
140 | # packaging.requirements.Requirement parsing ensures that this is a
141 | # valid PEP 508 Dependency Specifier
142 | # raises InvalidRequirement on failure
143 | elements.append(Requirement(item))
144 | elif isinstance(item, dict):
145 | if tuple(item.keys()) != ("include-group",):
146 | raise ValueError(f"Invalid dependency group item: {item}")
147 |
148 | include_group = next(iter(item.values()))
149 | elements.append(DependencyGroupInclude(include_group=include_group))
150 | else:
151 | raise ValueError(f"Invalid dependency group item: {item}")
152 |
153 | self._parsed_groups[group] = tuple(elements)
154 | return self._parsed_groups[group]
155 |
156 | def _resolve(self, group: str, requested_group: str) -> tuple[Requirement, ...]:
157 | """
158 | This is a helper for cached resolution to strings.
159 |
160 | :param group: The name of the group to resolve.
161 | :param requested_group: The group which was used in the original, user-facing
162 | request.
163 | """
164 | if group in self._resolve_cache:
165 | return self._resolve_cache[group]
166 |
167 | parsed = self._parse_group(group)
168 |
169 | resolved_group = []
170 | for item in parsed:
171 | if isinstance(item, Requirement):
172 | resolved_group.append(item)
173 | elif isinstance(item, DependencyGroupInclude):
174 | include_group = _normalize_name(item.include_group)
175 | if include_group in self._include_graph_ancestors.get(group, ()):
176 | raise CyclicDependencyError(
177 | requested_group, group, item.include_group
178 | )
179 | self._include_graph_ancestors[include_group] = (
180 | *self._include_graph_ancestors.get(group, ()),
181 | group,
182 | )
183 | resolved_group.extend(self._resolve(include_group, requested_group))
184 | else: # unreachable
185 | raise NotImplementedError(
186 | f"Invalid dependency group item after parse: {item}"
187 | )
188 |
189 | self._resolve_cache[group] = tuple(resolved_group)
190 | return self._resolve_cache[group]
191 |
192 |
193 | def resolve(
194 | dependency_groups: Mapping[str, str | Mapping[str, str]], /, *groups: str
195 | ) -> tuple[str, ...]:
196 | """
197 | Resolve a dependency group to a tuple of requirements, as strings.
198 |
199 | :param dependency_groups: the parsed contents of the ``[dependency-groups]`` table
200 | from ``pyproject.toml``
201 | :param groups: the name of the group(s) to resolve
202 |
203 | :raises TypeError: if the inputs appear to be the wrong types
204 | :raises ValueError: if the data does not appear to be valid dependency group data
205 | :raises LookupError: if group name is absent
206 | :raises packaging.requirements.InvalidRequirement: if a specifier is not valid
207 | """
208 | resolver = DependencyGroupResolver(dependency_groups)
209 | return tuple(str(r) for group in groups for r in resolver.resolve(group))
210 |
--------------------------------------------------------------------------------