diff --git a/news/3376.bugfix.md b/news/3376.bugfix.md new file mode 100644 index 0000000000..a863c68800 --- /dev/null +++ b/news/3376.bugfix.md @@ -0,0 +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 0f7a9cf8e7..cc939e17d1 100644 --- a/src/pdm/formats/poetry.py +++ b/src/pdm/formats/poetry.py @@ -80,7 +80,7 @@ def fix_req_path(req: Requirement) -> Requirement: 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 fix_req_path(req: Requirement) -> Requirement: "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 e99b32043c..238b917d33 100644 --- a/src/pdm/models/candidates.py +++ b/src/pdm/models/candidates.py @@ -146,6 +146,8 @@ def __init__( :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 @@ def as_lockfile_entry(self, project_root: Path) -> dict[str, Any]: 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 a268212e6d..24dbd709d8 100644 --- a/src/pdm/models/requirements.py +++ b/src/pdm/models/requirements.py @@ -145,7 +145,7 @@ def create(cls: type[T], **kwargs: Any) -> T: 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 @@ def from_dist(cls, dist: Distribution) -> 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 @@ def from_req_dict(cls, name: str, req_dict: RequirementDict, check_installable: 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 @@ def _parse_name_from_url(self) -> None: 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.") diff --git a/tests/models/test_candidates.py b/tests/models/test_candidates.py index e9c0655168..40f6cf5955 100644 --- a/tests/models/test_candidates.py +++ b/tests/models/test_candidates.py @@ -5,6 +5,7 @@ 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 3190c2cf92..5499b2d339 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