Skip to content

Commit 68e5246

Browse files
committed
fix: uv cannot lock a project that declares no name or version
uv requires project.name and project.version, while PDM allows an application to omit both. The generated pyproject.toml omitted them too, so uv failed to parse it. A placeholder is written instead, and the resulting root entry is filtered out again when the lockfile is read back so it never reaches pdm.lock. Fixes #3421
1 parent 29c40ea commit 68e5246

4 files changed

Lines changed: 84 additions & 29 deletions

File tree

news/3849.bugfix.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fill in a placeholder `project.name` and `project.version` for the `pyproject.toml` generated for `uv`, so `pdm lock`/`pdm install` work with `use_uv` on a project that declares neither.

src/pdm/formats/uv.py

Lines changed: 35 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@
1717
from pdm.project.core import Project
1818
from pdm.utils import get_requirement_from_override, normalize_name
1919

20+
#: uv requires ``project.name`` and ``project.version`` to be present, while PDM allows a
21+
#: project without them (an "application"). In that case a placeholder is written to the
22+
#: generated ``pyproject.toml`` and the resulting root entry is filtered out again when the
23+
#: uv lockfile is read back.
24+
PLACEHOLDER_NAME = "pdm-placeholder-project"
25+
PLACEHOLDER_VERSION = "0.0.0"
26+
2027

2128
@dataclass
2229
class _UvFileBuilder:
@@ -57,6 +64,10 @@ def build_pyproject_toml(self) -> Path:
5764
)
5865

5966
data.setdefault("project", {})["requires-python"] = self.requires_python
67+
if not data["project"].get("name"):
68+
data["project"]["name"] = PLACEHOLDER_NAME
69+
if not data["project"].get("version") and "version" not in data["project"].get("dynamic", []):
70+
data["project"]["version"] = PLACEHOLDER_VERSION
6071
data.pop("dependency-groups", None)
6172
data.setdefault("project", {}).pop("optional-dependencies", None)
6273
if self.workspace_members:
@@ -116,30 +127,30 @@ def build_uv_lock(self, include_self: bool = False) -> Path:
116127
p for k, p in locked_repo.packages.items() if strip_extras(k[0])[0] == key[0] and k[1:] == key[1:]
117128
]
118129
packages.append(self._build_lock_entry(related_packages))
119-
if name := self.project.name:
120-
version = self.project.pyproject.metadata.get("version", "0.0.0")
121-
this_package = {
122-
"name": normalize_name(name),
123-
"version": version,
124-
"source": {"editable" if include_self else "virtual": "."},
125-
}
126-
dependencies: list[dict[str, Any]] = []
127-
optional_dependencies: dict[str, list[dict[str, Any]]] = {}
128-
for req in self.requirements:
129-
if (dep := self._make_dependency(None, req)) is None:
130-
continue
131-
for group in req.groups:
132-
if group == "default":
133-
target_group = dependencies
134-
else:
135-
target_group = optional_dependencies.setdefault(group, [])
136-
if dep not in target_group:
137-
target_group.append(dep)
138-
if dependencies:
139-
this_package["dependencies"] = dependencies # type: ignore[assignment]
140-
if optional_dependencies:
141-
this_package["optional-dependencies"] = optional_dependencies
142-
packages.append(this_package)
130+
name = self.project.name or PLACEHOLDER_NAME
131+
version = self.project.pyproject.metadata.get("version") or PLACEHOLDER_VERSION
132+
this_package = {
133+
"name": normalize_name(name),
134+
"version": version,
135+
"source": {"editable" if include_self else "virtual": "."},
136+
}
137+
dependencies: list[dict[str, Any]] = []
138+
optional_dependencies: dict[str, list[dict[str, Any]]] = {}
139+
for req in self.requirements:
140+
if (dep := self._make_dependency(None, req)) is None:
141+
continue
142+
for group in req.groups:
143+
if group == "default":
144+
target_group = dependencies
145+
else:
146+
target_group = optional_dependencies.setdefault(group, [])
147+
if dep not in target_group:
148+
target_group.append(dep)
149+
if dependencies:
150+
this_package["dependencies"] = dependencies # type: ignore[assignment]
151+
if optional_dependencies:
152+
this_package["optional-dependencies"] = optional_dependencies
153+
packages.append(this_package)
143154

144155
data = {"version": 1, "requires-python": self.requires_python}
145156
if packages:

src/pdm/resolver/uv.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ def _parse_uv_lock(self, path: Path) -> Resolution:
114114
from unearth import Link
115115

116116
from pdm.compat import tomllib
117+
from pdm.formats.uv import PLACEHOLDER_NAME
117118

118119
with path.open("rb") as f:
119120
data = tomllib.load(f)
@@ -142,12 +143,11 @@ def make_hash(item: dict[str, Any], fallback_url: str | None = None) -> FileHash
142143
hash_value = hash_cache.get_hash(link, session)
143144
return {"url": url, "file": link.filename, "hash": hash_value}
144145

146+
# When the project has no `name`, a placeholder is written to the generated
147+
# pyproject.toml, so the root entry comes back under that name.
148+
self_name = normalize_name(self.project.name) if self.project.name else PLACEHOLDER_NAME
145149
for package in data["package"]:
146-
if (
147-
self.project.name
148-
and package["name"] == normalize_name(self.project.name)
149-
and (not self.keep_self or package["source"].get("virtual"))
150-
):
150+
if package["name"] == self_name and (not self.keep_self or package["source"].get("virtual")):
151151
continue
152152
req: Requirement
153153
if url := package["source"].get("url"):

tests/test_formats.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,49 @@ def test_build_uv_pyproject_toml_with_workspace(project):
129129
assert data["tool"]["uv"]["sources"]["bar"] == {"workspace": True}
130130

131131

132+
def test_build_uv_files_without_project_name_and_version(project):
133+
"""uv requires project.name/version, so a placeholder is filled in for applications
134+
that declare neither. See issue #3421.
135+
"""
136+
from pdm.formats.uv import PLACEHOLDER_NAME, PLACEHOLDER_VERSION
137+
138+
del project.pyproject.metadata["name"]
139+
del project.pyproject.metadata["version"]
140+
project.pyproject.write()
141+
142+
locked_repo = LockedRepository({}, project.sources, project.environment)
143+
with uv_file_builder(project, ">=3.10", [], locked_repo) as builder:
144+
pyproject_path = builder.build_pyproject_toml()
145+
with pyproject_path.open("rb") as fp:
146+
pyproject_data = tomllib.load(fp)
147+
lock_path = builder.build_uv_lock()
148+
with lock_path.open("rb") as fp:
149+
lock_data = tomllib.load(fp)
150+
151+
# uv refuses to parse a pyproject.toml whose [project] table lacks either key
152+
assert pyproject_data["project"].get("name") == PLACEHOLDER_NAME
153+
assert pyproject_data["project"].get("version") == PLACEHOLDER_VERSION
154+
roots = [p for p in lock_data["package"] if p["name"] == PLACEHOLDER_NAME]
155+
assert len(roots) == 1
156+
assert roots[0]["version"] == PLACEHOLDER_VERSION
157+
assert roots[0]["source"] == {"virtual": "."}
158+
159+
160+
def test_build_uv_pyproject_toml_keeps_dynamic_version(project):
161+
project.pyproject.metadata["dynamic"] = ["version"]
162+
del project.pyproject.metadata["version"]
163+
project.pyproject.write()
164+
165+
locked_repo = LockedRepository({}, project.sources, project.environment)
166+
with uv_file_builder(project, ">=3.10", [], locked_repo) as builder:
167+
path = builder.build_pyproject_toml()
168+
with path.open("rb") as fp:
169+
data = tomllib.load(fp)
170+
171+
assert "version" not in data["project"]
172+
assert data["project"]["dynamic"] == ["version"]
173+
174+
132175
def test_build_uv_lock_with_local_path_wheel(project):
133176
from pdm.models.candidates import Candidate
134177
from pdm.models.repositories import Package

0 commit comments

Comments
 (0)