Skip to content

Commit

Permalink
Add parselmouth mappings (#24)
Browse files Browse the repository at this point in the history
* add parselmouth mappings

* add support for max and min delimiters
  • Loading branch information
jaimergp authored May 9, 2024
1 parent 5a94617 commit 788f4d5
Show file tree
Hide file tree
Showing 2 changed files with 85 additions and 13 deletions.
53 changes: 41 additions & 12 deletions conda_pypi/dependencies/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@

from __future__ import annotations

import json
import os
from collections import defaultdict
from logging import getLogger
from functools import lru_cache
from io import BytesIO
from typing import Literal
from typing import Iterable, Literal

import requests
from conda.models.match_spec import MatchSpec
Expand All @@ -24,8 +25,10 @@
"pip",
)
NAME_MAPPINGS = {
"grayskull": "https://github.com/conda/grayskull/raw/main/grayskull/strategy/config.yaml",
"cf-graph-countyfair": "https://github.com/regro/cf-graph-countyfair/raw/master/mappings/pypi/grayskull_pypi_mapping.yaml",
# prioritize grayskull and cf-graph because they contain PyQt version delimiters
"grayskull": "https://raw.githubusercontent.com/conda/grayskull/main/grayskull/strategy/config.yaml",
"cf-graph-countyfair": "https://raw.githubusercontent.com/regro/cf-graph-countyfair/master/mappings/pypi/grayskull_pypi_mapping.yaml",
"parselmouth": "https://raw.githubusercontent.com/prefix-dev/parselmouth/main/files/mapping_as_grayskull.json",
}


Expand Down Expand Up @@ -135,13 +138,32 @@ def _pypi_to_conda_mapping(source="grayskull"):
except requests.HTTPError as exc:
logger.debug("Could not fetch mapping %s", url, exc_info=exc)
return {}
stream = BytesIO(r.content)
stream.seek(0)
return yaml.load(stream)
if source in ("grayskull", "cf-graph-countyfair"):
stream = BytesIO(r.content)
stream.seek(0)
data = yaml.load(stream)
name_key = "conda_forge" if source == "grayskull" else "conda_name"
mapping = {}
for pypi, payload in data.items():
conda_spec_str = payload[name_key]
if lower_bound := payload.get("delimiter_min"):
conda_spec_str += f">={lower_bound}"
if upper_bound := payload.get("delimiter_max"):
if lower_bound:
conda_spec_str += ","
conda_spec_str += f"<{upper_bound}.0dev0"
mapping[pypi] = conda_spec_str
return mapping
if source == "parselmouth": # json
return {pypi: conda for (conda, pypi) in json.loads(r.text).items()}


@lru_cache(maxsize=None)
def _pypi_spec_to_conda_spec(spec: str, channel: str = "conda-forge"):
def _pypi_spec_to_conda_spec(
spec: str,
channel: str = "conda-forge",
sources: Iterable[str] = NAME_MAPPINGS.keys(),
) -> str:
"""
Tries to find the conda equivalent of a PyPI name. For that it relies
on known mappings (see `_pypi_to_conda_mapping`). If the PyPI name is
Expand All @@ -152,15 +174,22 @@ def _pypi_spec_to_conda_spec(spec: str, channel: str = "conda-forge"):
We could improve this with API calls to metadata servers and compare sources,
but this is not currently implemented or even feasible.
"""
assert spec, "Must be non-empty spec"
assert channel == "conda-forge", "Only channel=conda-forge is supported for now"
match_spec = MatchSpec(spec)
conda_name = pypi_name = match_spec.name
for source in NAME_MAPPINGS:
for source in sources:
mapping = _pypi_to_conda_mapping(source)
if not mapping:
continue
entry = mapping.get(pypi_name, {})
conda_name = entry.get("conda_forge") or entry.get("conda_name") or pypi_name
if conda_name != pypi_name: # we found a match!
return str(MatchSpec(match_spec, name=conda_name))
conda_spec = MatchSpec(mapping.get(pypi_name, pypi_name))
conda_name = conda_spec.name
if conda_name != pypi_name or not conda_spec.is_name_only_spec:
renamed = MatchSpec(match_spec, name=conda_name)
if not conda_spec.is_name_only_spec:
merged = MatchSpec.merge([renamed, conda_spec])
if len(merged) > 1:
raise ValueError(f"This should not happen: {merged}")
return str(merged[0])
return str(renamed)
return spec
45 changes: 44 additions & 1 deletion tests/test_install.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from __future__ import annotations

import sys

import pytest
Expand All @@ -6,7 +8,25 @@
from conda.models.match_spec import MatchSpec
from conda.testing import CondaCLIFixture, TmpEnvFixture

from conda_pypi.dependencies import BACKENDS
from conda_pypi.dependencies import NAME_MAPPINGS, BACKENDS, _pypi_spec_to_conda_spec


@pytest.mark.parametrize("source", NAME_MAPPINGS.keys())
def test_mappings_one_by_one(source: str):
assert _pypi_spec_to_conda_spec("build", sources=(source,)) == "python-build"


@pytest.mark.parametrize("pypi_spec,conda_spec",
[
("numpy", "numpy"),
("build", "python-build"),
("ib_insync", "ib-insync"),
("pyqt5", "pyqt>=5.0.0,<6.0.0.0dev0"),
("PyQt5", "pyqt>=5.0.0,<6.0.0.0dev0"),
]
)
def test_mappings_fallback(pypi_spec: str, conda_spec: str):
assert MatchSpec(_pypi_spec_to_conda_spec(pypi_spec)) == MatchSpec(conda_spec)


@pytest.mark.parametrize("backend", BACKENDS)
Expand Down Expand Up @@ -80,3 +100,26 @@ def test_spec_normalization(
print(err, file=sys.stderr)
assert rc == 0
assert "All packages are already installed." in out + err


@pytest.mark.parametrize("pypi_spec,requested_conda_spec,installed_conda_specs",
[
("PyQt5", "pyqt[version='>=5.0.0,<6.0.0.0dev0']", ("pyqt-5", "qt-main-5")),
]
)
def test_pyqt(
tmp_env: TmpEnvFixture,
conda_cli: CondaCLIFixture,
pypi_spec: str,
requested_conda_spec: str,
installed_conda_specs: tuple[str],
):
with tmp_env("python=3.9", "pip") as prefix:
out, err, rc = conda_cli("pip", "-p", prefix, "--yes", "--dry-run", "install", pypi_spec)
print(out)
print(err, file=sys.stderr)
assert rc == 0
assert requested_conda_spec in out
for conda_spec in installed_conda_specs:
assert conda_spec in out

0 comments on commit 788f4d5

Please sign in to comment.