Repository: tweag/FawltyDeps

Issue:
Add support for PEP 735 -- Dependency Groups in pyproject.toml
See https://peps.python.org/pep-0735/ for details.

This should be relatively straightforward, given our existing support for many different fields in pyproject.toml.

Discussion:

Solution patch:
diff --git a/fawltydeps/extract_deps/pyproject_toml_parser.py b/fawltydeps/extract_deps/pyproject_toml_parser.py
index c997a7c..7aa46b4 100644
--- a/fawltydeps/extract_deps/pyproject_toml_parser.py
+++ b/fawltydeps/extract_deps/pyproject_toml_parser.py
@@ -147,6 +147,33 @@ def parse_pep621_pyproject_contents(  # noqa: C901
     )
 
 
+def parse_pep735_pyproject_contents(
+    parsed_contents: TomlData, source: Location
+) -> Iterator[DeclaredDependency]:
+    """Extract dependencies from a pyproject.toml using PEP 735 dependency groups."""
+
+    def parse_dep_groups(contents: TomlData, src: Location) -> NamedLocations:
+        for group in contents["dependency-groups"].values():
+            for req in group:
+                if isinstance(req, dict) and len(req) == 1 and "include-group" in req:
+                    # This include refers to another dependency group. At this
+                    # time we don't differentiate between which group
+                    # dependencies come from, nor do we care how many times
+                    # a dependency is mentioned/included. Therefore, we can
+                    # simply assume that the group being referenced here will
+                    # be parsed/handled on its own, and we don't need to take
+                    # steps to find/yield the same deps from here.
+                    pass
+                else:
+                    yield req, src
+
+    fields_parsers = [("dependency-groups", parse_dep_groups)]
+
+    yield from parse_pyproject_elements(
+        parsed_contents, source, "PEP735", fields_parsers
+    )
+
+
 def parse_dynamic_pyproject_contents(
     parsed_contents: TomlData, source: Location
 ) -> Iterator[DeclaredDependency]:
@@ -258,6 +285,9 @@ def parse_pyproject_toml(path: Path) -> Iterator[DeclaredDependency]:
         if dep.name not in skip:
             yield dep
 
+    for dep in parse_pep735_pyproject_contents(parsed_contents, source):
+        yield dep
+
     if "poetry" in parsed_contents.get("tool", {}):
         yield from parse_poetry_pyproject_dependencies(
             parsed_contents["tool"]["poetry"], source


Test patch:
diff --git a/tests/test_extract_deps_pyproject.py b/tests/test_extract_deps_pyproject.py
index 6479bc6..f0406a4 100644
--- a/tests/test_extract_deps_pyproject.py
+++ b/tests/test_extract_deps_pyproject.py
@@ -340,6 +340,63 @@ from fawltydeps.types import DeclaredDependency, Location
             ["pandas"],
             id="pixi_pyproject_self_dep_is_ignored",
         ),
+        pytest.param(
+            """\
+            [project]
+            name = "myapp"
+            version = "1.0"
+            description = "My App"
+            requires-python = ">=3.13"
+            dependencies = [
+                "flask",
+            ]
+
+            [dependency-groups]
+            dev = [
+                "debugpy",
+                "fawltydeps>=0.18.0",
+                "pytest>=8.3.4",
+                "pytest-flask",
+                "pytest-order",
+                "pytest-timeout",
+                "rich",
+                "ruff",
+                "watchdog>=6.0.0",
+            ]
+            """,
+            [
+                "flask",
+                "debugpy",
+                "fawltydeps",
+                "pytest",
+                "pytest-flask",
+                "pytest-order",
+                "pytest-timeout",
+                "rich",
+                "ruff",
+                "watchdog",
+            ],
+            id="pep735_dependency_groups",
+        ),
+        pytest.param(
+            """\
+            [dependency-groups]
+            test = ["pytest", "coverage"]
+            docs = ["sphinx", "sphinx-rtd-theme"]
+            typing = ["mypy", "types-requests"]
+            typing-test = [{include-group = "typing"}, {include-group = "test"}, "useful-types"]
+            """,
+            [
+                "pytest",
+                "coverage",
+                "sphinx",
+                "sphinx-rtd-theme",
+                "mypy",
+                "types-requests",
+                "useful-types",
+            ],
+            id="pep735_dependency_groups_with_include_group",
+        ),
     ],
 )
 def test_parse_pyproject_toml__wellformed_dependencies__yields_dependencies(