diff --git a/news/2203.bugfix.md b/news/2203.bugfix.md new file mode 100644 index 0000000000..6a6f855e3f --- /dev/null +++ b/news/2203.bugfix.md @@ -0,0 +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 971f2d0141..96e096eef6 100644 --- a/src/pdm/formats/__init__.py +++ b/src/pdm/formats/__init__.py @@ -3,6 +3,7 @@ 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 b49df56e95..af4fa72e3b 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 @@ def __init__(self, source: dict, ui: termui.UI | None = None) -> None: 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 @@ def convert(self) -> tuple[Mapping[str, Any], Mapping[str, Any]]: 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 @@ def convert(self) -> tuple[Mapping[str, Any], Mapping[str, Any]]: 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 f5eb3818ac..b8cfb7b61e 100644 --- a/src/pdm/models/candidates.py +++ b/src/pdm/models/candidates.py @@ -498,9 +498,14 @@ def _get_metadata_from_wheel(self, wheel: Path, metadata_parent: str) -> im.Dist 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 548a98d3b1..cf9235e9a3 100644 --- a/src/pdm/models/setup.py +++ b/src/pdm/models/setup.py @@ -67,12 +67,16 @@ def read_from_directory(cls, directory: Path) -> Setup: 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"), diff --git a/tests/fixtures/poetry-error.toml b/tests/fixtures/poetry-error.toml new file mode 100644 index 0000000000..0e2bb6aadd --- /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 "] +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 166fb79035..204ae531e8 100644 --- a/tests/test_formats.py +++ b/tests/test_formats.py @@ -3,7 +3,7 @@ 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 949f5ec727..4be1b41530 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}"