Repository: pdm-project/pdm

Issue:
Same package dependency with relative url is considered different even though absolute url is the same
- [X] I have searched the issue tracker and believe that this is not a duplicate.

## Context
On a monorepo, if 2 packages (package_a and package_b) have the same dependency (core) using a relative url, it fails to install the dependency

```
dependencies = [
    "core @ file:///${PROJECT_ROOT}/../core",
]
```

POC: https://github.com/micaelmalta/pdm_test

## Steps to reproduce

```
git clone git@github.com:micaelmalta/pdm_test.git
pdm venv create
pdm install
```

## Actual behavior

```Lock file does not exist
Updating the lock file...
🔒 Lock failed
Unable to find a resolution for core
because of the following conflicts:
    core @ file:///xxx/pdm_test/package_a/../core (from package-a@file:///${PROJECT_ROOT}/package_a)
    core @ file:///xxx/pdm_test/package_b/../core (from package-b@file:///${PROJECT_ROOT}/package_b)
To fix this, you could loosen the dependency version constraints in pyproject.toml. See https://pdm.fming.dev/latest/usage/dependency/#solve-the-locking-failure for more details.
See /var/folders/64/plzsy9_538x5rw7vv90m74j40000gp/T/pdm-lock-gl5qasxy.log for detailed debug log.
[ResolutionImpossible]: Unable to find a resolution
Add '-v' to see the detailed traceback
```

## Expected behavior

Core package successfuly install

## Environment Information

```bash
# Paste the output of `pdm info && pdm info --env` below:

PDM version:
  2.4.5
Python Interpreter:
  /xxx/pdm_test/.venv/bin/python (3.11)
Project Root:
  /xxx/pdm_test
Project Packages:
  None
{
  "implementation_name": "cpython",
  "implementation_version": "3.11.1",
  "os_name": "posix",
  "platform_machine": "arm64",
  "platform_release": "21.6.0",
  "platform_system": "Darwin",
  "platform_version": "Darwin Kernel Version 21.6.0: Thu Sep 29 20:11:33 PDT 2022; root:xnu-8020.240.7~1/RELEASE_ARM64_T8110",
  "python_full_version": "3.11.1",
  "platform_python_implementation": "CPython",
  "python_version": "3.11",
  "sys_platform": "darwin"
}
```


Discussion:

Patch:
diff --git a/news/1702.bugfix.md b/news/1702.bugfix.md
new file mode 100644
index 00000000..bf9269c7
--- /dev/null
+++ b/news/1702.bugfix.md
@@ -0,0 +1,1 @@
+Fix a resolution failure when the project has cascading relative path dependencies.
diff --git a/src/pdm/models/repositories.py b/src/pdm/models/repositories.py
index 6ae8308b..52bbf459 100644
--- a/src/pdm/models/repositories.py
+++ b/src/pdm/models/repositories.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 import dataclasses
+import posixpath
 import sys
 from functools import lru_cache, wraps
 from typing import TYPE_CHECKING, Any, Callable, Iterable, Mapping, TypeVar, cast
@@ -17,7 +18,13 @@ from pdm.models.requirements import (
 )
 from pdm.models.search import SearchResultParser
 from pdm.models.specifiers import PySpecSet
-from pdm.utils import cd, normalize_name, url_without_fragments
+from pdm.utils import (
+    cd,
+    normalize_name,
+    path_to_url,
+    url_to_path,
+    url_without_fragments,
+)
 
 if TYPE_CHECKING:
     from pdm._types import CandidateInfo, SearchResult, Source
@@ -392,8 +399,8 @@ class LockedRepository(BaseRepository):
         return {can.req.identify(): can for can in self.packages.values()}
 
     def _read_lockfile(self, lockfile: Mapping[str, Any]) -> None:
-        with cd(self.environment.project.root):
-            backend = self.environment.project.backend
+        root = self.environment.project.root
+        with cd(root):
             for package in lockfile.get("package", []):
                 version = package.get("version")
                 if version:
@@ -406,8 +413,8 @@ class LockedRepository(BaseRepository):
                 }
                 req = Requirement.from_req_dict(package_name, req_dict)
                 if req.is_file_or_url and req.path and not req.url:  # type: ignore
-                    req.url = backend.relative_path_to_url(  # type: ignore
-                        req.path.as_posix()  # type: ignore
+                    req.url = path_to_url(  # type: ignore
+                        posixpath.join(root, req.path)  # type: ignore
                     )
                 can = make_candidate(req, name=package_name, version=version)
                 can_id = self._identify_candidate(can)
@@ -426,10 +433,16 @@ class LockedRepository(BaseRepository):
 
     def _identify_candidate(self, candidate: Candidate) -> tuple:
         url = getattr(candidate.req, "url", None)
+        if url is not None:
+            url = url_without_fragments(url)
+            url = self.environment.project.backend.expand_line(url)
+            if url.startswith("file://"):
+                path = posixpath.normpath(url_to_path(url))
+                url = path_to_url(path)
         return (
             candidate.identify(),
             candidate.version if not url else None,
-            url_without_fragments(url) if url else None,
+            url,
             candidate.req.editable,
         )
 
diff --git a/src/pdm/models/requirements.py b/src/pdm/models/requirements.py
index fb39f60c..77206405 100644
--- a/src/pdm/models/requirements.py
+++ b/src/pdm/models/requirements.py
@@ -5,6 +5,7 @@ import functools
 import inspect
 import json
 import os
+import posixpath
 import re
 import secrets
 import urllib.parse as urlparse
@@ -278,8 +279,15 @@ class FileRequirement(Requirement):
     def str_path(self) -> str | None:
         if not self.path:
             return None
-        result = self.path.as_posix()
-        if not self.path.is_absolute() and not result.startswith(("./", "../")):
+        if self.path.is_absolute():
+            try:
+                result = self.path.relative_to(Path.cwd()).as_posix()
+            except ValueError:
+                return self.path.as_posix()
+        else:
+            result = self.path.as_posix()
+        result = posixpath.normpath(result)
+        if not result.startswith(("./", "../")):
             result = "./" + result
         if result.startswith("./../"):
             result = result[2:]
diff --git a/src/pdm/resolver/providers.py b/src/pdm/resolver/providers.py
index 77b3301f..3bfae8ef 100644
--- a/src/pdm/resolver/providers.py
+++ b/src/pdm/resolver/providers.py
@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+import os
 from typing import TYPE_CHECKING, Callable, cast
 
 from packaging.specifiers import InvalidSpecifier, SpecifierSet
@@ -7,7 +8,7 @@ from resolvelib import AbstractProvider
 
 from pdm.models.candidates import Candidate, make_candidate
 from pdm.models.repositories import LockedRepository
-from pdm.models.requirements import parse_requirement, strip_extras
+from pdm.models.requirements import FileRequirement, parse_requirement, strip_extras
 from pdm.resolver.python import (
     PythonCandidate,
     PythonRequirement,
@@ -157,18 +158,23 @@ class BaseProvider(AbstractProvider):
 
         return matches_gen
 
+    def _compare_file_reqs(self, req1: FileRequirement, req2: FileRequirement) -> bool:
+        backend = self.repository.environment.project.backend
+        if req1.path and req2.path:
+            return os.path.normpath(req1.path) == os.path.normpath(req2.path)
+        left = backend.expand_line(url_without_fragments(req1.get_full_url()))
+        right = backend.expand_line(url_without_fragments(req2.get_full_url()))
+        return left == right
+
     def is_satisfied_by(self, requirement: Requirement, candidate: Candidate) -> bool:
         if isinstance(requirement, PythonRequirement):
             return is_python_satisfied_by(requirement, candidate)
         elif candidate.identify() in self.overrides:
             return True
         if not requirement.is_named:
-            backend = self.repository.environment.project.backend
-            return not candidate.req.is_named and backend.expand_line(
-                url_without_fragments(candidate.req.get_full_url())  # type: ignore
-            ) == backend.expand_line(
-                url_without_fragments(requirement.get_full_url())  # type: ignore
-            )
+            if candidate.req.is_named:
+                return False
+            return self._compare_file_reqs(requirement, candidate.req)  # type: ignore
         version = candidate.version
         this_name = self.repository.environment.project.name
         if version is None or candidate.name == this_name:


Test patch:
diff --git a/tests/cli/test_install.py b/tests/cli/test_install.py
index d5b82ae7..4fd16d9c 100644
--- a/tests/cli/test_install.py
+++ b/tests/cli/test_install.py
@@ -3,6 +3,7 @@ import pytest
 from pdm.cli import actions
 from pdm.models.requirements import parse_requirement
 from pdm.pytest import Distribution
+from pdm.utils import cd
 
 
 @pytest.mark.usefixtures("repository")
@@ -206,7 +207,6 @@ def test_sync_with_pure_option(project, working_set, invoke):
     assert "django" not in working_set
 
 
-@pytest.mark.usefixtures("repository")
 def test_install_referencing_self_package(project, working_set, invoke):
     project.add_dependencies({"pytz": parse_requirement("pytz")}, to_group="tz")
     project.add_dependencies({"urllib3": parse_requirement("urllib3")}, to_group="web")
@@ -216,3 +216,11 @@ def test_install_referencing_self_package(project, working_set, invoke):
     invoke(["install", "-Gall"], obj=project, strict=True)
     assert "pytz" in working_set
     assert "urllib3" in working_set
+
+
+def test_install_monorepo_with_rel_paths(fixture_project, invoke, working_set):
+    project = fixture_project("test-monorepo")
+    with cd(project.root):
+        invoke(["install"], obj=project, strict=True)
+    for package in ("package-a", "package-b", "core"):
+        assert package in working_set
diff --git a/tests/conftest.py b/tests/conftest.py
index 40611276..957b4a26 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -99,6 +99,7 @@ def fixture_project(project_no_init):
         source = FIXTURES / "projects" / project_name
         copytree(source, project_no_init.root)
         project_no_init.pyproject.reload()
+        project_no_init.environment = None
         return project_no_init
 
     return func
diff --git a/tests/fixtures/projects/test-monorepo/README.md b/tests/fixtures/projects/test-monorepo/README.md
new file mode 100644
index 00000000..39e237d2
--- /dev/null
+++ b/tests/fixtures/projects/test-monorepo/README.md
@@ -0,0 +1,1 @@
+# pdm_test
diff --git a/tests/fixtures/projects/test-monorepo/core/core.py b/tests/fixtures/projects/test-monorepo/core/core.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/fixtures/projects/test-monorepo/core/pyproject.toml b/tests/fixtures/projects/test-monorepo/core/pyproject.toml
new file mode 100644
index 00000000..7dbe43b5
--- /dev/null
+++ b/tests/fixtures/projects/test-monorepo/core/pyproject.toml
@@ -0,0 +1,10 @@
+[project]
+name = "core"
+version = "0.0.1"
+description = ""
+requires-python = ">= 3.7"
+dependencies = []
+
+[build-system]
+requires = ["pdm-pep517"]
+build-backend = "pdm.pep517.api"
diff --git a/tests/fixtures/projects/test-monorepo/package_a/alice.py b/tests/fixtures/projects/test-monorepo/package_a/alice.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/fixtures/projects/test-monorepo/package_a/pyproject.toml b/tests/fixtures/projects/test-monorepo/package_a/pyproject.toml
new file mode 100644
index 00000000..95cd0d92
--- /dev/null
+++ b/tests/fixtures/projects/test-monorepo/package_a/pyproject.toml
@@ -0,0 +1,12 @@
+[project]
+name = "package_a"
+version = "0.0.1"
+description = ""
+requires-python = ">= 3.7"
+dependencies = [
+    "core @ file:///${PROJECT_ROOT}/../core",
+]
+
+[build-system]
+requires = ["pdm-pep517"]
+build-backend = "pdm.pep517.api"
diff --git a/tests/fixtures/projects/test-monorepo/package_b/bob.py b/tests/fixtures/projects/test-monorepo/package_b/bob.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/fixtures/projects/test-monorepo/package_b/pyproject.toml b/tests/fixtures/projects/test-monorepo/package_b/pyproject.toml
new file mode 100644
index 00000000..6f673c0c
--- /dev/null
+++ b/tests/fixtures/projects/test-monorepo/package_b/pyproject.toml
@@ -0,0 +1,12 @@
+[project]
+name = "package_b"
+version = "0.0.1"
+description = ""
+requires-python = ">= 3.7"
+dependencies = [
+    "core @ file:///${PROJECT_ROOT}/../core",
+]
+
+[build-system]
+requires = ["pdm-pep517"]
+build-backend = "pdm.pep517.api"
diff --git a/tests/fixtures/projects/test-monorepo/pyproject.toml b/tests/fixtures/projects/test-monorepo/pyproject.toml
new file mode 100644
index 00000000..6ffaf01f
--- /dev/null
+++ b/tests/fixtures/projects/test-monorepo/pyproject.toml
@@ -0,0 +1,10 @@
+[project]
+requires-python = ">= 3.7"
+dependencies = [
+    "package_a @ file:///${PROJECT_ROOT}/package_a",
+    "package_b @ file:///${PROJECT_ROOT}/package_b",
+]
+
+[build-system]
+requires = ["pdm-pep517"]
+build-backend = "pdm.pep517.api"