Repository: pdm-project/pdm

Issue:
`pdm update -u` fails when a depenendcy group contains a dependency group include
### Describe the bug

Since [PEP 735](https://peps.python.org/pep-0735/) was accepted, PDM has added support for it. This PEP also came with a specification for [dependency group includes](https://peps.python.org/pep-0735/#dependency-group-include):

```toml
[dependency-groups]
group-a = ["foo"]
group-b = ["foo>1.0"]
group-c = ["foo<1.0"]
all = [
    "foo",
    {include-group = "group-a"},
    {include-group = "group-b"},
    {include-group = "group-c"},
]
```

However, when a dependency group contains such an `{include-group = "..."}` statement, PDM fails to write the changes to `pyproject.toml`.

### To reproduce

1. Run `pdm init` to create a project
2. Use the following `pyproject.toml`
   ```toml
    [project]
    name = "pdm-include-group-bug"
    version = "0.1.0"
    description = "Default template for PDM package"
    
    dependencies = []
    requires-python = "==3.12.*"
    readme = "README.md"
    license = {text = "MIT"}
    
    
    [tool.pdm]
    distribution = false
    
    [dependency-groups]
    lint = [
        "ruff>=0.8.1",
    ]
    test = [
        "pytest>=8.3.4",
        { include-group = "lint" },
    ]
    ```
3.  Run `pdm update -u`, this results in:
    ```
      0:00:05 🔒 Lock successful.
    Changes are written to pyproject.toml.
    Changes are written to pyproject.toml.
    [AttributeError]: 'InlineTable' object has no attribute 'startswith'
    WARNING: Add '-v' to see the detailed traceback
    ```


### Expected Behavior

I expected this to not fail and properly update the pyproject.toml

### Environment Information

## `pdm info`

```
PDM version:
  2.21.0
Python Interpreter:
  C:\<PROJECT_DIR>\pdm-include-group-bug\.venv\Scripts\python.exe (3.12)
Project Root:
  C:/<PROJECT_DIR>/pdm-include-group-bug
Local Packages:
```

## `pdm info --env`

```json
{
  "implementation_name": "cpython",
  "implementation_version": "3.12.2",
  "os_name": "nt",
  "platform_machine": "AMD64",
  "platform_release": "10",
  "platform_system": "Windows",
  "platform_version": "10.0.19045",
  "python_full_version": "3.12.2",
  "platform_python_implementation": "CPython",
  "python_version": "3.12",
  "sys_platform": "win32"
}
```

### pdm -v output

```
pdm.termui: ======== Start resolving requirements ========
pdm.termui:   pytest
pdm.termui:   ruff
pdm.termui:   ruff
pdm.termui:   Adding requirement python==3.12.*
pdm.termui:   Adding requirement pytest
pdm.termui:   Adding requirement ruff
pdm.termui:   Adding requirement ruff
pdm.termui: ======== Starting round 0 ========
pdm.termui: Adding new pin: python None
pdm.termui: ======== Starting round 1 ========
pdm.termui:   Adding requirement colorama; sys_platform == "win32"(from pytest 8.3.4)
pdm.termui:   Adding requirement iniconfig(from pytest 8.3.4)
pdm.termui:   Adding requirement packaging(from pytest 8.3.4)
pdm.termui:   Adding requirement pluggy<2,>=1.5(from pytest 8.3.4)
pdm.termui: Adding new pin: pytest 8.3.4
pdm.termui: ======== Starting round 2 ========

## CENSORED (downloading packages from private repository)

pdm.termui: Adding new pin: ruff 0.8.1
pdm.termui: ======== Starting round 3 ========
pdm.termui: Adding new pin: pluggy 1.5.0
pdm.termui: ======== Starting round 4 ========
pdm.termui: Adding new pin: colorama 0.4.6
pdm.termui: ======== Starting round 5 ========
pdm.termui: Adding new pin: iniconfig 2.0.0
pdm.termui: ======== Starting round 6 ========
pdm.termui: Adding new pin: packaging 24.2
pdm.termui: ======== Starting round 7 ========
pdm.termui: ======== Resolution Result ========
pdm.termui:      python None
pdm.termui:      pytest 8.3.4
pdm.termui:        ruff 0.8.1
pdm.termui:      pluggy 1.5.0
pdm.termui:    colorama 0.4.6
pdm.termui:   iniconfig 2.0.0
pdm.termui:   packaging 24.2
pdm.termui: Fetching hashes for pytest@8.3.4
pdm.termui: Fetching hashes for ruff@0.8.1
Changes are written to pyproject.toml.
Changes are written to pyproject.toml.
Traceback (most recent call last):
  File "<frozen runpy>", line 198, in _run_module_as_main
  File "<frozen runpy>", line 88, in _run_code
  File "c:\users\<USER>\appdata\roaming\python\scripts\pdm.exe\__main__.py", line 7, in <module>
  File "C:\Users\<USER>\AppData\Roaming\pdm\venv\Lib\site-packages\pdm\core.py", line 392, in main
    return core.main(args or sys.argv[1:])
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\<USER>\AppData\Roaming\pdm\venv\Lib\site-packages\pdm\core.py", line 270, in main
    raise cast(Exception, err).with_traceback(traceback) from None
  File "C:\Users\<USER>\AppData\Roaming\pdm\venv\Lib\site-packages\pdm\core.py", line 265, in main
    self.handle(project, options)
  File "C:\Users\<USER>\AppData\Roaming\pdm\venv\Lib\site-packages\pdm\core.py", line 195, in handle
    command.handle(project, options)
  File "C:\Users\<USER>\AppData\Roaming\pdm\venv\Lib\site-packages\pdm\cli\commands\update.py", line 75, in handle
    self.do_update(
  File "C:\Users\<USER>\AppData\Roaming\pdm\venv\Lib\site-packages\pdm\cli\commands\update.py", line 196, in do_update
    project.add_dependencies(deps, group, selection.dev or False)
  File "C:\Users\<USER>\AppData\Roaming\pdm\venv\Lib\site-packages\pdm\project\core.py", line 707, in add_dependencies
    parsed_deps = [parse_line(dep) for dep in deps]
                   ^^^^^^^^^^^^^^^
  File "C:\Users\<USER>\AppData\Roaming\pdm\venv\Lib\site-packages\pdm\models\requirements.py", line 482, in parse_line
    if line.startswith("-e "):
       ^^^^^^^^^^^^^^^
AttributeError: 'InlineTable' object has no attribute 'startswith'
```

### Additional Context

_No response_

### Are you willing to submit a PR to fix this bug?

- [ ] Yes, I would like to submit a PR.

Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/pdm-project/pdm/pull/3336?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) Report
Attention: Patch coverage is `94.11765%` with `1 line` in your changes missing coverage. Please review.
> Project coverage is 85.32%. Comparing base [(`a7ac964`)](https://app.codecov.io/gh/pdm-project/pdm/commit/a7ac964cea71a811159d7e86205c5e248385f610?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) to head [(`95b7ce9`)](https://app.codecov.io/gh/pdm-project/pdm/commit/95b7ce934aa8df14a6c27e55ad8c58c247d10a7f?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project).

| [Files with missing lines](https://app.codecov.io/gh/pdm-project/pdm/pull/3336?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) | Patch % | Lines |
|---|---|---|
| [src/pdm/project/core.py](https://app.codecov.io/gh/pdm-project/pdm/pull/3336?src=pr&el=tree&filepath=src%2Fpdm%2Fproject%2Fcore.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project#diff-c3JjL3BkbS9wcm9qZWN0L2NvcmUucHk=) | 94.11% | [0 Missing and 1 partial :warning: ](https://app.codecov.io/gh/pdm-project/pdm/pull/3336?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #3336      +/-   ##
==========================================
- Coverage   85.39%   85.32%   -0.07%     
==========================================
  Files         112      112              
  Lines       11440    11443       +3     
  Branches     2497     2501       +4     
==========================================
- Hits         9769     9764       -5     
- Misses       1141     1147       +6     
- Partials      530      532       +2     
```

| [Flag](https://app.codecov.io/gh/pdm-project/pdm/pull/3336/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) | Coverage Δ | |
|---|---|---|
| [unittests](https://app.codecov.io/gh/pdm-project/pdm/pull/3336/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) | `85.13% <94.11%> (-0.06%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/pdm-project/pdm/pull/3336?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project).


Patch:
diff --git a/news/3333.bugfix.md b/news/3333.bugfix.md
new file mode 100644
index 00000000..528b9b16
--- /dev/null
+++ b/news/3333.bugfix.md
@@ -0,0 +1,1 @@
+Fix a crash issue when rewriting dependency groups with `include-group` items.
diff --git a/src/pdm/project/core.py b/src/pdm/project/core.py
index 1cf72dcc..f2cacd4a 100644
--- a/src/pdm/project/core.py
+++ b/src/pdm/project/core.py
@@ -65,6 +65,7 @@ def _resolve_dependency_group(
     past_groups: tuple[str, ...] = (),
     optional_dependencies: dict[str, list[str]] | None = None,
     allow_optional: bool = False,
+    recursive_resolve: bool = True,
 ) -> tuple[list[str], dict[str, set[str]]]:
     optional_dependencies = optional_dependencies or {}
     if group in past_groups:
@@ -94,26 +95,28 @@ def _resolve_dependency_group(
                 pass
             else:
                 if normalize_name(req) == project_name:
-                    for extra in extras or ():
-                        resolved, referred = _resolve_dependency_group(
-                            project_name,
-                            child_dependencies,
-                            extra,
-                            (*past_groups, group),
-                            optional_dependencies,
-                            # `self[extra]` is allowed to refer to optional dependency groups
-                            allow_optional=True,
-                        )
-                        realized_group.extend(resolved)
-                        for k, v in referred.items():
-                            referred_groups.setdefault(k, {group}).update(v)
+                    if recursive_resolve:
+                        for extra in extras or ():
+                            resolved, referred = _resolve_dependency_group(
+                                project_name,
+                                child_dependencies,
+                                extra,
+                                (*past_groups, group),
+                                optional_dependencies,
+                                # `self[extra]` is allowed to refer to optional dependency groups
+                                allow_optional=True,
+                            )
+                            realized_group.extend(resolved)
+                            for k, v in referred.items():
+                                referred_groups.setdefault(k, {group}).update(v)
                     continue
             realized_group.append(item)
             referred_groups.setdefault(item, {group})
         elif isinstance(item, dict):
             if tuple(item.keys()) != ("include-group",):
                 raise ProjectError(f"Invalid dependency group item: {item}")
-
+            if not recursive_resolve:
+                continue
             include_group = normalize_name(next(iter(item.values())))
             resolved, referred = _resolve_dependency_group(
                 project_name,
@@ -400,7 +403,7 @@ class Project:
     def python_requires(self) -> PySpecSet:
         return PySpecSet(self.pyproject.metadata.get("requires-python", ""))
 
-    def get_dependencies(self, group: str | None = None) -> Sequence[Requirement]:
+    def get_dependencies(self, group: str | None = None, resolve_include: bool = True) -> Sequence[Requirement]:
         metadata = self.pyproject.metadata
         group = normalize_name(group or "default")
         optional_dependencies = {normalize_name(k): v for k, v in metadata.get("optional-dependencies", {}).items()}
@@ -417,10 +420,12 @@ class Project:
                     "and \\[dependency-groups], the former is taken."
                 )
             if group in optional_dependencies:
-                deps, referred_groups = _resolve_dependency_group(project_name, optional_dependencies, group, ())
+                deps, referred_groups = _resolve_dependency_group(
+                    project_name, optional_dependencies, group, (), recursive_resolve=resolve_include
+                )
             elif group in dev_dependencies:
                 deps, referred_groups = _resolve_dependency_group(
-                    project_name, dev_dependencies, group, (), optional_dependencies
+                    project_name, dev_dependencies, group, (), optional_dependencies, recursive_resolve=resolve_include
                 )
             else:
                 raise PdmUsageError(f"Non-exist group {group}")
@@ -450,7 +455,7 @@ class Project:
 
     @property
     def all_dependencies(self) -> dict[str, Sequence[Requirement]]:
-        return {group: self.get_dependencies(group) for group in self.iter_groups()}
+        return {group: self.get_dependencies(group, resolve_include=False) for group in self.iter_groups()}
 
     @property
     def default_source(self) -> RepositoryConfig:
@@ -643,10 +648,10 @@ class Project:
             dependency_groups: list[str | dict[str, str]] = tomlkit.array().multiline(True)
             dev_dependencies: list[str] = tomlkit.array().multiline(True)
             for dep in deps:
-                if dep.startswith("-e"):
+                if isinstance(dep, str) and dep.startswith("-e"):
                     dev_dependencies.append(dep)
-                    continue
-                dependency_groups.append(dep)
+                else:
+                    dependency_groups.append(dep)
             if dependency_groups:
                 self.pyproject.dependency_groups[group] = dependency_groups
             else:
@@ -704,13 +709,17 @@ class Project:
         updated_indices: set[int] = set()
 
         with cd(self.root):
-            parsed_deps = [parse_line(dep) for dep in deps]
+            parsed_deps = [(parse_line(dep) if isinstance(dep, str) else None) for dep in deps]
 
             for req in requirements:
                 if isinstance(req, str):
                     req = parse_line(req)
                 matched_index = next(
-                    (i for i, r in enumerate(deps) if req.matches(r) and i not in updated_indices),
+                    (
+                        i
+                        for i, r in enumerate(deps)
+                        if isinstance(r, str) and req.matches(r) and i not in updated_indices
+                    ),
                     None,
                 )
                 dep = req.as_line()
@@ -726,8 +735,9 @@ class Project:
         if write:
             self.pyproject.write(show_message)
         for r in parsed_deps:
-            r.groups = [to_group]
-        return parsed_deps
+            if r is not None:
+                r.groups = [to_group]
+        return [r for r in parsed_deps if r is not None]
 
     def init_global_project(self) -> None:
         if not self.is_global or not self.pyproject.empty():


Test patch:
diff --git a/tests/cli/test_add.py b/tests/cli/test_add.py
index 9176c418..2855cc6a 100644
--- a/tests/cli/test_add.py
+++ b/tests/cli/test_add.py
@@ -339,3 +339,13 @@ def test_add_group_with_normalized_name(project, pdm, working_set):
     result = pdm(["add", "-G", "foo-bar", "pytz"], obj=project)
     assert result.exit_code != 0
     assert "Group foo-bar already exists in another non-normalized form" in result.stderr
+
+
+@pytest.mark.usefixtures("working_set")
+def test_add_to_dependency_group_with_include(project, pdm):
+    from pdm.formats.base import make_array
+
+    project.pyproject.dependency_groups.update({"tz": ["pytz"], "web": make_array([{"include-group": "tz"}])})
+    project.pyproject.write()
+    pdm(["add", "-Gweb", "requests"], obj=project, strict=True)
+    assert project.pyproject.dependency_groups["web"] == [{"include-group": "tz"}, "requests>=2.19.1"]
diff --git a/tests/cli/test_update.py b/tests/cli/test_update.py
index 77f3ae3b..d99c0155 100644
--- a/tests/cli/test_update.py
+++ b/tests/cli/test_update.py
@@ -323,3 +323,12 @@ def test_update_group_not_in_lockfile(project, working_set, pdm):
     result = pdm(["update", "--group", "extra"], obj=project)
     assert result.exit_code != 0
     assert "Requested groups not in lockfile: extra" in result.stderr
+
+
+@pytest.mark.usefixtures("working_set")
+def test_update_dependency_group_with_include(project, pdm):
+    from pdm.formats.base import make_array
+
+    project.pyproject.dependency_groups.update({"tz": ["pytz"], "web": make_array([{"include-group": "tz"}])})
+    project.pyproject.write()
+    pdm(["update", "-u"], obj=project, strict=True)