From 97279832f586310688c8e827d4a07f391adcc72a Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Mon, 28 Oct 2024 01:21:51 -0400 Subject: [PATCH 1/5] feat: helper to get dependency-groups Signed-off-by: Henry Schreiner --- nox/project.py | 79 +++++++++++++++++++++++++++++++++++++++++-- tests/test_project.py | 58 ++++++++++++++++++++++++++----- 2 files changed, 127 insertions(+), 10 deletions(-) diff --git a/nox/project.py b/nox/project.py index 24a4712e..1f7c3464 100644 --- a/nox/project.py +++ b/nox/project.py @@ -6,10 +6,14 @@ from pathlib import Path from typing import TYPE_CHECKING +import packaging.requirements import packaging.specifiers if TYPE_CHECKING: - from typing import Any + from collections.abc import Generator + from typing import Any, TypeVar + + T = TypeVar("T") if sys.version_info < (3, 11): import tomli as tomllib @@ -17,7 +21,7 @@ import tomllib -__all__ = ["load_toml", "python_versions"] +__all__ = ["load_toml", "python_versions", "dependency_groups"] def __dir__() -> list[str]: @@ -127,3 +131,74 @@ def python_versions( max_minor_version = int(max_version.split(".")[1]) return [f"3.{v}" for v in range(min_minor_version, max_minor_version + 1)] + + +def _normalize_name(name: str) -> str: + return re.sub(r"[-_.]+", "-", name).lower() + + +def _normalize_group_names(dependency_groups: dict[str, T]) -> dict[str, T]: + 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 + + +def _resolve_dependency_group( + dependency_groups: dict[str, Any], group: str, *past_groups: str +) -> Generator[str, None, None]: + if group in past_groups: + raise ValueError(f"Cyclic dependency group include: {group} -> {past_groups}") + + if group not in dependency_groups: + raise LookupError(f"Dependency group '{group}' not found") + + raw_group = dependency_groups[group] + if not isinstance(raw_group, list): + raise ValueError(f"Dependency group '{group}' is not a list") + + 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 + packaging.requirements.Requirement(item) + yield item + elif isinstance(item, dict): + if tuple(item.keys()) != ("include-group",): + raise ValueError(f"Invalid dependency group item: {item}") + + include_group = _normalize_name(next(iter(item.values()))) + yield from _resolve_dependency_group( + dependency_groups, include_group, *past_groups, group + ) + else: + raise ValueError(f"Invalid dependency group item: {item}") + + +def _resolve( + dependency_groups: dict[str, Any], *groups: str +) -> Generator[str, None, None]: + if not isinstance(dependency_groups, dict): + raise TypeError("Dependency Groups table is not a dict") + for group in groups: + if not isinstance(group, str): + raise TypeError("Dependency group name is not a str") + yield from _resolve_dependency_group(dependency_groups, group) + + +def dependency_groups(pyproject: dict[str, Any], *groups: str) -> list[str]: + norm_groups = (_normalize_name(g) for g in groups) + return list(_resolve(pyproject["dependency-groups"], *norm_groups)) diff --git a/tests/test_project.py b/tests/test_project.py index a522c77c..d6e92d4d 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -1,6 +1,6 @@ import pytest -from nox.project import python_versions +import nox.project def test_classifiers(): @@ -18,19 +18,19 @@ def test_classifiers(): } } - assert python_versions(pyproject) == ["3.7", "3.9", "3.12"] + assert nox.project.python_versions(pyproject) == ["3.7", "3.9", "3.12"] def test_no_classifiers(): pyproject = {"project": {"requires-python": ">=3.9"}} with pytest.raises(ValueError, match="No Python version classifiers"): - python_versions(pyproject) + nox.project.python_versions(pyproject) def test_no_requires_python(): pyproject = {"project": {"classifiers": ["Programming Language :: Python :: 3.12"]}} with pytest.raises(ValueError, match='No "project.requires-python" value set'): - python_versions(pyproject, max_version="3.13") + nox.project.python_versions(pyproject, max_version="3.13") def test_python_range(): @@ -48,18 +48,60 @@ def test_python_range(): } } - assert python_versions(pyproject, max_version="3.12") == ["3.10", "3.11", "3.12"] - assert python_versions(pyproject, max_version="3.11") == ["3.10", "3.11"] + assert nox.project.python_versions(pyproject, max_version="3.12") == [ + "3.10", + "3.11", + "3.12", + ] + assert nox.project.python_versions(pyproject, max_version="3.11") == [ + "3.10", + "3.11", + ] def test_python_range_gt(): pyproject = {"project": {"requires-python": ">3.2.1,<3.3"}} - assert python_versions(pyproject, max_version="3.4") == ["3.2", "3.3", "3.4"] + assert nox.project.python_versions(pyproject, max_version="3.4") == [ + "3.2", + "3.3", + "3.4", + ] def test_python_range_no_min(): pyproject = {"project": {"requires-python": "==3.3.1"}} with pytest.raises(ValueError, match="No minimum version found"): - python_versions(pyproject, max_version="3.5") + nox.project.python_versions(pyproject, max_version="3.5") + + +def test_dependency_groups(): + example = { + "dependency-groups": { + "test": ["pytest", "coverage"], + "docs": ["sphinx", "sphinx-rtd-theme"], + "typing": ["mypy", "types-requests"], + "typing-test": [ + {"include-group": "typing"}, + {"include-group": "test"}, + "useful-types", + ], + } + } + + assert nox.project.dependency_groups(example, "test") == ["pytest", "coverage"] + assert nox.project.dependency_groups(example, "typing-test") == [ + "mypy", + "types-requests", + "pytest", + "coverage", + "useful-types", + ] + assert nox.project.dependency_groups(example, "typing_test") == [ + "mypy", + "types-requests", + "pytest", + "coverage", + "useful-types", + ] From 09ff37d88ef58015fc30dbe41bb12e5fb5157b95 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Mon, 28 Oct 2024 12:15:04 -0400 Subject: [PATCH 2/5] refactor: use dependency-groups Signed-off-by: Henry Schreiner --- .pre-commit-config.yaml | 1 + nox/project.py | 72 ++--------------------------------------- pyproject.toml | 1 + 3 files changed, 5 insertions(+), 69 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1f4d45f5..b717ac50 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -37,6 +37,7 @@ repos: files: ^nox/ args: [] additional_dependencies: + - dependency-groups>=1.1 - jinja2 - packaging - importlib_metadata diff --git a/nox/project.py b/nox/project.py index 1f7c3464..ba87f52e 100644 --- a/nox/project.py +++ b/nox/project.py @@ -8,9 +8,9 @@ import packaging.requirements import packaging.specifiers +from dependency_groups import resolve if TYPE_CHECKING: - from collections.abc import Generator from typing import Any, TypeVar T = TypeVar("T") @@ -133,72 +133,6 @@ def python_versions( return [f"3.{v}" for v in range(min_minor_version, max_minor_version + 1)] -def _normalize_name(name: str) -> str: - return re.sub(r"[-_.]+", "-", name).lower() - - -def _normalize_group_names(dependency_groups: dict[str, T]) -> dict[str, T]: - 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 - - -def _resolve_dependency_group( - dependency_groups: dict[str, Any], group: str, *past_groups: str -) -> Generator[str, None, None]: - if group in past_groups: - raise ValueError(f"Cyclic dependency group include: {group} -> {past_groups}") - - if group not in dependency_groups: - raise LookupError(f"Dependency group '{group}' not found") - - raw_group = dependency_groups[group] - if not isinstance(raw_group, list): - raise ValueError(f"Dependency group '{group}' is not a list") - - 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 - packaging.requirements.Requirement(item) - yield item - elif isinstance(item, dict): - if tuple(item.keys()) != ("include-group",): - raise ValueError(f"Invalid dependency group item: {item}") - - include_group = _normalize_name(next(iter(item.values()))) - yield from _resolve_dependency_group( - dependency_groups, include_group, *past_groups, group - ) - else: - raise ValueError(f"Invalid dependency group item: {item}") - - -def _resolve( - dependency_groups: dict[str, Any], *groups: str -) -> Generator[str, None, None]: - if not isinstance(dependency_groups, dict): - raise TypeError("Dependency Groups table is not a dict") - for group in groups: - if not isinstance(group, str): - raise TypeError("Dependency group name is not a str") - yield from _resolve_dependency_group(dependency_groups, group) - - def dependency_groups(pyproject: dict[str, Any], *groups: str) -> list[str]: - norm_groups = (_normalize_name(g) for g in groups) - return list(_resolve(pyproject["dependency-groups"], *norm_groups)) + dep_groups = pyproject["dependency-groups"] + return [item for g in groups for item in resolve(dep_groups, g)] diff --git a/pyproject.toml b/pyproject.toml index c0d100bf..a179c049 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,7 @@ classifiers = [ dependencies = [ "argcomplete<4,>=1.9.4", "colorlog<7,>=2.6.1", + "dependency-groups>=1.1", "packaging>=20.9", "tomli>=1; python_version<'3.11'", "virtualenv>=20.14.1", From 8129254c0cd28802f15ae864e8ce3c19861426ae Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Tue, 29 Oct 2024 14:34:44 -0400 Subject: [PATCH 3/5] docs: set up docs Signed-off-by: Henry Schreiner --- nox/project.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/nox/project.py b/nox/project.py index ba87f52e..c3117b1c 100644 --- a/nox/project.py +++ b/nox/project.py @@ -11,9 +11,7 @@ from dependency_groups import resolve if TYPE_CHECKING: - from typing import Any, TypeVar - - T = TypeVar("T") + from typing import Any if sys.version_info < (3, 11): import tomli as tomllib @@ -134,5 +132,17 @@ def python_versions( def dependency_groups(pyproject: dict[str, Any], *groups: str) -> list[str]: + """ + Get a list of dependencies from a ``[dependency-groups]`` section(s). + + Example: + + .. code-block:: python + + @nox.session + def test(session): + pyproject = nox.project.load_toml("pyproject.toml") + session.install(*nox.project.dependency_groups(pyproject, "dev")) + """ dep_groups = pyproject["dependency-groups"] return [item for g in groups for item in resolve(dep_groups, g)] From 20a73b7624b4f1922d26e25a597999bdb8fb819b Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Tue, 29 Oct 2024 17:02:11 -0400 Subject: [PATCH 4/5] style: avoid changing import Signed-off-by: Henry Schreiner --- tests/test_project.py | 33 +++++++++++---------------------- 1 file changed, 11 insertions(+), 22 deletions(-) diff --git a/tests/test_project.py b/tests/test_project.py index d6e92d4d..4dd59c6b 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -1,6 +1,6 @@ import pytest -import nox.project +from nox.project import dependency_groups, python_versions def test_classifiers(): @@ -18,19 +18,19 @@ def test_classifiers(): } } - assert nox.project.python_versions(pyproject) == ["3.7", "3.9", "3.12"] + assert python_versions(pyproject) == ["3.7", "3.9", "3.12"] def test_no_classifiers(): pyproject = {"project": {"requires-python": ">=3.9"}} with pytest.raises(ValueError, match="No Python version classifiers"): - nox.project.python_versions(pyproject) + python_versions(pyproject) def test_no_requires_python(): pyproject = {"project": {"classifiers": ["Programming Language :: Python :: 3.12"]}} with pytest.raises(ValueError, match='No "project.requires-python" value set'): - nox.project.python_versions(pyproject, max_version="3.13") + python_versions(pyproject, max_version="3.13") def test_python_range(): @@ -48,32 +48,21 @@ def test_python_range(): } } - assert nox.project.python_versions(pyproject, max_version="3.12") == [ - "3.10", - "3.11", - "3.12", - ] - assert nox.project.python_versions(pyproject, max_version="3.11") == [ - "3.10", - "3.11", - ] + assert python_versions(pyproject, max_version="3.12") == ["3.10", "3.11", "3.12"] + assert python_versions(pyproject, max_version="3.11") == ["3.10", "3.11"] def test_python_range_gt(): pyproject = {"project": {"requires-python": ">3.2.1,<3.3"}} - assert nox.project.python_versions(pyproject, max_version="3.4") == [ - "3.2", - "3.3", - "3.4", - ] + assert python_versions(pyproject, max_version="3.4") == ["3.2", "3.3", "3.4"] def test_python_range_no_min(): pyproject = {"project": {"requires-python": "==3.3.1"}} with pytest.raises(ValueError, match="No minimum version found"): - nox.project.python_versions(pyproject, max_version="3.5") + python_versions(pyproject, max_version="3.5") def test_dependency_groups(): @@ -90,15 +79,15 @@ def test_dependency_groups(): } } - assert nox.project.dependency_groups(example, "test") == ["pytest", "coverage"] - assert nox.project.dependency_groups(example, "typing-test") == [ + assert dependency_groups(example, "test") == ["pytest", "coverage"] + assert dependency_groups(example, "typing-test") == [ "mypy", "types-requests", "pytest", "coverage", "useful-types", ] - assert nox.project.dependency_groups(example, "typing_test") == [ + assert dependency_groups(example, "typing_test") == [ "mypy", "types-requests", "pytest", From 717116a3292891c2d5e61dc625a9cbd5386c58c6 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Tue, 29 Oct 2024 19:01:51 -0400 Subject: [PATCH 5/5] chore: use 1.2+ (multi-groups upstreamed) Signed-off-by: Henry Schreiner --- .pre-commit-config.yaml | 2 +- nox/project.py | 4 ++-- tests/test_project.py | 10 +++++----- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b717ac50..349dcc77 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -37,7 +37,7 @@ repos: files: ^nox/ args: [] additional_dependencies: - - dependency-groups>=1.1 + - dependency-groups>=1.2 - jinja2 - packaging - importlib_metadata diff --git a/nox/project.py b/nox/project.py index c3117b1c..61c63152 100644 --- a/nox/project.py +++ b/nox/project.py @@ -131,7 +131,7 @@ def python_versions( return [f"3.{v}" for v in range(min_minor_version, max_minor_version + 1)] -def dependency_groups(pyproject: dict[str, Any], *groups: str) -> list[str]: +def dependency_groups(pyproject: dict[str, Any], *groups: str) -> tuple[str, ...]: """ Get a list of dependencies from a ``[dependency-groups]`` section(s). @@ -145,4 +145,4 @@ def test(session): session.install(*nox.project.dependency_groups(pyproject, "dev")) """ dep_groups = pyproject["dependency-groups"] - return [item for g in groups for item in resolve(dep_groups, g)] + return resolve(dep_groups, *groups) diff --git a/tests/test_project.py b/tests/test_project.py index 4dd59c6b..3854902a 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -79,18 +79,18 @@ def test_dependency_groups(): } } - assert dependency_groups(example, "test") == ["pytest", "coverage"] - assert dependency_groups(example, "typing-test") == [ + assert dependency_groups(example, "test") == ("pytest", "coverage") + assert dependency_groups(example, "typing-test") == ( "mypy", "types-requests", "pytest", "coverage", "useful-types", - ] - assert dependency_groups(example, "typing_test") == [ + ) + assert dependency_groups(example, "typing_test") == ( "mypy", "types-requests", "pytest", "coverage", "useful-types", - ] + )