Skip to content
Open
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ A simple development conda channel for testing repodata for conda-pypi.
This will install the conda-pypi plugin and its dependencies:

```bash
conda install -n base 'conda-pypi>=0.5.0' 'conda-rattler-solver<0.0.6'
conda install -n base 'conda-pypi>=0.5.0' 'conda-rattler-solver>=0.6.0'
```

### Configure the solver
Expand Down
200 changes: 184 additions & 16 deletions generate.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@
import argparse
import asyncio
import json
from enum import Enum

try:
from compression.zstd import compress as zstd_compress # Python 3.14+
except ImportError:
from backports.zstd import compress as zstd_compress # type: ignore[no-redef]
import httpx
import re
import time
from packaging.markers import Marker
from packaging.requirements import Requirement, InvalidRequirement
from pathlib import Path
from typing import Any
Expand All @@ -30,6 +32,39 @@
_MAPPING_CACHE: dict[str, dict[str, str]] | None = None


class MarkerVar(str, Enum):
PYTHON_VERSION = "python_version"
PYTHON_FULL_VERSION = "python_full_version"
EXTRA = "extra"
SYS_PLATFORM = "sys_platform"
PLATFORM_SYSTEM = "platform_system"
OS_NAME = "os_name"
IMPLEMENTATION_NAME = "implementation_name"
PLATFORM_PYTHON_IMPLEMENTATION = "platform_python_implementation"
PLATFORM_MACHINE = "platform_machine"


class MarkerOp(str, Enum):
EQ = "=="
NE = "!="
NOT_IN = "not in"


SYSTEM_TO_VIRTUAL_PACKAGE = {
"windows": "__win",
"win32": "__win",
"linux": "__linux",
"darwin": "__osx",
"cygwin": "__unix",
}

OS_NAME_TO_VIRTUAL_PACKAGE = {
"nt": "__win",
"windows": "__win",
"posix": "__unix",
}


async def load_grayskull_mapping() -> dict[str, dict[str, str]]:
"""Load grayskull PyPI to conda mapping from conda-pypi repository."""
global _MAPPING_CACHE
Expand Down Expand Up @@ -77,18 +112,145 @@ def map_package_name(pypi_name: str) -> str:
return normalized


def _marker_value(token: Any) -> str:
"""Extract the textual value from packaging marker tokens."""
return getattr(token, "value", str(token))


def _normalize_marker_clause(
marker_name: str, op: str, marker_value: str
) -> str | None:
"""Map a single PEP 508 marker atom to a MatchSpec-like fragment.

Examples:
- ("sys_platform", "==", "win32") -> "__win"
- ("python_version", "<", "3.11") -> "python<3.11"
- ("python_version", "not in", "3.0, 3.1") -> "(python!=3.0 and python!=3.1)"
- ("implementation_name", "==", "cpython") -> None
"""
marker_name = marker_name.lower()
marker_value = marker_value.lower()

if marker_name in {MarkerVar.PYTHON_VERSION, MarkerVar.PYTHON_FULL_VERSION}:
if op == MarkerOp.NOT_IN:
excluded_versions = [
version.strip()
for version in marker_value.split(",")
if version.strip()
]
if not excluded_versions:
return None
clauses = [f"python!={version}" for version in excluded_versions]
if len(clauses) == 1:
return clauses[0]
return f"({' and '.join(clauses)})"
return f"python{op}{marker_value}"

if marker_name == MarkerVar.EXTRA and op == MarkerOp.EQ:
return None

if marker_name in {MarkerVar.SYS_PLATFORM, MarkerVar.PLATFORM_SYSTEM}:
mapped = SYSTEM_TO_VIRTUAL_PACKAGE.get(marker_value)
if op == MarkerOp.EQ and mapped:
return mapped
if op == MarkerOp.NE and marker_value in {"win32", "windows", "cygwin"}:
return "__unix"
if op == MarkerOp.NE and marker_value == "emscripten":
return None
return None

if marker_name == MarkerVar.OS_NAME:
mapped = OS_NAME_TO_VIRTUAL_PACKAGE.get(marker_value)
if not mapped:
return None
if op == MarkerOp.EQ:
return mapped
if op == MarkerOp.NE:
return "__unix" if mapped == "__win" else "__win"
return None

if marker_name in {
MarkerVar.IMPLEMENTATION_NAME,
MarkerVar.PLATFORM_PYTHON_IMPLEMENTATION,
}:
if marker_value in {"cpython", "pypy", "jython"}:
return None
return None

if marker_name == MarkerVar.PLATFORM_MACHINE:
return None

return None


def _combine_conditions(left: str | None, op: str, right: str | None) -> str | None:
"""Combine optional left/right expressions with a boolean operator."""
if left is None:
return right
if right is None:
return left
if left == right:
return left
return f"({left} {op} {right})"


def extract_marker_condition_and_extras(marker: Marker) -> tuple[str | None, list[str]]:
"""Split a Marker into optional non-extra condition and extra group names.

Examples:
- `extra == "docs"` -> `(None, ["docs"])`
- `python_version < "3.11" and extra == "test"` -> `("python<3.11", ["test"])`
- `sys_platform == "win32"` -> `("__win", [])`
"""
extras: list[str] = []
seen_extras: set[str] = set()

def parse_marker_node(node: Any) -> str | None:
if isinstance(node, tuple) and len(node) == 3:
marker_name = _marker_value(node[0])
op = _marker_value(node[1])
marker_value = _marker_value(node[2])

if marker_name.lower() == MarkerVar.EXTRA and op == MarkerOp.EQ:
extra_name = marker_value.lower()
if extra_name not in seen_extras:
seen_extras.add(extra_name)
extras.append(extra_name)
return None

return _normalize_marker_clause(marker_name, op, marker_value)

if isinstance(node, list):
if not node:
return None

condition_expr = parse_marker_node(node[0])
for op, rhs in zip(node[1::2], node[2::2]):
right_condition = parse_marker_node(rhs)
condition_expr = _combine_conditions(
condition_expr, str(op).lower(), right_condition
)
return condition_expr

return None

# Marker._markers is private in packaging; keep usage isolated here.
condition = parse_marker_node(getattr(marker, "_markers", []))
return condition, extras


def pypi_to_repodata_whl_entry(
pypi_data: dict[str, Any], url_index: int = 0
) -> dict[str, Any] | None:
"""
Convert PyPI JSON endpoint data to a repodata.json packages.whl entry.
Convert PyPI JSON endpoint data to a repodata.json v3.whl entry.

Args:
pypi_data: Dictionary containing the complete info section from PyPI JSON endpoint
url_index: Index of the wheel URL to use (typically the first one is the wheel)

Returns:
Dictionary representing the entry for packages.whl, or None if wheel not found
Dictionary representing the entry for v3.whl, or None if wheel not found
"""
# Find a pure Python wheel (platform tag must be "none-any").
# Wheels with compiled native code use platform-specific tags such as
Expand All @@ -115,9 +277,9 @@ def pypi_to_repodata_whl_entry(
conda_name = map_package_name(pypi_name)
version = pypi_info.get("version")

# Build dependency list and extras dict with name mapping
# Build dependency list and optional dependency groups with name mapping
depends_list = []
extras_dict: dict[str, list[str]] = {}
extra_depends_dict: dict[str, list[str]] = {}
for dep in pypi_info.get("requires_dist") or []:
try:
req = Requirement(dep)
Expand All @@ -128,13 +290,22 @@ def pypi_to_repodata_whl_entry(
conda_dep = map_package_name(req.name) + str(req.specifier)

if req.marker:
extra_match = re.search(
r'extra\s*==\s*["\']([^"\']+)["\']', str(req.marker)
non_extra_condition, extra_names = extract_marker_condition_and_extras(
req.marker
)
if extra_match:
extras_dict.setdefault(extra_match.group(1), []).append(conda_dep)
if extra_names:
for extra_name in extra_names:
extra_dep = conda_dep
if non_extra_condition:
marker_condition = json.dumps(non_extra_condition)
extra_dep = f"{extra_dep}[when={marker_condition}]"
extra_depends_dict.setdefault(extra_name, []).append(extra_dep)
else:
depends_list.append(conda_dep)
if non_extra_condition:
marker_condition = json.dumps(non_extra_condition)
depends_list.append(f"{conda_dep}[when={marker_condition}]")
else:
depends_list.append(conda_dep)
else:
depends_list.append(conda_dep)

Expand All @@ -146,9 +317,6 @@ def pypi_to_repodata_whl_entry(
# Noarch python packages should still depend on python when PyPI omits requires_python
depends_list.append("python")

# Extract filename components
filename = wheel_url.get("filename", "")

# Build the repodata entry
entry = {
"url": wheel_url.get("url", ""),
Expand All @@ -158,7 +326,7 @@ def pypi_to_repodata_whl_entry(
"build": "py3_none_any_0",
"build_number": 0,
"depends": depends_list,
"extras": extras_dict,
"extra_depends": extra_depends_dict,
"sha256": wheel_url.get("digests", {}).get("sha256", ""),
"size": wheel_url.get("size", 0),
"subdir": "noarch",
Expand Down Expand Up @@ -377,9 +545,9 @@ async def fetch_with_semaphore(
"packages": {},
"packages.conda": {},
"removed": [],
"repodata_version": 1,
"repodata_version": 3,
"signatures": {},
"packages.whl": {key: value for key, value in sorted(pkg_whls.items())},
"v3": {"whl": {key: value for key, value in sorted(pkg_whls.items())}},
}

# Create output directory
Expand Down
Loading
Loading