Repository: pdm-project/pdm (python)

Issue:
`pdm remove` does not handle dependency groups properly
### Describe the bug

When running `pdm remove` to remove a package from a dependency group, and this dependency group contains an `include-group` table, `pdm` will error out with `[AttributeError]: 'InlineTable' object has no attribute 'startswith'`.

### To reproduce

```
mkdir pdm-mcve
cd pdm-mcve
cat << EOF > pyproject.toml
[project]
name = "rc-adc"
description = "Resistor Capacitor Analog-to-Digital Converter, written in Amaranth."
authors = [
    {name = "William D. Jones", email = "thor0505@comcast.net"},
]
dependencies = [
    "amaranth>=0.5.3",
]
requires-python = ">=3.10"  # 3.10 for meaningful PyPy notebook support.
readme = "README.md"
license = {text = "BSD-2-Clause license"}
dynamic = ["version"]

[project.optional-dependencies]
daq = [
    "boneless-cpu @ git+https://github.com/whitequark/Boneless-CPU.git",
    "tqdm>=4.67.1",
]
[build-system]
requires = ["pdm-backend"]
build-backend = "pdm.backend"

# pdm.lock may need to be manually tweaked until
# https://github.com/pdm-project/pdm/issues/3426 is resolved.
[tool.pdm.resolution.overrides]
amaranth = "git+https://github.com/amaranth-lang/amaranth.git@v0.5.x"

[dependency-groups]
examples = [
    "rc-adc[daq]"
]
# "pdm run python -m ensurepip" for VSCode!
# pdm venv create -v -n notebook pypy@3.10 --copies if using PyPy.
# You'll be in a world of pain without it!
notebook = [
    "tqdm>=4.67.1",
    { include-group = "examples" },
]
EOF
pdm lock
pdm remove -G notebook tqdm
```

This will result in the following error:

```
Removing packages from notebook dependencies: tqdm
[AttributeError]: 'InlineTable' object has no attribute 'startswith'
WARNING: Add '-v' to see the detailed traceback
```

### Expected Behavior

Actually, other than to ignore the dependency group and continue to the next line, I'm not sure what the correct behavior here should be. Otherwise, since I know the immediate issue/easiest fix, I'd make a PR myself :).

### Environment Information

```
(adc-3.10) william@xubuntu-dtrain:/tmp/pdm-mcve$ pdm info && pdm info --env
PDM version:
  2.23.0
Python Interpreter:
  /tmp/pdm-mcve/.venv/bin/python (3.10)
Project Root:
  /tmp/pdm-mcve
Local Packages:

{
  "implementation_name": "cpython",
  "implementation_version": "3.10.12",
  "os_name": "posix",
  "platform_machine": "x86_64",
  "platform_release": "5.15.0-122-generic",
  "platform_system": "Linux",
  "platform_version": "#132-Ubuntu SMP Thu Aug 29 13:45:52 UTC 2024",
  "python_full_version": "3.10.12",
  "platform_python_implementation": "CPython",
  "python_version": "3.10",
  "sys_platform": "linux"
}
```

### Verbose Command Output

```
(adc-3.10) william@xubuntu-dtrain:/tmp/pdm-mcve$ pdm remove -v -G notebook tqdm
Removing packages from notebook dependencies: tqdm
Traceback (most recent call last):
  File "/home/william/.local/bin/pdm", line 8, in <module>
    sys.exit(main())
  File "/home/william/.local/pipx/venvs/pdm/lib/python3.10/site-packages/pdm/core.py", line 392, in main
    return core.main(args or sys.argv[1:])
  File "/home/william/.local/pipx/venvs/pdm/lib/python3.10/site-packages/pdm/core.py", line 270, in main
    raise cast(Exception, err).with_traceback(traceback) from None
  File "/home/william/.local/pipx/venvs/pdm/lib/python3.10/site-packages/pdm/core.py", line 265, in main
    self.handle(project, options)
  File "/home/william/.local/pipx/venvs/pdm/lib/python3.10/site-packages/pdm/core.py", line 195, in handle
    command.handle(project, options)
  File "/home/william/.local/pipx/venvs/pdm/lib/python3.10/site-packages/pdm/cli/commands/remove.py", line 60, in handle
    self.do_remove(
  File "/home/william/.local/pipx/venvs/pdm/lib/python3.10/site-packages/pdm/cli/commands/remove.py", line 107, in do_remove
    matched_indexes = sorted((i for i, r in enumerate(deps) if req.matches(r)), reverse=True)
  File "/home/william/.local/pipx/venvs/pdm/lib/python3.10/site-packages/pdm/cli/commands/remove.py", line 107, in <genexpr>
    matched_indexes = sorted((i for i, r in enumerate(deps) if req.matches(r)), reverse=True)
  File "/home/william/.local/pipx/venvs/pdm/lib/python3.10/site-packages/pdm/models/requirements.py", line 205, in matches
    req = parse_line(line)
  File "/home/william/.local/pipx/venvs/pdm/lib/python3.10/site-packages/pdm/models/requirements.py", line 486, in parse_line
    if line.startswith("-e "):
AttributeError: 'InlineTable' object has no attribute 'startswith'
```

### Additional Context

Related to #3336?

### Are you willing to submit a PR to fix this bug?

- [x] Yes, I would like to submit a PR.

Pull request:
fix: `pdm remove` does not handle dependency groups properly
Fixes #3452

Signed-off-by: Frost Ming <me@frostming.com>

## Pull Request Checklist

- [ ] A news fragment is added in `news/` describing what is new.
- [ ] Test cases added for changed code.

## Describe what you have changed in this PR.


Patch:
diff --git a/news/3452.bugfix.md b/news/3452.bugfix.md
new file mode 100644
index 00000000..907ed333
--- /dev/null
+++ b/news/3452.bugfix.md
@@ -0,0 +1,1 @@
+Fix a bug that `pdm remove` doesn't handle dependency groups include correctly.
diff --git a/pdm.lock b/pdm.lock
index 1232849d..959f9c2e 100644
--- a/pdm.lock
+++ b/pdm.lock
@@ -5,7 +5,7 @@
 groups = ["default", "all", "cookiecutter", "copier", "doc", "keyring", "pytest", "template", "test", "tox", "workflow"]
 strategy = ["inherit_metadata"]
 lock_version = "4.5.0"
-content_hash = "sha256:5c004ad629e014130dcd0a71ab839c12f525c8b91246ac988b5018b0e88127d7"
+content_hash = "sha256:5b9a6368d6eb1341b227a41c56d341325b743bbd81c1ae6269eba51dc6489474"
 
 [[metadata.targets]]
 requires_python = ">=3.9"
@@ -2083,7 +2083,7 @@ files = [
 
 [[package]]
 name = "unearth"
-version = "0.17.4"
+version = "0.17.5"
 requires_python = ">=3.8"
 summary = "A utility to fetch and download python packages"
 groups = ["default"]
@@ -2092,8 +2092,8 @@ dependencies = [
     "packaging>=20",
 ]
 files = [
-    {file = "unearth-0.17.4-py3-none-any.whl", hash = "sha256:5b0eb24bfd479642ac5dc73a9123fc5f8660f1c2e5672c5fd54b1731c3ac6165"},
-    {file = "unearth-0.17.4.tar.gz", hash = "sha256:ac881e9a39de4654c1c23872b15988dcca4745ef5cdfc9579e85c00c70f99f18"},
+    {file = "unearth-0.17.5-py3-none-any.whl", hash = "sha256:9963e66b14f0484644c9b45b517e530befb2de6a8da4b06a9a38bed2d086dfe6"},
+    {file = "unearth-0.17.5.tar.gz", hash = "sha256:a19e1c02e64b40518d088079c7416fc41b45a648b81a4128aac02597234ee6ba"},
 ]
 
 [[package]]
diff --git a/pyproject.toml b/pyproject.toml
index 89194484..d12f9960 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -20,7 +20,7 @@ dependencies = [
     "rich>=12.3.0",
     "virtualenv>=20",
     "pyproject-hooks",
-    "unearth>=0.17.4",
+    "unearth>=0.17.5",
     "dep-logic>=0.4.4",
     "findpython>=0.6.0,<1.0.0a0",
     "tomlkit>=0.11.1,<1",
diff --git a/src/pdm/models/candidates.py b/src/pdm/models/candidates.py
index 13e9a712..0fd06ed6 100644
--- a/src/pdm/models/candidates.py
+++ b/src/pdm/models/candidates.py
@@ -680,9 +680,11 @@ class PreparedCandidate:
                 dirname, _ = os.path.splitext(self.link.filename)
             return str(src_dir / str(dirname))
         # Otherwise, for source dists, they will be unpacked into a *temp* directory.
-        if self.link not in self._build_dir_cache:
-            self._build_dir_cache[self.link] = self.environment.project.core.create_temp_dir(prefix="pdm-build-")
-        return self._build_dir_cache[self.link]
+        if (build_dir := self._build_dir_cache.get(self.link)) is None:
+            build_dir = self._build_dir_cache[self.link] = self.environment.project.core.create_temp_dir(
+                prefix="pdm-build-"
+            )
+        return build_dir
 
     def _wheel_compatible(self, wheel_file: str, allow_all: bool = False) -> bool:
         env_spec = self.environment.allow_all_spec if allow_all else self.environment.spec
diff --git a/src/pdm/models/requirements.py b/src/pdm/models/requirements.py
index 034fc538..e05084fd 100644
--- a/src/pdm/models/requirements.py
+++ b/src/pdm/models/requirements.py
@@ -202,6 +202,8 @@ class Requirement:
         """Return whether the passed in PEP 508 string
         is the same requirement as this one.
         """
+        if not isinstance(line, str):
+            return False
         req = parse_line(line)
         return self.key == req.key or (
             isinstance(self, FileRequirement) and isinstance(req, FileRequirement) and self.url == req.url


Test patch:
diff --git a/tests/cli/test_remove.py b/tests/cli/test_remove.py
index 5a8b774c..8de0d694 100644
--- a/tests/cli/test_remove.py
+++ b/tests/cli/test_remove.py
@@ -114,3 +114,15 @@ def test_remove_exclude_non_existing_dev_group_in_lockfile(project, pdm):
     assert project.lockfile.groups == ["default"]
     result = pdm(["remove", "requests"], obj=project)
     assert result.exit_code == 0
+
+
+@pytest.mark.usefixtures("working_set")
+def test_remove_package_with_group_include(project, pdm):
+    project.pyproject._data["dependency-groups"] = {
+        "web": ["requests"],
+        "serve": [{"include-group": "web"}, "django"],
+    }
+    project.pyproject.write()
+    pdm(["lock"], obj=project, strict=True)
+    pdm(["remove", "--no-sync", "-Gserve", "django"], obj=project, strict=True)
+    assert "django" not in project.pyproject.dependency_groups["serve"]