Repository: pdm-project/pdm (python)

Issue:
Cannot install pgobject, Invalid specifier: '>=3.5.4|^4.2.0'
- [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
Install pygobject
```bash
pdm add pygobject
```

## Actual behavior

```bash
Traceback (most recent call last):
  File "/usr/lib/python-exec/python3.11/pdm", line 8, in <module>
    sys.exit(main())
             ^^^^^^
  File "/usr/lib/python3.11/site-packages/pdm/core.py", line 290, in main
    return Core().main(args or sys.argv[1:])
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.11/site-packages/pdm/core.py", line 208, in main
    raise cast(Exception, err).with_traceback(traceback) from None
  File "/usr/lib/python3.11/site-packages/pdm/core.py", line 203, in main
    self.handle(project, options)
  File "/usr/lib/python3.11/site-packages/pdm/core.py", line 157, in handle
    command.handle(project, options)
  File "/usr/lib/python3.11/site-packages/pdm/cli/commands/add.py", line 67, in handle
    self.do_add(
  File "/usr/lib/python3.11/site-packages/pdm/cli/commands/add.py", line 147, in do_add
    all_dependencies = project.all_dependencies
                       ^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.11/site-packages/pdm/project/core.py", line 343, in all_dependencies
    return {group: self.get_dependencies(group) for group in self.iter_groups()}
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.11/site-packages/pdm/project/core.py", line 343, in <dictcomp>
    return {group: self.get_dependencies(group) for group in self.iter_groups()}
                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.11/site-packages/pdm/project/core.py", line 328, in get_dependencies
    req = parse_requirement(line)
          ^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.11/site-packages/pdm/models/requirements.py", line 506, in parse_requirement
    r = Requirement.from_pkg_requirement(pkg_req)
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.11/site-packages/pdm/models/requirements.py", line 228, in from_pkg_requirement
    return klass(url=req.url, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "<string>", line 12, in __init__
  File "/usr/lib/python3.11/site-packages/pdm/models/requirements.py", line 255, in __post_init__
    self._check_installable()
  File "/usr/lib/python3.11/site-packages/pdm/models/requirements.py", line 394, in _check_installable
    result = Setup.from_directory(self.path.absolute())
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.11/site-packages/pdm/models/setup.py", line 37, in from_directory
    return _SetupReader.read_from_directory(dir)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.11/site-packages/pdm/models/setup.py", line 61, in read_from_directory
    new_result = file_reader(filepath)
                 ^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.11/site-packages/pdm/models/setup.py", line 73, in read_pyproject_toml
    metadata = PyProject(file, ui=termui.UI()).metadata.unwrap()
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.11/site-packages/pdm/project/toml_file.py", line 18, in __init__
    self._data = self.read()
                 ^^^^^^^^^^^
  File "/usr/lib/python3.11/site-packages/pdm/project/project_file.py", line 32, in read
    metadata, settings = converter.convert(None, self._path, None)
                         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.11/site-packages/pdm/formats/poetry.py", line 204, in convert
    return converter.convert()
           ^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.11/site-packages/pdm/formats/base.py", line 55, in convert
    self._data[key] = func(self, value)
                      ^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.11/site-packages/pdm/formats/poetry.py", line 147, in dev_dependencies
    "dev": make_array([r for key, req in value.items() for r in _convert_req(key, req)], True),
                      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.11/site-packages/pdm/formats/poetry.py", line 147, in <listcomp>
    "dev": make_array([r for key, req in value.items() for r in _convert_req(key, req)], True),
                      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.11/site-packages/pdm/formats/poetry.py", line 74, in _convert_req
    yield Requirement.from_req_dict(name, _convert_specifier(req_dict)).as_line()
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.11/site-packages/pdm/models/requirements.py", line 180, in from_req_dict
    return NamedRequirement(name=name, specifier=get_specifier(req_dict))
                                                 ^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.11/site-packages/pdm/models/specifiers.py", line 28, in get_specifier
    return SpecifierSet(version_str)
           ^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.11/site-packages/packaging/specifiers.py", line 714, in __init__
    parsed.add(Specifier(specifier))
               ^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.11/site-packages/packaging/specifiers.py", line 245, in __init__
    raise InvalidSpecifier(f"Invalid specifier: '{spec}'")
packaging.specifiers.InvalidSpecifier: Invalid specifier: '>=3.5.4|^4.2.0'
```

## Expected behavior

Successful installation

## Environment Information

```bash
PDM version:
  2.8.2
Python Interpreter:
  /tmp/test/.venv/bin/python (3.11)
Project Root:
  /tmp/test
Local Packages:

{
  "implementation_name": "cpython",
  "implementation_version": "3.11.4",
  "os_name": "posix",
  "platform_machine": "x86_64",
  "platform_release": "6.1.31-gentoo-dist",
  "platform_system": "Linux",
  "platform_version": "#1 SMP PREEMPT_DYNAMIC Sun Jun 25 00:30:59 CEST 2023",
  "python_full_version": "3.11.4",
  "platform_python_implementation": "CPython",
  "python_version": "3.11",
  "sys_platform": "linux"
}
```


Pull request description:
fix: Handle parsing errors when converting from poetry-style metadata
Close #2203

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/2203.bugfix.md b/news/2203.bugfix.md
new file mode 100644
index 00000000..6a6f855e
--- /dev/null
+++ b/news/2203.bugfix.md
@@ -0,0 +1,1 @@
+Handle parsing errors when converting from poetry-style metadata.
diff --git a/src/pdm/formats/__init__.py b/src/pdm/formats/__init__.py
index 971f2d01..96e096ee 100644
--- a/src/pdm/formats/__init__.py
+++ b/src/pdm/formats/__init__.py
@@ -3,6 +3,7 @@ from __future__ import annotations
 from typing import TYPE_CHECKING, cast
 
 from pdm.formats import flit, pipfile, poetry, requirements, setup_py
+from pdm.formats.base import MetaConvertError as MetaConvertError
 
 if TYPE_CHECKING:
     from argparse import Namespace
diff --git a/src/pdm/formats/base.py b/src/pdm/formats/base.py
index b49df56e..af4fa72e 100644
--- a/src/pdm/formats/base.py
+++ b/src/pdm/formats/base.py
@@ -23,6 +23,18 @@ class Unset(Exception):
     pass
 
 
+class MetaConvertError(Exception):
+    """A special exception that preserves the partial metadata that are already resolved."""
+
+    def __init__(self, errors: list[str], *, data: dict[str, Any], settings: dict[str, Any]) -> None:
+        self.errors = errors
+        self.data = data
+        self.settings = settings
+
+    def __str__(self) -> str:
+        return "\n" + "\n".join(self.errors)
+
+
 class _MetaConverterMeta(type):
     def __init__(cls, name: str, bases: tuple[type, ...], ns: dict[str, Any]) -> None:
         super().__init__(name, bases, ns)
@@ -47,6 +59,7 @@ class MetaConverter(metaclass=_MetaConverterMeta):
 
     def convert(self) -> tuple[Mapping[str, Any], Mapping[str, Any]]:
         source = self.source
+        errors: list[str] = []
         for key, func in self._converters.items():
             if func._convert_from and func._convert_from not in source:  # type: ignore[attr-defined]
                 continue
@@ -55,6 +68,8 @@ class MetaConverter(metaclass=_MetaConverterMeta):
                 self._data[key] = func(self, value)
             except Unset:
                 pass
+            except Exception as e:
+                errors.append(f"{key}: {e}")
 
         # Delete all used fields
         for func in self._converters.values():
@@ -66,6 +81,8 @@ class MetaConverter(metaclass=_MetaConverterMeta):
                 pass
         # Add remaining items to the data
         self._data.update(source)
+        if errors:
+            raise MetaConvertError(errors, data=self._data, settings=self.settings)
         return self._data, self.settings
 
 
diff --git a/src/pdm/models/candidates.py b/src/pdm/models/candidates.py
index f5eb3818..b8cfb7b6 100644
--- a/src/pdm/models/candidates.py
+++ b/src/pdm/models/candidates.py
@@ -498,9 +498,14 @@ class PreparedCandidate:
 
     def _get_metadata_from_project(self, pyproject_toml: Path) -> im.Distribution | None:
         # Try getting from PEP 621 metadata
+        from pdm.formats import MetaConvertError
         from pdm.project.project_file import PyProject
 
-        pyproject = PyProject(pyproject_toml, ui=self.environment.project.core.ui)
+        try:
+            pyproject = PyProject(pyproject_toml, ui=self.environment.project.core.ui)
+        except MetaConvertError as e:
+            termui.logger.warning("Failed to parse pyproject.toml: %s", e)
+            return None
         metadata = pyproject.metadata.unwrap()
         if not metadata:
             termui.logger.warning("Failed to parse pyproject.toml")
diff --git a/src/pdm/models/setup.py b/src/pdm/models/setup.py
index 548a98d3..cf9235e9 100644
--- a/src/pdm/models/setup.py
+++ b/src/pdm/models/setup.py
@@ -67,12 +67,16 @@ class _SetupReader:
     def read_pyproject_toml(file: Path) -> Setup:
         from pdm import termui
         from pdm.exceptions import ProjectError
+        from pdm.formats import MetaConvertError
         from pdm.project.project_file import PyProject
 
         try:
             metadata = PyProject(file, ui=termui.UI()).metadata.unwrap()
         except ProjectError:
             return Setup()
+        except MetaConvertError as e:
+            termui.logger.warning("Error parsing pyproject.toml, metadata may be incomplete. %s", e)
+            metadata = e.data
         return Setup(
             name=metadata.get("name"),
             summary=metadata.get("description"),


Test patch:
diff --git a/tests/fixtures/poetry-error.toml b/tests/fixtures/poetry-error.toml
new file mode 100644
index 00000000..0e2bb6aa
--- /dev/null
+++ b/tests/fixtures/poetry-error.toml
@@ -0,0 +1,14 @@
+[tool.poetry]
+name = "test-poetry"
+version = "0.1.0"
+description = ""
+authors = ["Frost Ming <me@frostming.com>"]
+readme = "README.md"
+
+[tool.poetry.dependencies]
+python = "^3.11"
+foo = ">=1.0||^2.1"
+
+[build-system]
+requires = ["poetry-core"]
+build-backend = "poetry.core.masonry.api"
diff --git a/tests/test_formats.py b/tests/test_formats.py
index 166fb790..204ae531 100644
--- a/tests/test_formats.py
+++ b/tests/test_formats.py
@@ -3,7 +3,7 @@ from argparse import Namespace
 
 import pytest
 
-from pdm.formats import flit, pipfile, poetry, requirements, setup_py
+from pdm.formats import MetaConvertError, flit, pipfile, poetry, requirements, setup_py
 from pdm.models.requirements import parse_requirement
 from pdm.utils import cd
 from tests import FIXTURES
@@ -121,6 +121,17 @@ def test_convert_flit(project):
     assert build["excludes"] == ["doc/*.html"]
 
 
+def test_convert_error_preserve_metadata(project):
+    pyproject_file = FIXTURES / "poetry-error.toml"
+    try:
+        poetry.convert(project, pyproject_file, Namespace(dev=False, group=None))
+    except MetaConvertError as e:
+        assert e.data["name"] == "test-poetry"
+        assert "dependencies: Invalid specifier" in str(e)
+    else:
+        pytest.fail("Should raise MetaConvertError")
+
+
 def test_import_requirements_with_group(project):
     golden_file = FIXTURES / "requirements.txt"
     assert requirements.check_fingerprint(project, golden_file)
diff --git a/tests/test_project.py b/tests/test_project.py
index 949f5ec7..4be1b415 100644
--- a/tests/test_project.py
+++ b/tests/test_project.py
@@ -113,7 +113,7 @@ def test_project_use_venv(project):
     project._python = None
     scripts = "Scripts" if os.name == "nt" else "bin"
     suffix = ".exe" if os.name == "nt" else ""
-    venv.create(project.root / "venv")
+    venv.create(project.root / "venv", symlinks=True)
 
     project.project_config["python.use_venv"] = True
     env = project.environment
@@ -149,7 +149,7 @@ def test_ignore_saved_python(project, monkeypatch):
     project._python = None
     scripts = "Scripts" if os.name == "nt" else "bin"
     suffix = ".exe" if os.name == "nt" else ""
-    venv.create(project.root / "venv")
+    venv.create(project.root / "venv", symlinks=True)
     monkeypatch.setenv("PDM_IGNORE_SAVED_PYTHON", "1")
     assert project.python.executable != project._saved_python
     assert project.python.executable == project.root / "venv" / scripts / f"python{suffix}"