Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions news/3816.bugfix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Keep workspace members in their explicitly declared dependency groups instead of adding them to ``default`` in the lockfile.
25 changes: 20 additions & 5 deletions src/pdm/project/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import os
import shutil
import sys
from collections.abc import Callable, Iterable, Mapping, Sequence
from collections.abc import Callable, Collection, Iterable, Mapping, Sequence
from copy import deepcopy
from functools import cached_property, reduce
from pathlib import Path
Expand Down Expand Up @@ -161,9 +161,19 @@ def iter_workspace_dependencies(self) -> Iterable[Requirement]:
"""Iterate over implicit editable requirements for workspace members."""
yield from self.workspace.iter_dependencies()

def with_workspace_dependencies(self, requirements: Iterable[Requirement]) -> list[Requirement]:
"""Return requirements with implicit workspace member dependencies included."""
return self.workspace.with_dependencies(requirements)
def with_workspace_dependencies(
self,
requirements: Iterable[Requirement],
*,
exclude: Collection[str] | None = None,
) -> list[Requirement]:
"""Return requirements with implicit workspace member dependencies included.

Members already declared in other dependency groups are skipped so they
keep those groups in the lockfile instead of being forced into
``default`` (#3816).
"""
return self.workspace.with_dependencies(requirements, exclude=exclude)

def pyproject_content_hash(self, algo: str = "sha256") -> str:
"""Return a lockfile content hash including workspace members."""
Expand Down Expand Up @@ -554,7 +564,12 @@ def _get_dependencies(group: str) -> tuple[list[Requirement], set[str]]:
for group, deps in extra_deps.items():
group_deps[group].extend(deps)
if "default" in group_deps and self.is_workspace_root:
group_deps["default"] = self.with_workspace_dependencies(group_deps["default"])
# Do not force workspace members that are already declared in other
# groups into default (keeps lockfile groups accurate) (#3816).
declared_elsewhere = {
req.identify() for group, deps in group_deps.items() if group != "default" for req in deps
}
group_deps["default"] = self.with_workspace_dependencies(group_deps["default"], exclude=declared_elsewhere)
return group_deps

@property
Expand Down
11 changes: 9 additions & 2 deletions src/pdm/project/workspace.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from __future__ import annotations

import hashlib
from collections.abc import Iterable
from collections.abc import Collection, Iterable
from functools import cached_property
from pathlib import Path
from typing import TYPE_CHECKING
Expand Down Expand Up @@ -87,9 +87,16 @@ def iter_dependencies(self) -> Iterable[Requirement]:
req.groups = ["default"]
yield req

def with_dependencies(self, requirements: Iterable[Requirement]) -> list[Requirement]:
def with_dependencies(
self,
requirements: Iterable[Requirement],
*,
exclude: Collection[str] | None = None,
) -> list[Requirement]:
result = list(requirements)
seen = {req.identify() for req in result}
if exclude:
seen.update(exclude)
for req in self.iter_dependencies():
if req.identify() in seen:
continue
Expand Down
2 changes: 1 addition & 1 deletion src/pdm/resolver/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,7 @@ def matches_gen() -> Iterator[Candidate]:
reqs.sort(key=self.requirement_preference)
if workspace_candidates := list(self._find_workspace_candidates(identifier)):
return (
can
can.copy_with(dataclasses.replace(can.req, groups=original_req.groups))
for can in workspace_candidates
if can not in incompat and all(self.is_satisfied_by(r, can) for r in reqs)
)
Expand Down
28 changes: 28 additions & 0 deletions tests/test_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,34 @@ def test_workspace_root_adds_members_as_implicit_editable_dependencies(project):
assert member_dependency.str_path == "./packages/foo"


def test_workspace_member_declared_in_dev_group_not_forced_into_default(project, pdm):
"""Workspace members already in other groups must not be re-added to default (#3816)."""
project.pyproject.settings["workspace"] = {"members": ["packages/*"]}
project.pyproject.write()
member_path = project.root / "packages" / "foo"
member_path.mkdir(parents=True)
member_path.joinpath("pyproject.toml").write_text(
'[project]\nname = "foo"\nversion = "0.1.0"\n',
encoding="utf-8",
)
# Declare the member only in a dev/test group, not in main dependencies.
project.add_dependencies(
[parse_requirement("foo @ file:///${PROJECT_ROOT}/packages/foo", True)],
"test",
True,
)

all_deps = project._resolve_dependencies()
default_deps = project.get_dependencies("default", all_deps)
assert all(getattr(dep, "name", None) != "foo" for dep in default_deps)

test_deps = project.get_dependencies("test", all_deps)
assert any(getattr(dep, "name", None) == "foo" for dep in test_deps)

pdm(["lock"], obj=project, strict=True)
assert project.get_locked_repository().candidates["foo"].req.groups == ["test"]


def test_add_member_to_workspace(project):
project.pyproject.settings["workspace"] = {"members": []}
project.pyproject.write()
Expand Down
Loading