Skip to content

Commit 3bb863b

Browse files
committed
Fix SpecsAreNotPlugins error for wheel-based and rattler-built packages
PackageInfo.from_record() relied on conda's deprecated info/files manifest to discover .dist-info directories. Packages built by rattler-build (e.g. numpy) don't ship info/files at all, and wheel packages extracted by conda-pypi have neither info/files nor info/paths.json. Read info/paths.json first (the canonical source per the conda package contents CEP), fall back to info/files for older packages, and scan the filesystem as a last resort for bare wheels. Fixes #105
1 parent c6f43e2 commit 3bb863b

File tree

2 files changed

+211
-9
lines changed

2 files changed

+211
-9
lines changed

conda_self/package_info.py

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import configparser
4+
import json
45
import os
56
import sys
67
from pathlib import Path
@@ -20,6 +21,25 @@ class CaseSensitiveConfigParser(configparser.ConfigParser):
2021
optionxform = staticmethod(str) # type: ignore
2122

2223

24+
def _file_paths_from_extracted_package(pkg_dir: str) -> list[str]:
25+
"""Read file paths from an extracted package directory.
26+
27+
Tries info/paths.json first (canonical per CEP), then falls back
28+
to info/files (deprecated legacy format).
29+
"""
30+
pkg_path = Path(pkg_dir)
31+
32+
paths_json = pkg_path / "info/paths.json"
33+
if paths_json.is_file():
34+
data = json.loads(paths_json.read_text())
35+
return [entry["_path"] for entry in data.get("paths", [])]
36+
37+
try:
38+
return (pkg_path / "info/files").read_text().splitlines()
39+
except FileNotFoundError:
40+
return []
41+
42+
2343
class PackageInfo:
2444
def __init__(self, dist_info_path: Path):
2545
"""Describe the dist-info for a Python package installed as a conda package"""
@@ -29,16 +49,10 @@ def __init__(self, dist_info_path: Path):
2949
def from_record(
3050
cls, record: PrefixRecord | PackageCacheRecord
3151
) -> list[PackageInfo]:
52+
had_manifest = True
3253
if not (paths := getattr(record, "files", None)):
33-
try:
34-
paths = (
35-
Path(record.extracted_package_dir, "info/files")
36-
.read_text()
37-
.splitlines()
38-
)
39-
except FileNotFoundError:
40-
# missing info/files -> empty package
41-
paths = []
54+
paths = _file_paths_from_extracted_package(record.extracted_package_dir)
55+
had_manifest = bool(paths)
4256
dist_infos = set()
4357
for path in paths:
4458
if (maybe_dist_info := os.path.dirname(path)).endswith(".dist-info"):
@@ -47,6 +61,15 @@ def from_record(
4761
basedir = sys.prefix
4862
else:
4963
basedir = record.extracted_package_dir
64+
65+
if not dist_infos and not had_manifest:
66+
# Last resort: scan the extracted directory for .dist-info
67+
# directories directly (e.g. bare wheel with no conda metadata).
68+
basepath = Path(basedir)
69+
for candidate in basepath.rglob("*.dist-info"):
70+
if candidate.is_dir():
71+
dist_infos.add(str(candidate.relative_to(basepath)))
72+
5073
if not dist_infos:
5174
raise NoDistInfoDirFound(record.name, basedir)
5275
return [cls(Path(basedir, p)) for p in dist_infos]

tests/test_package_info.py

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
from __future__ import annotations
2+
3+
import json
4+
from dataclasses import dataclass, field
5+
6+
import pytest
7+
8+
from conda_self.exceptions import NoDistInfoDirFound
9+
from conda_self.package_info import PackageInfo
10+
11+
12+
@dataclass
13+
class FakeCacheRecord:
14+
name: str
15+
extracted_package_dir: str
16+
files: list[str] | None = field(default=None)
17+
18+
19+
@pytest.fixture()
20+
def cache_record(tmp_path):
21+
def _make(files=None):
22+
return FakeCacheRecord(
23+
name="some-plugin",
24+
extracted_package_dir=str(tmp_path),
25+
files=files,
26+
)
27+
28+
return _make
29+
30+
31+
def test_finds_dist_info_via_info_files(tmp_path, cache_record):
32+
dist_info = tmp_path / "lib/python3.12/site-packages/pkg-1.0.dist-info"
33+
dist_info.mkdir(parents=True)
34+
(dist_info / "entry_points.txt").write_text("[conda]\nplugin = pkg.plugin\n")
35+
36+
info_dir = tmp_path / "info"
37+
info_dir.mkdir()
38+
(info_dir / "files").write_text(
39+
"lib/python3.12/site-packages/pkg-1.0.dist-info/entry_points.txt\n"
40+
"lib/python3.12/site-packages/pkg/__init__.py\n"
41+
)
42+
43+
infos = PackageInfo.from_record(cache_record())
44+
45+
assert len(infos) == 1
46+
assert infos[0].dist_info_path == dist_info
47+
assert "conda" in infos[0].entry_points()
48+
49+
50+
def test_finds_dist_info_via_paths_json(tmp_path, cache_record):
51+
dist_info = tmp_path / "site-packages/pkg-1.0.dist-info"
52+
dist_info.mkdir(parents=True)
53+
(dist_info / "entry_points.txt").write_text("[conda]\nplugin = pkg.plugin\n")
54+
55+
info_dir = tmp_path / "info"
56+
info_dir.mkdir()
57+
(info_dir / "paths.json").write_text(
58+
json.dumps(
59+
{
60+
"paths_version": 1,
61+
"paths": [
62+
{
63+
"_path": "site-packages/pkg-1.0.dist-info/entry_points.txt",
64+
"path_type": "hardlink",
65+
"sha256": "abc123",
66+
"size_in_bytes": 39,
67+
},
68+
{
69+
"_path": "site-packages/pkg/__init__.py",
70+
"path_type": "hardlink",
71+
"sha256": "def456",
72+
"size_in_bytes": 100,
73+
},
74+
],
75+
}
76+
)
77+
)
78+
79+
infos = PackageInfo.from_record(cache_record())
80+
81+
assert len(infos) == 1
82+
assert infos[0].dist_info_path == dist_info
83+
assert "conda" in infos[0].entry_points()
84+
85+
86+
def test_paths_json_takes_precedence_over_info_files(tmp_path, cache_record):
87+
"""info/paths.json is the canonical source per the conda package CEP."""
88+
dist_info = tmp_path / "site-packages/real-1.0.dist-info"
89+
dist_info.mkdir(parents=True)
90+
(dist_info / "entry_points.txt").write_text("[conda]\nplugin = real.p\n")
91+
92+
info_dir = tmp_path / "info"
93+
info_dir.mkdir()
94+
95+
# paths.json points to the real dist-info
96+
(info_dir / "paths.json").write_text(
97+
json.dumps(
98+
{
99+
"paths_version": 1,
100+
"paths": [
101+
{
102+
"_path": "site-packages/real-1.0.dist-info/entry_points.txt",
103+
"path_type": "hardlink",
104+
"sha256": "abc",
105+
"size_in_bytes": 1,
106+
},
107+
],
108+
}
109+
)
110+
)
111+
112+
# info/files points to a stale/wrong dist-info
113+
(info_dir / "files").write_text(
114+
"site-packages/stale-1.0.dist-info/entry_points.txt\n"
115+
)
116+
117+
infos = PackageInfo.from_record(cache_record())
118+
119+
assert len(infos) == 1
120+
assert infos[0].dist_info_path == dist_info
121+
122+
123+
@pytest.mark.parametrize(
124+
"dist_info_relpath",
125+
[
126+
"pkg-1.0.dist-info",
127+
"site-packages/pkg-1.0.dist-info",
128+
],
129+
)
130+
def test_filesystem_fallback_finds_dist_info(tmp_path, cache_record, dist_info_relpath):
131+
dist_info = tmp_path / dist_info_relpath
132+
dist_info.mkdir(parents=True)
133+
(dist_info / "entry_points.txt").write_text("[conda]\nplugin = pkg.plugin\n")
134+
135+
infos = PackageInfo.from_record(cache_record())
136+
137+
assert len(infos) == 1
138+
assert infos[0].dist_info_path == dist_info
139+
assert "conda" in infos[0].entry_points()
140+
141+
142+
def test_no_dist_info_raises(tmp_path, cache_record):
143+
(tmp_path / "some_file.py").write_text("pass")
144+
145+
with pytest.raises(NoDistInfoDirFound):
146+
PackageInfo.from_record(cache_record())
147+
148+
149+
def test_no_fallback_when_manifest_exists(tmp_path, cache_record):
150+
"""rglob must not scan the filesystem when a file manifest was present."""
151+
dist_info = tmp_path / "unrelated-1.0.dist-info"
152+
dist_info.mkdir()
153+
(dist_info / "entry_points.txt").write_text("[conda]\nplugin = x\n")
154+
155+
info_dir = tmp_path / "info"
156+
info_dir.mkdir()
157+
(info_dir / "files").write_text("lib/somefile.py\n")
158+
159+
with pytest.raises(NoDistInfoDirFound):
160+
PackageInfo.from_record(cache_record())
161+
162+
163+
def test_entry_points_missing_file(tmp_path):
164+
dist_info = tmp_path / "pkg-1.0.dist-info"
165+
dist_info.mkdir()
166+
167+
assert PackageInfo(dist_info).entry_points() == {}
168+
169+
170+
def test_entry_points_non_conda(tmp_path):
171+
dist_info = tmp_path / "pkg-1.0.dist-info"
172+
dist_info.mkdir()
173+
(dist_info / "entry_points.txt").write_text(
174+
"[console_scripts]\npkg = pkg.cli:main\n"
175+
)
176+
177+
ep = PackageInfo(dist_info).entry_points()
178+
assert "conda" not in ep
179+
assert "console_scripts" in ep

0 commit comments

Comments
 (0)