Skip to content

Commit 22bf68d

Browse files
danyeawryanskeith
andauthored
Update repodata to v3 syntax (#273)
* Add initial v3 repodata support * Convert from regex to packaging markers * Ensure py-rattler >= 0.23 * Add comments as developer docs * Drop extra rebased test * Relock dependencies * Add news * Update docs * Use slicing and zip instead of manual index math Co-authored-by: Ryan Keith <rkeith@anaconda.com> * Rename variables and add examples --------- Co-authored-by: Ryan Keith <rkeith@anaconda.com>
1 parent 609401a commit 22bf68d

File tree

9 files changed

+1518
-736
lines changed

9 files changed

+1518
-736
lines changed

docs/developer/testing/mock-channel-server.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,9 @@ The mock channel currently includes:
152152

153153
### Wheel Support in Repodata
154154

155-
`conda-pypi` extends conda's repodata format to support wheel files. The repodata includes a `packages.whl` section alongside the standard `packages` and `packages.conda` sections:
155+
`conda-pypi` extends conda's repodata format to support wheel files. The
156+
repodata includes a nested `v3.whl` section alongside the standard `packages`
157+
and `packages.conda` sections:
156158

157159
```json
158160
{
@@ -161,8 +163,11 @@ The mock channel currently includes:
161163
},
162164
"packages": {},
163165
"packages.conda": {},
164-
"packages.whl": {
165-
"package-name.whl": { ... }
166+
"repodata_version": 3,
167+
"v3": {
168+
"whl": {
169+
"package-name-py3_none_any_0": { ... }
170+
}
166171
}
167172
}
168173
```

docs/features.md

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ discussion and subject to change.
7878
:::
7979

8080
If you maintain a conda channel, you can now serve Python wheels directly
81-
alongside regular conda packages. Add your wheels to a `packages.whl` section
81+
alongside regular conda packages. Add your wheels to a `v3.whl` section
8282
in `repodata.json` and point each entry at the wheel URL — `conda install`
8383
will pick them up, resolve their dependencies, and extract them correctly,
8484
with no pre-conversion step required.
@@ -92,15 +92,7 @@ Wheels served this way behave like any other conda package.
9292
### Extras
9393

9494
Wheels in a channel can declare [dependency specifier extras](https://packaging.python.org/en/latest/specifications/dependency-specifiers/#extras)
95-
via an `extras` field in the repodata entry. Users can request them with the
96-
standard bracket syntax:
97-
98-
```bash
99-
conda install "requests[security]"
100-
```
101-
102-
The solver will include the extra's dependencies — `cryptography` and
103-
`pyopenssl` in this case — alongside `requests` in the environment.
95+
via an `extra_depends` field in the repodata entry.
10496

10597
## Editable Package Support
10698

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
### Enhancements
2+
3+
* Update local wheel-channel test repodata to `v3.whl`, `extra_depends`, and normalized `when` conditions (#273)
4+
5+
### Bug fixes
6+
7+
* <news item>
8+
9+
### Deprecations
10+
11+
* <news item>
12+
13+
### Docs
14+
15+
* <news item>
16+
17+
### Other
18+
19+
* <news item>

pixi.lock

Lines changed: 188 additions & 189 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ python = ">=3.10"
5555
conda = ">=26.1"
5656
conda-index = ">=0.7.0"
5757
conda-package-streaming = ">=0.11"
58-
conda-rattler-solver = ">=0.0.5"
58+
conda-rattler-solver = ">=0.0.6"
5959
packaging = "*"
6060
pip = "*"
6161
unearth = "*"

tests/conda_local_channel/generate_noarch_wheel_repodata.py

Lines changed: 189 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,199 @@
1-
# This is a utility for generating test specific data in conda-pypi
2-
# only. It is not appropriate to use this to generate production level
3-
# repodata.
1+
"""
2+
Utility for generating test-specific local channel repodata.
3+
4+
This is test data generation logic for conda-pypi only; it is not intended for
5+
production repodata generation.
6+
7+
Marker conversion policy for this test channel:
8+
- Convert Python markers to `python...` matchspec fragments, including
9+
`python_version not in "x, y"` -> `(python!=x and python!=y)`.
10+
- Convert platform/os markers to virtual packages when feasible
11+
(`__win`, `__linux`, `__osx`, `__unix`).
12+
- Keep extras in `extra_depends`, with remaining non-extra marker logic
13+
encoded via `[when="..."]`.
14+
- Drop unsupported marker dimensions (for example interpreter/machine-specific
15+
variants) for these noarch channel tests.
16+
"""
417

518
import json
6-
import re
719
import requests
820
from concurrent.futures import ThreadPoolExecutor, as_completed
21+
from enum import StrEnum
22+
from packaging.markers import Marker
923
from packaging.requirements import Requirement
1024
from typing import Any
1125

12-
EXTRA_MARKER_RE = re.compile(r'extra\s*==\s*["\']([^"\']+)["\']')
26+
27+
class MarkerVar(StrEnum):
28+
PYTHON_VERSION = "python_version"
29+
PYTHON_FULL_VERSION = "python_full_version"
30+
EXTRA = "extra"
31+
SYS_PLATFORM = "sys_platform"
32+
PLATFORM_SYSTEM = "platform_system"
33+
OS_NAME = "os_name"
34+
IMPLEMENTATION_NAME = "implementation_name"
35+
PLATFORM_PYTHON_IMPLEMENTATION = "platform_python_implementation"
36+
PLATFORM_MACHINE = "platform_machine"
37+
38+
39+
class MarkerOp(StrEnum):
40+
EQ = "=="
41+
NE = "!="
42+
NOT_IN = "not in"
43+
44+
45+
SYSTEM_TO_VIRTUAL_PACKAGE = {
46+
"windows": "__win",
47+
"win32": "__win",
48+
"linux": "__linux",
49+
"darwin": "__osx",
50+
"cygwin": "__unix",
51+
}
52+
53+
OS_NAME_TO_VIRTUAL_PACKAGE = {
54+
"nt": "__win",
55+
"windows": "__win",
56+
"posix": "__unix",
57+
}
1358

1459

1560
def normalize_name(name: str) -> str:
1661
"""Normalize a package name to conda conventions (lowercase, _ -> -)."""
1762
return name.lower().replace("_", "-")
1863

1964

65+
def _marker_value(token: Any) -> str:
66+
"""Extract the textual value from packaging marker tokens."""
67+
return getattr(token, "value", str(token))
68+
69+
70+
def _normalize_marker_clause(marker_name: str, op: str, marker_value: str) -> str | None:
71+
"""Map a single PEP 508 marker atom to a MatchSpec-like fragment.
72+
73+
Examples:
74+
- ("sys_platform", "==", "win32") -> "__win"
75+
- ("python_version", "<", "3.11") -> "python<3.11"
76+
- ("python_version", "not in", "3.0, 3.1") -> "(python!=3.0 and python!=3.1)"
77+
- ("implementation_name", "==", "cpython") -> None
78+
"""
79+
marker_name = marker_name.lower()
80+
marker_value = marker_value.lower()
81+
82+
if marker_name in {MarkerVar.PYTHON_VERSION, MarkerVar.PYTHON_FULL_VERSION}:
83+
if op == MarkerOp.NOT_IN:
84+
excluded_versions = [
85+
version.strip() for version in marker_value.split(",") if version.strip()
86+
]
87+
if not excluded_versions:
88+
return None
89+
clauses = [f"python!={version}" for version in excluded_versions]
90+
if len(clauses) == 1:
91+
return clauses[0]
92+
return f"({' and '.join(clauses)})"
93+
return f"python{op}{marker_value}"
94+
95+
if marker_name == MarkerVar.EXTRA and op == MarkerOp.EQ:
96+
return None
97+
98+
if marker_name in {MarkerVar.SYS_PLATFORM, MarkerVar.PLATFORM_SYSTEM}:
99+
mapped = SYSTEM_TO_VIRTUAL_PACKAGE.get(marker_value)
100+
if op == MarkerOp.EQ and mapped:
101+
return mapped
102+
if op == MarkerOp.NE and marker_value in {"win32", "windows", "cygwin"}:
103+
return "__unix"
104+
if op == MarkerOp.NE and marker_value == "emscripten":
105+
return None
106+
return None
107+
108+
if marker_name == MarkerVar.OS_NAME:
109+
mapped = OS_NAME_TO_VIRTUAL_PACKAGE.get(marker_value)
110+
if not mapped:
111+
return None
112+
if op == MarkerOp.EQ:
113+
return mapped
114+
if op == MarkerOp.NE:
115+
return "__unix" if mapped == "__win" else "__win"
116+
return None
117+
118+
if marker_name in {MarkerVar.IMPLEMENTATION_NAME, MarkerVar.PLATFORM_PYTHON_IMPLEMENTATION}:
119+
if marker_value in {"cpython", "pypy", "jython"}:
120+
return None
121+
return None
122+
123+
if marker_name == MarkerVar.PLATFORM_MACHINE:
124+
return None
125+
126+
return None
127+
128+
129+
def _combine_conditions(left: str | None, op: str, right: str | None) -> str | None:
130+
"""Combine optional left/right expressions with a boolean operator."""
131+
if left is None:
132+
return right
133+
if right is None:
134+
return left
135+
if left == right:
136+
return left
137+
return f"({left} {op} {right})"
138+
139+
140+
def extract_marker_condition_and_extras(marker: Marker) -> tuple[str | None, list[str]]:
141+
"""Split a Marker into optional non-extra condition and extra group names.
142+
143+
Examples:
144+
- `extra == "docs"` -> `(None, ["docs"])`
145+
- `python_version < "3.11" and extra == "test"` -> `("python<3.11", ["test"])`
146+
- `sys_platform == "win32"` -> `("__win", [])`
147+
"""
148+
extras: list[str] = []
149+
seen_extras: set[str] = set()
150+
151+
def parse_marker_node(node: Any) -> str | None:
152+
if isinstance(node, tuple) and len(node) == 3:
153+
marker_name = _marker_value(node[0])
154+
op = _marker_value(node[1])
155+
marker_value = _marker_value(node[2])
156+
157+
if marker_name.lower() == MarkerVar.EXTRA and op == MarkerOp.EQ:
158+
extra_name = marker_value.lower()
159+
if extra_name not in seen_extras:
160+
seen_extras.add(extra_name)
161+
extras.append(extra_name)
162+
return None
163+
164+
return _normalize_marker_clause(marker_name, op, marker_value)
165+
166+
if isinstance(node, list):
167+
if not node:
168+
return None
169+
170+
condition_expr = parse_marker_node(node[0])
171+
for op, rhs in zip(node[1::2], node[2::2]):
172+
right_condition = parse_marker_node(rhs)
173+
condition_expr = _combine_conditions(
174+
condition_expr, str(op).lower(), right_condition
175+
)
176+
return condition_expr
177+
178+
return None
179+
180+
# Marker._markers is a private packaging attribute; keep access isolated here.
181+
condition = parse_marker_node(getattr(marker, "_markers", []))
182+
return condition, extras
183+
184+
20185
def pypi_to_repodata_noarch_whl_entry(
21186
pypi_data: dict[str, Any],
22187
) -> dict[str, Any] | None:
23188
"""
24-
Convert PyPI JSON endpoint data to a repodata.json packages.whl entry for a
189+
Convert PyPI JSON endpoint data to a repodata.json v3.whl entry for a
25190
pure Python (noarch) wheel.
26191
27192
Args:
28193
pypi_data: Dictionary containing the complete info from PyPI JSON endpoint
29194
30195
Returns:
31-
Dictionary representing the entry for packages.whl, or None if no pure
196+
Dictionary representing the entry for v3.whl, or None if no pure
32197
Python wheel (platform tag "none-any") is found
33198
"""
34199
# Find a pure Python wheel (platform tag "none-any")
@@ -48,17 +213,26 @@ def pypi_to_repodata_noarch_whl_entry(
48213
pypi_info = pypi_data.get("info")
49214

50215
depends_list: list[str] = []
51-
extras_dict: dict[str, list[str]] = {}
216+
extra_depends_dict: dict[str, list[str]] = {}
52217
for dep in pypi_info.get("requires_dist") or []:
53218
req = Requirement(dep)
54219
conda_dep = normalize_name(req.name) + str(req.specifier)
55220

56221
if req.marker:
57-
extra_match = EXTRA_MARKER_RE.search(str(req.marker))
58-
if extra_match:
59-
extras_dict.setdefault(extra_match.group(1), []).append(conda_dep)
222+
non_extra_condition, extra_names = extract_marker_condition_and_extras(req.marker)
223+
if extra_names:
224+
for extra_name in extra_names:
225+
extra_dep = conda_dep
226+
if non_extra_condition:
227+
marker_condition = json.dumps(non_extra_condition)
228+
extra_dep = f"{extra_dep}[when={marker_condition}]"
229+
extra_depends_dict.setdefault(extra_name, []).append(extra_dep)
60230
else:
61-
depends_list.append(conda_dep)
231+
if non_extra_condition:
232+
marker_condition = json.dumps(non_extra_condition)
233+
depends_list.append(f"{conda_dep}[when={marker_condition}]")
234+
else:
235+
depends_list.append(conda_dep)
62236
else:
63237
depends_list.append(conda_dep)
64238

@@ -78,7 +252,7 @@ def pypi_to_repodata_noarch_whl_entry(
78252
"build": "py3_none_any_0",
79253
"build_number": 0,
80254
"depends": depends_list,
81-
"extras": extras_dict,
255+
"extra_depends": extra_depends_dict,
82256
"fn": f"{pypi_info.get('name')}-{pypi_info.get('version')}-py3-none-any.whl",
83257
"sha256": wheel_url.get("digests", {}).get("sha256", ""),
84258
"size": wheel_url.get("size", 0),
@@ -137,9 +311,9 @@ def get_repodata_entry(name: str, version: str) -> dict[str, Any] | None:
137311
"packages": {},
138312
"packages.conda": {},
139313
"removed": [],
140-
"repodata_version": 1,
314+
"repodata_version": 3,
141315
"signatures": {},
142-
"packages.whl": {key: value for key, value in sorted(pkg_whls.items())},
316+
"v3": {"whl": {key: value for key, value in sorted(pkg_whls.items())}},
143317
}
144318

145319
with open(wheel_repodata, "w") as f:

0 commit comments

Comments
 (0)