From f1186740e0395d4924514da5e26b3a5bec8e6525 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Miro=20Hron=C4=8Dok?= <miro@hroncok.cz>
Date: Mon, 4 Nov 2024 13:32:52 +0100
Subject: [PATCH] %pyproject_buildrequires: Add support for dependency groups
 (PEP 735), via the -g flag

(cherry picked from commit 307d2bef63eadd1ac3ecede2938f6c58e338e183)
---
 README.md                              |  13 ++-
 macros.aaa-pyproject-srpm              |   2 +-
 macros.pyproject                       |  20 +++--
 pyproject-rpm-macros.spec              |   6 +-
 pyproject_buildrequires.py             |  86 +++++++++++++++++++-
 pyproject_buildrequires_testcases.yaml | 107 +++++++++++++++++++++++++
 test_pyproject_buildrequires.py        |   1 +
 7 files changed, 222 insertions(+), 13 deletions(-)

diff --git a/README.md b/README.md
index 7ef8c9e..0fb73bd 100644
--- a/README.md
+++ b/README.md
@@ -124,6 +124,14 @@ For example, if upstream suggests installing test dependencies with
     %generate_buildrequires
     %pyproject_buildrequires -x testing
 
+For projects that specify test requirements using [PEP 735] dependency groups,
+these can be added using the `-g` flag.
+Multiple groups can be supplied by repeating the flag or as a comma separated list.
+For example, if upstream uses a dependency group called `tests`, the test deps would be generated by:
+
+    %generate_buildrequires
+    %pyproject_buildrequires -g tests
+
 For projects that specify test requirements in their [tox] configuration,
 these can be added using the `-t` flag (default tox environment)
 or the `-e` flag followed by the tox environment.
@@ -153,10 +161,12 @@ such plugins will be BuildRequired as well.
 Not all plugins are guaranteed to play well with [tox-current-env],
 in worst case, patch/sed the requirement out from the tox configuration.
 
-Note that neither `-x` or `-t` can be used with `-R`,
+Note that neither `-x` or `-t` can be used with `-R` or `-N`,
 because runtime dependencies are always required for testing.
 You can only use those options if the build backend  supports the [prepare-metadata-for-build-wheel hook],
 or together with `-p` or `-w`.
+However, using `-g` with `-R` or `-N` is supported because dependency groups don't need to be used for testing
+and can be obtained by reading `pyproject.toml` only.
 
 [tox]: https://tox.readthedocs.io/
 [tox-current-env]: https://github.com/fedora-python/tox-current-env/
@@ -520,6 +530,7 @@ so be prepared for problems.
 [PEP 517]: https://www.python.org/dev/peps/pep-0517/
 [PEP 518]: https://www.python.org/dev/peps/pep-0518/
 [PEP 639]: https://www.python.org/dev/peps/pep-0639/
+[PEP 735]: https://www.python.org/dev/peps/pep-0735/
 [pip's documentation]: https://pip.pypa.io/en/stable/cli/pip_install/#vcs-support
 
 
diff --git a/macros.aaa-pyproject-srpm b/macros.aaa-pyproject-srpm
index 06972fc..1b06ac3 100644
--- a/macros.aaa-pyproject-srpm
+++ b/macros.aaa-pyproject-srpm
@@ -4,7 +4,7 @@
 # this macro will cause the package with the real macro to be installed.
 # When macros.pyproject is installed, it overrides this macro.
 # Note: This needs to maintain the same set of options as the real macro.
-%pyproject_buildrequires(rRxtNwpe:C:) echo 'pyproject-rpm-macros' && exit 0
+%pyproject_buildrequires(rRxtNwpe:g:C:) echo 'pyproject-rpm-macros' && exit 0
 
 
 # Declarative buildsystem, requires RPM 4.20+ to work
diff --git a/macros.pyproject b/macros.pyproject
index a02c29a..b2de0e7 100644
--- a/macros.pyproject
+++ b/macros.pyproject
@@ -158,9 +158,16 @@ fi
 %default_toxenv py%{python3_version_nodots}
 %toxenv %{default_toxenv}
 
+%_pyproject_tomlidep %["%{python3_pkgversion}" == "3"\
+    ? "echo '(python%{python3_pkgversion}dist(tomli) if python%{python3_pkgversion}-devel < 3.11)'"\
+    : "%[v"%{python3_pkgversion}" < v"3.11"\
+       ? "echo 'python%{python3_pkgversion}dist(tomli)'"\
+       : "true # will use tomllib, echo nothing"\
+    ]"\
+  ]
 
 # Note: Keep the options in sync with this macro from macros.aaa-pyproject-srpm
-%pyproject_buildrequires(rRxtNwpe:C:) %{expand:\\\
+%pyproject_buildrequires(rRxtNwpe:g:C:) %{expand:\\\
 %_set_pytest_addopts
 # The default flags expect the package note file to exist
 # see https://bugzilla.redhat.com/show_bug.cgi?id=2097535
@@ -181,6 +188,9 @@ fi
 %{-w:%{error:The -N and -w options are mutually exclusive}}
 %{-p:%{error:The -N and -p options are mutually exclusive}}
 %{-C:%{error:The -N and -C options are mutually exclusive}}
+%{-g:if [ -f pyproject.toml ]; then
+  %_pyproject_tomlidep
+fi}
 }
 %{-w:
 %{-p:%{error:The -w and -p options are mutually exclusive}}
@@ -191,13 +201,7 @@ echo 'python%{python3_pkgversion}-devel'
 echo 'python%{python3_pkgversion}dist(packaging)'
 %{!-N:echo 'python%{python3_pkgversion}dist(pip) >= 19'
 if [ -f pyproject.toml ]; then
-  %["%{python3_pkgversion}" == "3"
-    ? "echo '(python%{python3_pkgversion}dist(tomli) if python%{python3_pkgversion}-devel < 3.11)'"
-    : "%[v"%{python3_pkgversion}" < v"3.11"
-       ? "echo 'python%{python3_pkgversion}dist(tomli)'"
-       : "true # will use tomllib, echo nothing"
-    ]"
-  ]
+  %_pyproject_tomlidep
 elif [ -f setup.py ]; then
   # Note: If the default requirements change, also change them in the script!
   echo 'python%{python3_pkgversion}dist(setuptools) >= 40.8'
diff --git a/pyproject-rpm-macros.spec b/pyproject-rpm-macros.spec
index 5553a42..af1c711 100644
--- a/pyproject-rpm-macros.spec
+++ b/pyproject-rpm-macros.spec
@@ -14,7 +14,7 @@ License:        MIT
 #   Increment Y and reset Z when new macros or features are added
 #   Increment Z when this is a bugfix or a cosmetic change
 # Dropping support for EOL Fedoras is *not* considered a breaking change
-Version:        1.15.1
+Version:        1.16.0
 Release:        1%{?dist}
 
 # Macro files
@@ -196,6 +196,10 @@ export HOSTNAME="rpmbuild"  # to speedup tox in network-less mock, see rhbz#1856
 
 
 %changelog
+* Mon Nov 04 2024 Miro HronĨok <mhroncok@redhat.com> - 1.16.0-1
+- %%pyproject_buildrequires: Add support for dependency groups (PEP 735), via the -g flag
+- Fixes: rhbz#2318849
+
 * Thu Oct 03 2024 Karolina Surma <ksurma@redhat.com> - 1.15.1-1
 - Fix handling of self-referencing extras when reading pyproject.toml
 
diff --git a/pyproject_buildrequires.py b/pyproject_buildrequires.py
index bbe3651..4a371a7 100644
--- a/pyproject_buildrequires.py
+++ b/pyproject_buildrequires.py
@@ -468,6 +468,80 @@ def generate_tox_requirements(toxenv, requirements):
                             source=f'tox --print-deps-only: {toxenv}')
 
 
+def generate_dependency_groups(requested_groups, requirements):
+    """Adapted from https://peps.python.org/pep-0735/#reference-implementation (public domain)"""
+    from collections import defaultdict
+
+    def _normalize_name(name: str) -> str:
+        return re.sub(r"[-_.]+", "-", name).lower()
+
+    def _normalize_group_names(dependency_groups: dict) -> dict:
+        original_names = defaultdict(list)
+        normalized_groups = {}
+
+        for group_name, value in dependency_groups.items():
+            normed_group_name = _normalize_name(group_name)
+            original_names[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, group: str, past_groups: tuple[str, ...] = ()
+    ) -> list[str]:
+        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")
+
+        realized_group = []
+        for item in raw_group:
+            if isinstance(item, str):
+                realized_group.append(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())))
+                realized_group.extend(
+                    _resolve_dependency_group(
+                        dependency_groups, include_group, past_groups + (group,)
+                    )
+                )
+            else:
+                raise ValueError(f"Invalid dependency group item: {item}")
+
+        return realized_group
+
+    def resolve(dependency_groups: dict, group: str) -> list[str]:
+        if not isinstance(dependency_groups, dict):
+            raise TypeError("Dependency Groups table is not a dict")
+        return _resolve_dependency_group(dependency_groups, _normalize_name(group))
+
+    pyproject_data = load_pyproject()
+    dependency_groups_raw = pyproject_data.get("dependency-groups", {})
+    dependency_groups = _normalize_group_names(dependency_groups_raw)
+
+    for group_names in requested_groups:
+        for group_name in group_names.split(","):
+            requirements.extend(
+                resolve(dependency_groups, group_name),
+                source=f"Dependency group {group_name}",
+            )
+
+
 def python3dist(name, op=None, version=None, python3_pkgversion="3"):
     prefix = f"python{python3_pkgversion}dist"
 
@@ -480,7 +554,7 @@ def python3dist(name, op=None, version=None, python3_pkgversion="3"):
 
 
 def generate_requires(
-    *, include_runtime=False, build_wheel=False, wheeldir=None, toxenv=None, extras=None,
+    *, include_runtime=False, build_wheel=False, wheeldir=None, toxenv=None, extras=None, dependency_groups=None,
     get_installed_version=importlib.metadata.version,  # for dep injection
     generate_extras=False, python3_pkgversion="3", requirement_files=None, use_build_system=True,
     read_pyproject_dependencies=False,
@@ -514,7 +588,9 @@ def generate_requires(
             generate_build_requirements(backend, requirements)
         if toxenv:
             include_runtime = True
-            generate_tox_requirements(toxenv, requirements)
+            generate_tox_requirements(toxenv, requirements)  # TODO extend dependency_groups
+        if dependency_groups:
+            generate_dependency_groups(dependency_groups, requirements)
         if include_runtime:
             generate_run_requirements(backend, requirements, build_wheel=build_wheel,
                 read_pyproject_dependencies=read_pyproject_dependencies, wheeldir=wheeldir)
@@ -559,6 +635,11 @@ def main(argv):
         help='comma separated list of "extras" for runtime requirements '
              '(e.g. -x testing,feature-x) (implies --runtime, can be repeated)',
     )
+    parser.add_argument(
+        '-g', '--dependency-groups', metavar='GROUPS', action='append',
+        help='comma separated list of dependency groups (PEP 735) for requirements '
+             '(e.g. -g tests,docs) (can be repeated)',
+    )
     parser.add_argument(
         '-t', '--tox', action='store_true',
         help=('generate test tequirements from tox environment '
@@ -627,6 +708,7 @@ def main(argv):
             wheeldir=args.wheeldir,
             toxenv=args.toxenv,
             extras=args.extras,
+            dependency_groups=args.dependency_groups,
             generate_extras=args.generate_extras,
             python3_pkgversion=args.python3_pkgversion,
             requirement_files=args.requirement_files,
diff --git a/pyproject_buildrequires_testcases.yaml b/pyproject_buildrequires_testcases.yaml
index 63cf5cc..eccc228 100644
--- a/pyproject_buildrequires_testcases.yaml
+++ b/pyproject_buildrequires_testcases.yaml
@@ -1278,3 +1278,110 @@ pyproject.toml with self-referencing extras:
     python3dist(pytest-rerunfailures)
     python3dist(wurlitzer)
   result: 0
+
+pyproject.toml with dependency-groups not requested:
+  use_build_system: false
+  pyproject.toml: |
+    [dependency-groups]
+    tests = ["pytest>=5", "pytest-mock"]
+    docs = ["sphinx", "python-docs-theme"]
+  expected: "\n"
+  result: 0
+
+pyproject.toml with dependency-groups and build system:
+  skipif: not SETUPTOOLS_60
+  use_build_system: true
+  installed:
+    setuptools: 50
+    wheel: 1
+    tomli: 1
+  dependency_groups:
+    - tests
+  pyproject.toml: |
+    [build-system]
+    requires = ["setuptools"]
+    build-backend = "setuptools.build_meta"
+    [project]
+    name = "my_package"
+    version = "0.1"
+    [dependency-groups]
+    tests = ["pytest>=5", "pytest-mock"]
+    docs = ["sphinx", "python-docs-theme"]
+  expected: |
+    python3dist(setuptools)
+    python3dist(wheel)
+    python3dist(pytest) >= 5
+    python3dist(pytest-mock)
+  result: 0
+
+pyproject.toml with dependency-groups one requested:
+  use_build_system: false
+  dependency_groups:
+    - tests
+  pyproject.toml: |
+    [dependency-groups]
+    tests = ["pytest>=5", "pytest-mock"]
+    docs = ["sphinx", "python-docs-theme"]
+  expected: |
+    python3dist(pytest) >= 5
+    python3dist(pytest-mock)
+  result: 0
+
+pyproject.toml with dependency-groups two requested:
+  use_build_system: false
+  dependency_groups:
+    - tests
+    - docs
+  pyproject.toml: |
+    [dependency-groups]
+    tests = ["pytest>=5", "pytest-mock"]
+    docs = ["sphinx", "python-docs-theme"]
+  expected: |
+    python3dist(pytest) >= 5
+    python3dist(pytest-mock)
+    python3dist(sphinx)
+    python3dist(python-docs-theme)
+  result: 0
+
+pyproject.toml with dependency-groups two requested via comma:
+  use_build_system: false
+  dependency_groups:
+    - tests,docs
+  pyproject.toml: |
+    [dependency-groups]
+    tests = ["pytest>=5", "pytest-mock"]
+    docs = ["sphinx", "python-docs-theme"]
+  expected: |
+    python3dist(pytest) >= 5
+    python3dist(pytest-mock)
+    python3dist(sphinx)
+    python3dist(python-docs-theme)
+  result: 0
+
+pyproject.toml with include-group:
+  use_build_system: false
+  dependency_groups:
+    - tests_docs
+  pyproject.toml: |
+    [dependency-groups]
+    tests = ["pytest>=5", "pytest-mock"]
+    docs = ["sphinx", "python-docs-theme"]
+    typing = ["mypy"]
+    tests-docs = [{include-group = "tests"}, {include-group = "docs"}, "pytest-sphinx"]
+  expected: |
+    python3dist(pytest) >= 5
+    python3dist(pytest-mock)
+    python3dist(sphinx)
+    python3dist(python-docs-theme)
+    python3dist(pytest-sphinx)
+  result: 0
+
+pyproject.toml with dependency-groups nonexisting requested:
+  use_build_system: false
+  dependency_groups:
+    - typing
+  pyproject.toml: |
+    [dependency-groups]
+    tests = ["pytest>=5", "pytest-mock"]
+    docs = ["sphinx", "python-docs-theme"]
+  except: LookupError
diff --git a/test_pyproject_buildrequires.py b/test_pyproject_buildrequires.py
index e5c3b9b..b20ace0 100644
--- a/test_pyproject_buildrequires.py
+++ b/test_pyproject_buildrequires.py
@@ -71,6 +71,7 @@ def test_data(case_name, capfd, tmp_path, monkeypatch):
             build_wheel=case.get('build_wheel', False),
             wheeldir=str(wheeldir),
             extras=case.get('extras', []),
+            dependency_groups=case.get('dependency_groups', []),
             toxenv=case.get('toxenv', None),
             generate_extras=case.get('generate_extras', False),
             requirement_files=requirement_files,