Repository: pdm-project/pdm

Issue:
PDM sync/install parse all dependencies, regardless if they are included or not
### Describe the bug

we have an "editable-inner" as well as "inner" dependency groups
in the newest PDM version (2.22.2) when we install in our CI and don't include the "editable-inner" group, it still tries to parse it and fails (since we don't have the local version of the editable dependency)


### To reproduce

Example pyproject.toml
```
[project]
name = ""
version = ""
description = ""
authors = [
    {name = "liran", email = "liran@mycompany.com"},
]
dependencies = [
    "gunicorn==20.1.0",
]
requires-python = ">=3.9,<3.10"
readme = "README.md"
license = {text = "MIT"}


[project.optional-dependencies]
inner = [
    "config-resolver @ git+ssh://git@github.com/HiredScore/config-resolver.git@5.0.0"
]
[build-system]
requires = ["pdm-backend"]
build-backend = "pdm.backend"

[dependency-groups]
editable-inner = [
    "-e file:///${PROJECT_ROOT}/../config-resolver#egg=config-resolver",
]
```
and run:
`pdm sync --prod -G inner`


### Expected Behavior

I expect the PDM sync command to ignore the "editable-inner" group (as happened pre 2.22.2) and install gunicorn and "config-resolver" from github

### Environment Information

PDM version:
  2.22.2
Python Interpreter:
  /Users/liran.cohen/hiredscore/git/screenit/.venv/bin/python (3.9)
Project Root:
  /Users/liran.cohen/hiredscore/git/screenit
Local Packages:
  
{
  "implementation_name": "cpython",
  "implementation_version": "3.13.1",
  "os_name": "posix",
  "platform_machine": "arm64",
  "platform_release": "23.6.0",
  "platform_system": "Darwin",
  "platform_version": "Darwin Kernel Version 23.6.0: Thu Sep 12 23:35:10 PDT 2024; root:xnu-10063.141.1.701.1~1/RELEASE_ARM64_T6030",
  "python_full_version": "3.9.5",
  "platform_python_implementation": "CPython",
  "python_version": "3.9",
  "sys_platform": "darwin"
}


### Verbose Command Output

reproduced locally:
```
See /Users/liran.cohen/Library/Logs/pdm/pdm-install-puk9rq8i.log for detailed debug log.
[RequirementError]: file:///${PROJECT_ROOT}/../config-resolver#egg=config-resolver: Editable requirement is only supported for VCS link or local directory.
```

### Additional Context

last couple of log lines:
```
File "/opt/homebrew/Cellar/pdm/2.22.2/libexec/lib/python3.13/site-packages/pdm/installers/base.py", line 96, in candidates
    for req in (self.requirements or chain.from_iterable(self.environment.project.all_dependencies.values()))
                                                         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/pdm/2.22.2/libexec/lib/python3.13/site-packages/pdm/project/core.py", line 457, in all_dependencies
    return {group: self.get_dependencies(group, resolve_include=False) for group in self.iter_groups()}
                   ~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/pdm/2.22.2/libexec/lib/python3.13/site-packages/pdm/project/core.py", line 441, in get_dependencies
    req = parse_line(line)
  File "/opt/homebrew/Cellar/pdm/2.22.2/libexec/lib/python3.13/site-packages/pdm/models/requirements.py", line 487, in parse_line
    return parse_requirement(line[3:].strip(), editable=True)
  File "/opt/homebrew/Cellar/pdm/2.22.2/libexec/lib/python3.13/site-packages/pdm/models/requirements.py", line 528, in parse_requirement
    raise RequirementError(f"{line}: Editable requirement is only supported for VCS link or local directory.")
pdm.exceptions.RequirementError: file:///${PROJECT_ROOT}/../config-resolver#egg=config-resolver: Editable requirement is only supported for VCS link or local directory.
```

I suspect it's related to this: https://github.com/pdm-project/pdm/pull/3361
also, downgraded PDM and it resolved it

### 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/3377?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 `69.56522%` with `7 lines` in your changes missing coverage. Please review.
> Project coverage is 85.19%. Comparing base [(`f0ad0c0`)](https://app.codecov.io/gh/pdm-project/pdm/commit/f0ad0c07af9184da790f689c5f1e05344cf6eaa2?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) to head [(`e1bd7f9`)](https://app.codecov.io/gh/pdm-project/pdm/commit/e1bd7f9fdd3329b8b7c13be0c8ca0ae1ec2e3701?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/3377?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/models/requirements.py](https://app.codecov.io/gh/pdm-project/pdm/pull/3377?src=pr&el=tree&filepath=src%2Fpdm%2Fmodels%2Frequirements.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project#diff-c3JjL3BkbS9tb2RlbHMvcmVxdWlyZW1lbnRzLnB5) | 61.11% | [3 Missing and 4 partials :warning: ](https://app.codecov.io/gh/pdm-project/pdm/pull/3377?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    #3377      +/-   ##
==========================================
- Coverage   85.20%   85.19%   -0.02%     
==========================================
  Files         112      112              
  Lines       11492    11496       +4     
  Branches     2517     2521       +4     
==========================================
+ Hits         9792     9794       +2     
- Misses       1173     1174       +1     
- Partials      527      528       +1     
```

| [Flag](https://app.codecov.io/gh/pdm-project/pdm/pull/3377/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/3377/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) | `84.99% <69.56%> (-0.02%)` | :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/3377?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/3376.bugfix.md b/news/3376.bugfix.md
new file mode 100644
index 00000000..a863c688
--- /dev/null
+++ b/news/3376.bugfix.md
@@ -0,0 +1,1 @@
+Don't validate local file requirements that are not used.
diff --git a/src/pdm/formats/poetry.py b/src/pdm/formats/poetry.py
index 0f7a9cf8..cc939e17 100644
--- a/src/pdm/formats/poetry.py
+++ b/src/pdm/formats/poetry.py
@@ -80,7 +80,7 @@ def _convert_req(name: str, req_dict: RequirementDict | list[RequirementDict]) -
         for req in req_dict:
             yield from _convert_req(name, req)
     elif isinstance(req_dict, str):
-        pdm_req = fix_req_path(Requirement.from_req_dict(name, _convert_specifier(req_dict), check_installable=False))
+        pdm_req = fix_req_path(Requirement.from_req_dict(name, _convert_specifier(req_dict)))
         yield pdm_req.as_line()
     else:
         assert isinstance(req_dict, dict)
@@ -100,7 +100,7 @@ def _convert_req(name: str, req_dict: RequirementDict | list[RequirementDict]) -
                 "rev",
                 req_dict.pop("tag", req_dict.pop("branch", None)),  # type: ignore[arg-type]
             )
-        pdm_req = fix_req_path(Requirement.from_req_dict(name, req_dict, check_installable=False))
+        pdm_req = fix_req_path(Requirement.from_req_dict(name, req_dict))
         yield pdm_req.as_line()
 
 
diff --git a/src/pdm/models/candidates.py b/src/pdm/models/candidates.py
index e99b3204..238b917d 100644
--- a/src/pdm/models/candidates.py
+++ b/src/pdm/models/candidates.py
@@ -146,6 +146,8 @@ class Candidate:
         :param link: the file link of the candidate.
         """
         self.req = req
+        if isinstance(req, FileRequirement):
+            req.check_installable()
         self.name = name or self.req.project_name
         self.version = version
         if link is None and not req.is_named:
@@ -277,6 +279,7 @@ class Candidate:
                 result.update(revision=self.get_revision())
         elif not self.req.is_named:
             if self.req.is_file_or_url and self.req.is_local:
+                self.req._root = project_root
                 result.update(path=self.req.str_path)
             else:
                 result.update(url=self.req.url)
diff --git a/src/pdm/models/requirements.py b/src/pdm/models/requirements.py
index a268212e..24dbd709 100644
--- a/src/pdm/models/requirements.py
+++ b/src/pdm/models/requirements.py
@@ -145,7 +145,7 @@ class Requirement:
         try:
             kwargs["specifier"] = get_specifier(version)
         except InvalidSpecifier as e:
-            raise RequirementError(f'Invalid specifier for {kwargs.get("name")}: {version}: {e}') from None
+            raise RequirementError(f"Invalid specifier for {kwargs.get('name')}: {version}: {e}") from None
         return cls(**{k: v for k, v in kwargs.items() if k in inspect.signature(cls).parameters})
 
     @classmethod
@@ -171,7 +171,7 @@ class Requirement:
         return NamedRequirement.create(name=dist.metadata["Name"], version=f"=={dist.version}")
 
     @classmethod
-    def from_req_dict(cls, name: str, req_dict: RequirementDict, check_installable: bool = True) -> Requirement:
+    def from_req_dict(cls, name: str, req_dict: RequirementDict) -> Requirement:
         if isinstance(req_dict, str):  # Version specifier only.
             return NamedRequirement.create(name=name, version=req_dict)
         for vcs in VCS_SCHEMA:
@@ -180,7 +180,7 @@ class Requirement:
                 url = f"{vcs}+{repo}"
                 return VcsRequirement.create(name=name, vcs=vcs, url=url, **req_dict)
         if "path" in req_dict or "url" in req_dict:
-            return FileRequirement.create(name=name, **req_dict, check_installable=check_installable)
+            return FileRequirement.create(name=name, **req_dict)
         return NamedRequirement.create(name=name, **req_dict)
 
     @property
@@ -242,14 +242,11 @@ class FileRequirement(Requirement):
     url: str = ""
     path: Path | None = None
     subdirectory: str | None = None
-    check_installable: bool = True
     _root: Path = dataclasses.field(default_factory=Path.cwd, repr=False)
 
     def __post_init__(self) -> None:
         super().__post_init__()
         self._parse_url()
-        if self.is_local_dir and self.check_installable:
-            self._check_installable()
 
     def _hash_key(self) -> tuple:
         return (*super()._hash_key(), self.get_full_url(), self.editable)
@@ -397,15 +394,20 @@ class FileRequirement(Requirement):
         if not self.name and not self.is_vcs:
             self.name = self.guess_name()
 
-    def _check_installable(self) -> None:
-        assert self.path
-        if not self.path.exists():
-            return
-        if not (self.path.joinpath("setup.py").exists() or self.path.joinpath("pyproject.toml").exists()):
-            raise RequirementError(f"The local path '{self.path}' is not installable.")
-        result = Setup.from_directory(self.path.absolute())
-        if result.name:
-            self.name = result.name
+    def check_installable(self) -> None:
+        if path := self.absolute_path:
+            if not path.exists():
+                raise RequirementError(f"The local path '{self.path}' does not exist.")
+            if path.is_dir():
+                if not path.joinpath("setup.py").exists() and not path.joinpath("pyproject.toml").exists():
+                    raise RequirementError(f"The local path '{self.path}' is not installable.")
+                result = Setup.from_directory(path)
+                if result.name:
+                    self.name = result.name
+            elif self.editable:
+                raise RequirementError("Local file requirement must not be editable.")
+        elif self.editable and not self.is_vcs:
+            raise RequirementError("Non-VCS remote file requirement must not be editable.")
 
 
 @dataclasses.dataclass(eq=False)
@@ -521,8 +523,7 @@ def parse_requirement(line: str, editable: bool = False) -> Requirement:
             r.path = Path(get_relative_path(r.url) or "")
 
     if editable:
-        if r.is_vcs or (r.is_file_or_url and r.is_local_dir):  # type: ignore[attr-defined]
-            assert isinstance(r, FileRequirement)
+        if isinstance(r, FileRequirement) and (r.is_vcs or not r.url or r.url.startswith("file://")):
             r.editable = True
         else:
             raise RequirementError(f"{line}: Editable requirement is only supported for VCS link or local directory.")


Test patch:
diff --git a/tests/models/test_candidates.py b/tests/models/test_candidates.py
index e9c06551..40f6cf59 100644
--- a/tests/models/test_candidates.py
+++ b/tests/models/test_candidates.py
@@ -5,6 +5,7 @@ import shutil
 import pytest
 from unearth import Link
 
+from pdm.exceptions import RequirementError
 from pdm.models.candidates import Candidate
 from pdm.models.requirements import Requirement, parse_requirement
 from pdm.models.specifiers import PySpecSet
@@ -313,6 +314,5 @@ def test_parse_metadata_with_dynamic_fields(project, local_finder):
 
 def test_get_metadata_for_non_existing_path(project):
     req = parse_requirement("file:///${PROJECT_ROOT}/non-existing-path")
-    candidate = Candidate(req)
-    with pytest.raises(FileNotFoundError, match="No such file or directory"):
-        candidate.prepare(project.environment).metadata
+    with pytest.raises(RequirementError, match="The local path '.+' does not exist"):
+        Candidate(req)
diff --git a/tests/models/test_requirements.py b/tests/models/test_requirements.py
index 3190c2cf..5499b2d3 100644
--- a/tests/models/test_requirements.py
+++ b/tests/models/test_requirements.py
@@ -83,6 +83,8 @@ def filter_requirements_to_lines(
 @pytest.mark.parametrize("req, result", REQUIREMENTS)
 def test_convert_req_dict_to_req_line(req, result):
     r = parse_requirement(req)
+    if hasattr(r, "check_installable"):
+        r.check_installable()
     result = result or req
     assert r.as_line() == result