Repository: pdm-project/pdm

Issue:
pdm lock & pdm install on git dependencies
- [x] I have searched the issue tracker and believe that this is not a duplicate.

**Make sure you run commands with `-v` flag before pasting the output.**

## Steps to reproduce

1. Create a clean folder
2. `pdm init -n`
3. `echo git+https://github.com/online-ml/river > requirements.txt`
4. `pdm import requirements.txt`
5. `pdm lock`
6. `pdm install` <-- this fails.

However, `pdm add git+https://github.com/online-ml/river` works so there seems to be something weird with the flow.

## Actual behavior

```❯ pdm install -vv
STATUS: Resolving packages from lockfile...
Traceback (most recent call last):
  File "/Users/martinroed/.local/bin/pdm", line 8, in <module>
    sys.exit(main())
  File "/Users/martinroed/.local/pipx/venvs/pdm/lib/python3.10/site-packages/pdm/core.py", line 255, in main
    return Core().main(args)
  File "/Users/martinroed/.local/pipx/venvs/pdm/lib/python3.10/site-packages/pdm/core.py", line 193, in main
    raise cast(Exception, err).with_traceback(traceback) from None
  File "/Users/martinroed/.local/pipx/venvs/pdm/lib/python3.10/site-packages/pdm/core.py", line 188, in main
    self.handle(project, options)
  File "/Users/martinroed/.local/pipx/venvs/pdm/lib/python3.10/site-packages/pdm/core.py", line 154, in handle
    command.handle(project, options)
  File "/Users/martinroed/.local/pipx/venvs/pdm/lib/python3.10/site-packages/pdm/cli/commands/install.py", line 62, in handle
    actions.do_sync(
  File "/Users/martinroed/.local/pipx/venvs/pdm/lib/python3.10/site-packages/pdm/cli/actions.py", line 197, in do_sync
    candidates = resolve_candidates_from_lockfile(project, requirements)
  File "/Users/martinroed/.local/pipx/venvs/pdm/lib/python3.10/site-packages/pdm/cli/actions.py", line 140, in resolve_candidates_from_lockfile
    mapping, *_ = resolve(
  File "/Users/martinroed/.local/pipx/venvs/pdm/lib/python3.10/site-packages/pdm/resolver/core.py", line 35, in resolve
    result = resolver.resolve(requirements, max_rounds)
  File "/Users/martinroed/.local/pipx/venvs/pdm/lib/python3.10/site-packages/resolvelib/resolvers.py", line 546, in resolve
    state = resolution.resolve(requirements, max_rounds=max_rounds)
  File "/Users/martinroed/.local/pipx/venvs/pdm/lib/python3.10/site-packages/resolvelib/resolvers.py", line 397, in resolve
    self._add_to_criteria(self.state.criteria, r, parent=None)
  File "/Users/martinroed/.local/pipx/venvs/pdm/lib/python3.10/site-packages/resolvelib/resolvers.py", line 173, in _add_to_criteria
    if not criterion.candidates:
  File "/Users/martinroed/.local/pipx/venvs/pdm/lib/python3.10/site-packages/resolvelib/structs.py", line 127, in __bool__
    next(iter(self))
  File "/Users/martinroed/.local/pipx/venvs/pdm/lib/python3.10/site-packages/pdm/resolver/providers.py", line 140, in <genexpr>
    return (
  File "/Users/martinroed/.local/pipx/venvs/pdm/lib/python3.10/site-packages/pdm/models/repositories.py", line 472, in find_candidates
    for key in self._matching_keys(requirement):
  File "/Users/martinroed/.local/pipx/venvs/pdm/lib/python3.10/site-packages/pdm/models/repositories.py", line 456, in _matching_keys
    if can_req.path != getattr(requirement, "path", None):  # type: ignore[attr-defined]
AttributeError: 'NamedRequirement' object has no attribute 'path'
```


## Expected behavior

Install should work!

## Environment Information

```bash
❯ pdm info && pdm info --env
PDM version:
  2.5.2
Python Interpreter:
  /Users/martinroed/play/debug-river/.venv/bin/python (3.11)
Project Root:
  /Users/martinroed/play/debug-river
Local Packages:


PDM 2.5.2 is installed, while 2.7.0 is available.
Please run `pdm self update` to upgrade.
Run `pdm config check_update false` to disable the check.
{
  "implementation_name": "cpython",
  "implementation_version": "3.11.3",
  "os_name": "posix",
  "platform_machine": "arm64",
  "platform_release": "22.2.0",
  "platform_system": "Darwin",
  "platform_version": "Darwin Kernel Version 22.2.0: Fri Nov 11 02:06:26 PST 2022;
root:xnu-8792.61.2~4/RELEASE_ARM64_T8112",
  "python_full_version": "3.11.3",
  "platform_python_implementation": "CPython",
  "python_version": "3.11",
  "sys_platform": "darwin"
}

PDM 2.5.2 is installed, while 2.7.0 is available.
Please run `pdm self update` to upgrade.
Run `pdm config check_update false` to disable the check.
```


Comments:

Patch:
diff --git a/news/1970.bugfix.md b/news/1970.bugfix.md
new file mode 100644
index 00000000..df68a998
--- /dev/null
+++ b/news/1970.bugfix.md
@@ -0,0 +1,1 @@
+Guess the project name from VCS url if it is missing when importing from requirements.txt.
diff --git a/src/pdm/formats/requirements.py b/src/pdm/formats/requirements.py
index f7a32e04..c18ebb98 100644
--- a/src/pdm/formats/requirements.py
+++ b/src/pdm/formats/requirements.py
@@ -8,7 +8,7 @@ import urllib.parse
 from typing import TYPE_CHECKING, Any, Mapping
 
 from pdm.formats.base import make_array
-from pdm.models.requirements import Requirement, parse_requirement
+from pdm.models.requirements import FileRequirement, Requirement, parse_requirement
 
 if TYPE_CHECKING:
     from argparse import Namespace
@@ -53,7 +53,11 @@ class RequirementParser:
         if not line.startswith("-"):
             # Starts with a requirement, just ignore all per-requirement options
             req_string = line.split(" -", 1)[0].strip()
-            self.requirements.append(parse_requirement(req_string))
+            req = parse_requirement(req_string)
+            if not req.name:
+                assert isinstance(req, FileRequirement)
+                req.name = req.guess_name()
+            self.requirements.append(req)
             return
         args, _ = self._parser.parse_known_args(shlex.split(line))
         if args.index_url:
diff --git a/src/pdm/models/repositories.py b/src/pdm/models/repositories.py
index 8cd5a582..ded3887e 100644
--- a/src/pdm/models/repositories.py
+++ b/src/pdm/models/repositories.py
@@ -4,7 +4,7 @@ import dataclasses
 import posixpath
 import sys
 from functools import wraps
-from typing import TYPE_CHECKING, Any, Callable, Iterable, Mapping, TypeVar, cast
+from typing import TYPE_CHECKING, TypeVar, cast
 
 from pdm import termui
 from pdm.exceptions import CandidateInfoNotFound, CandidateNotFound
@@ -25,11 +25,15 @@ from pdm.utils import (
 )
 
 if TYPE_CHECKING:
+    from typing import Any, Callable, Iterable, Mapping
+
     from unearth import Link
 
     from pdm._types import CandidateInfo, RepositoryConfig, SearchResult
     from pdm.environments import BaseEnvironment
 
+    CandidateKey = tuple[str, str | None, str | None, bool]
+
 ALLOW_ALL_PYTHON = PySpecSet()
 T = TypeVar("T", bound="BaseRepository")
 
@@ -390,9 +394,9 @@ class LockedRepository(BaseRepository):
         environment: BaseEnvironment,
     ) -> None:
         super().__init__(sources, environment, ignore_compatibility=False)
-        self.packages: dict[tuple, Candidate] = {}
+        self.packages: dict[CandidateKey, Candidate] = {}
         self.file_hashes: dict[tuple[str, str], dict[Link, str]] = {}
-        self.candidate_info: dict[tuple, CandidateInfo] = {}
+        self.candidate_info: dict[CandidateKey, CandidateInfo] = {}
         self._read_lockfile(lockfile)
 
     @property
@@ -428,7 +432,7 @@ class LockedRepository(BaseRepository):
                 Link(item["url"]): item["hash"] for item in hashes if "url" in item
             }
 
-    def _identify_candidate(self, candidate: Candidate) -> tuple:
+    def _identify_candidate(self, candidate: Candidate) -> CandidateKey:
         url = getattr(candidate.req, "url", None)
         if url is not None:
             url = url_without_fragments(url)
@@ -452,17 +456,22 @@ class LockedRepository(BaseRepository):
             self._get_dependencies_from_lockfile,
         )
 
-    def _matching_keys(self, requirement: Requirement) -> Iterable[tuple]:
+    def _matching_keys(self, requirement: Requirement) -> Iterable[CandidateKey]:
+        from pdm.models.requirements import FileRequirement
+
         for key in self.candidate_info:
+            can_req = self.packages[key].req
             if requirement.name:
                 if key[0] != requirement.identify():
                     continue
-            elif key[2] is not None:
-                if key[2] != url_without_fragments(getattr(requirement, "url", "")):
-                    continue
             else:
-                can_req = self.packages[key].req
-                if can_req.path != getattr(requirement, "path", None):  # type: ignore[attr-defined]
+                assert isinstance(requirement, FileRequirement)
+                if not isinstance(can_req, FileRequirement):
+                    continue
+                if requirement.path and can_req.path:
+                    if requirement.path != can_req.path:
+                        continue
+                elif key[2] is not None and key[2] != url_without_fragments(requirement.url):
                     continue
 
             yield key
diff --git a/src/pdm/models/requirements.py b/src/pdm/models/requirements.py
index 9f1f21f6..41b6b4ee 100644
--- a/src/pdm/models/requirements.py
+++ b/src/pdm/models/requirements.py
@@ -258,6 +258,35 @@ class FileRequirement(Requirement):
     def _hash_key(self) -> tuple:
         return (*super()._hash_key(), self.get_full_url(), self.editable)
 
+    def guess_name(self) -> str | None:
+        filename = os.path.basename(urlparse.unquote(url_without_fragments(self.url))).rsplit("@", 1)[0]
+        if self.is_vcs:
+            if self.vcs == "git":  # type: ignore[attr-defined]
+                name = filename
+                if name.endswith(".git"):
+                    name = name[:-4]
+                return name
+            elif self.vcs == "hg":  # type: ignore[attr-defined]
+                return filename
+            else:  # svn and bzr
+                name, in_branch, _ = filename.rpartition("/branches/")
+                if not in_branch and name.endswith("/trunk"):
+                    return name[:-6]
+                return name
+        elif filename.endswith(".whl"):
+            return parse_wheel_filename(filename)[0]
+        else:
+            try:
+                return parse_sdist_filename(filename)[0]
+            except ValueError:
+                match = _egg_info_re.match(filename)
+                # Filename is like `<name>-<version>.tar.gz`, where name will be
+                # extracted and version will be left to be determined from
+                # the metadata.
+                if match:
+                    return match.group(1)
+        return None
+
     @classmethod
     def create(cls: type[T], **kwargs: Any) -> T:
         if kwargs.get("path"):
@@ -354,19 +383,7 @@ class FileRequirement(Requirement):
             if not self.extras:
                 self.extras = extras
         if not self.name and not self.is_vcs:
-            filename = os.path.basename(urlparse.unquote(url_without_fragments(self.url)))
-            if filename.endswith(".whl"):
-                self.name, *_ = parse_wheel_filename(filename)
-            else:
-                try:
-                    self.name, *_ = parse_sdist_filename(filename)
-                except ValueError:
-                    match = _egg_info_re.match(filename)
-                    # Filename is like `<name>-<version>.tar.gz`, where name will be
-                    # extracted and version will be left to be determined from
-                    # the metadata.
-                    if match:
-                        self.name = match.group(1)
+            self.name = self.guess_name()
 
     def _check_installable(self) -> None:
         assert self.path


Test patch:
diff --git a/tests/test_formats.py b/tests/test_formats.py
index 289bc203..85b7c605 100644
--- a/tests/test_formats.py
+++ b/tests/test_formats.py
@@ -52,7 +52,7 @@ def test_convert_requirements_file_without_name(project, vcs):
     assert requirements.check_fingerprint(project, str(req_file))
     result, _ = requirements.convert(project, str(req_file), Namespace(dev=False, group=None))
 
-    assert result["dependencies"] == ["git+https://github.com/test-root/demo.git"]
+    assert result["dependencies"] == ["demo @ git+https://github.com/test-root/demo.git"]
 
 
 def test_convert_poetry(project):