Skip to content

Commit

Permalink
Use pip as a solver (#12)
Browse files Browse the repository at this point in the history
* naive pip --dry-run --report

* some fixes

* from utils

* this shouldn't be a set

* patch history

* readd tests for grayskull too

* make it str for debugging

* fix test

* use D: for tmp on WIndows?

* output pip report to stdout

* do not choke on decoding errors

* use this other method

* debug

* longer

* fix decoding issues on WIndows

* refactor the classifier out

* more typing

* remove tmate

* fix annotations

* more fixes

* map names on our own

* add more tests for pkgs not available in conda

* configure channels
  • Loading branch information
jaimergp authored Apr 5, 2024
1 parent 4d628c6 commit 9815fa1
Show file tree
Hide file tree
Showing 10 changed files with 410 additions and 91 deletions.
7 changes: 6 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,12 @@ jobs:
pixi add "python=${{ matrix.python-version }}.*=*cpython*"
pixi run dev
pixi run conda info
- name: Configure conda
run: |
echo "channels: [conda-forge]" > .pixi/envs/default/.condarc
- name: Patch history file (temporary)
run: echo "//fix" > .pixi/envs/default/conda-meta/history
- name: Run tests
run: pixi run test
run: pixi run test --basetemp=${{ runner.os == 'Windows' && 'D:\\temp' || runner.temp }}
- name: Build recipe
run: pixi run build
25 changes: 22 additions & 3 deletions conda_pip/cli.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"""
conda pip subcommand for CLI
"""
from __future__ import annotations

import argparse
import os
import sys
Expand All @@ -17,6 +19,8 @@


def configure_parser(parser: argparse.ArgumentParser):
from .dependencies import BACKENDS

add_parser_help(parser)
add_parser_prefix(parser)
add_output_and_prompt_options(parser)
Expand Down Expand Up @@ -51,14 +55,22 @@ def configure_parser(parser: argparse.ArgumentParser):
default="conda-forge",
help="Where to look for conda dependencies.",
)
install.add_argument(
"--backend",
metavar="TOOL",
default="pip",
choices=BACKENDS,
help="Which tool to use for PyPI packaging dependency resolution.",
)
install.add_argument("packages", metavar="package", nargs="+")


def execute(args: argparse.Namespace) -> None:
def execute(args: argparse.Namespace) -> int:
from conda.common.io import Spinner
from conda.models.match_spec import MatchSpec
from .dependencies import analyze_dependencies
from .main import (validate_target_env, get_prefix, ensure_externally_managed, run_conda_install, run_pip_install,)
from .main import (validate_target_env, ensure_externally_managed, run_conda_install, run_pip_install)
from .utils import get_prefix

prefix = get_prefix(args.prefix, args.name)
packages_not_installed = validate_target_env(prefix, args.packages)
Expand All @@ -73,6 +85,9 @@ def execute(args: argparse.Namespace) -> None:
*packages_to_process,
prefer_on_conda=not args.force_with_pip,
channel=args.conda_channel,
backend=args.backend,
prefix=prefix,
force_reinstall=args.force_reinstall,
)

conda_match_specs = []
Expand Down Expand Up @@ -104,7 +119,11 @@ def execute(args: argparse.Namespace) -> None:
print(" -", spec)

if not args.yes and not args.json:
confirm_yn(dry_run=args.dry_run)
if conda_match_specs or pypi_specs:
confirm_yn(dry_run=False) # we let conda handle the dry-run exit below
else:
print("Nothing to do.", file=sys.stderr)
return 0

if conda_match_specs:
if not args.quiet or not args.json:
Expand Down
155 changes: 155 additions & 0 deletions conda_pip/dependencies/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
""" """

from __future__ import annotations

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

import requests
from conda.models.match_spec import MatchSpec
from conda_libmamba_solver.index import LibMambaIndexHelper as Index
from ruamel.yaml import YAML

yaml = YAML(typ="safe")
logger = getLogger(f"conda.{__name__}")

BACKENDS = (
"grayskull",
"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",
}


def analyze_dependencies(
*packages: str,
prefer_on_conda: bool = True,
channel: str = "conda-forge",
backend: Literal["grayskull", "pip"] = "pip",
prefix: str | os.PathLike | None = None,
force_reinstall: bool = False,
) -> tuple[dict[str, list[str]], dict[str, list[str]]]:
conda_deps = defaultdict(list)
needs_analysis = []
for package in packages:
match_spec = MatchSpec(package)
pkg_name = match_spec.name
# pkg_version = match_spec.version
if prefer_on_conda and _is_pkg_on_conda(pkg_name, channel=channel):
# TODO: check if version is available too
logger.info("Package %s is available on %s. Skipping analysis.", pkg_name, channel)
conda_spec = _pypi_spec_to_conda_spec(package)
conda_deps[pkg_name].append(conda_spec)
continue
needs_analysis.append(package)

if not needs_analysis:
return conda_deps, {}

if backend == "grayskull":
from .grayskull import _analyze_with_grayskull

found_conda_deps, pypi_deps = _analyze_with_grayskull(
*needs_analysis, prefer_on_conda=prefer_on_conda, channel=channel
)
elif backend == "pip":
from .pip import _analyze_with_pip

python_deps, pypi_deps = _analyze_with_pip(
*needs_analysis,
prefix=prefix,
force_reinstall=force_reinstall,
)
found_conda_deps, pypi_deps = _classify_dependencies(
pypi_deps,
prefer_on_conda=prefer_on_conda,
channel=channel,
)
found_conda_deps.update(python_deps)
else:
raise ValueError(f"Unknown backend {backend}")

for name, specs in found_conda_deps.items():
conda_deps[name].extend(specs)

# deduplicate
conda_deps = {name: list(dict.fromkeys(specs)) for name, specs in conda_deps.items()}
pypi_deps = {name: list(dict.fromkeys(specs)) for name, specs in pypi_deps.items()}
return conda_deps, pypi_deps


def _classify_dependencies(
deps_from_pypi: dict[str, list[str]],
prefer_on_conda: bool = True,
channel: str = "conda-forge",
) -> tuple[dict[str, list[str]], dict[str, list[str]]]:
pypi_deps = defaultdict(list)
conda_deps = defaultdict(list)
for depname, deps in deps_from_pypi.items():
if prefer_on_conda and _is_pkg_on_conda(depname, channel=channel):
conda_depname = _pypi_spec_to_conda_spec(depname, channel=channel).name
deps_mapped_to_conda = [_pypi_spec_to_conda_spec(dep, channel=channel) for dep in deps]
conda_deps[conda_depname].extend(deps_mapped_to_conda)
else:
pypi_deps[depname].extend(deps)
return conda_deps, pypi_deps


@lru_cache(maxsize=None)
def _is_pkg_on_conda(pypi_spec: str, channel: str = "conda-forge"):
"""
Given a PyPI spec (name, version), try to find it on conda-forge.
"""
conda_spec = _pypi_spec_to_conda_spec(pypi_spec)
index = Index(channels=[channel])
records = index.search(conda_spec)
return bool(records)


@lru_cache(maxsize=None)
def _pypi_to_conda_mapping(source="grayskull"):
try:
url = NAME_MAPPINGS[source]
except KeyError as exc:
raise ValueError(f"Invalid source {source}. Allowed: {NAME_MAPPINGS.keys()}") from exc
r = requests.get(url)
try:
r.raise_for_status()
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)


@lru_cache(maxsize=None)
def _pypi_spec_to_conda_spec(spec: str, channel: str = "conda-forge"):
"""
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
not found in any of the mappings, we assume the name is the same.
Note that we don't currently have a way to disambiguate two different
projects that have the same name in PyPI and conda-forge (e.g. quetz, pixi).
We could improve this with API calls to metadata servers and compare sources,
but this is not currently implemented or even feasible.
"""
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:
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))
return spec
46 changes: 23 additions & 23 deletions conda_pip/dependencies.py → conda_pip/dependencies/grayskull.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"""
"""
from __future__ import annotations

import os
from logging import getLogger, ERROR
from collections import defaultdict
Expand All @@ -21,19 +21,20 @@
keep_refs_alive = []


def analyze_dependencies(*packages: str, prefer_on_conda=True, channel="conda-forge", backend="grayskull"):
def _analyze_with_grayskull(
*packages: str,
prefer_on_conda: bool = True,
channel: str = "conda-forge",
) -> tuple[dict[str, list[str]], dict[str, list[str]]]:
conda_deps = defaultdict(list)
pypi_deps = defaultdict(list)
for package in packages:
match_spec = MatchSpec(package)
pkg_name = match_spec.name
pkg_version = match_spec.version
if prefer_on_conda and is_pkg_available(pkg_name, channel=channel):
# TODO: check if version is available too
logger.info("Package %s is available on %s. Skipping analysis.", pkg_name, channel)
conda_deps[pkg_name].append(f"{channel}::{package}")
continue
conda_deps_map, pypi_deps_map, visited_pypi_map = _recursive_dependencies(pkg_name, pkg_version)
conda_deps_map, pypi_deps_map, visited_pypi_map = _recursive_grayskull(
pkg_name, pkg_version
)
for name, specs in conda_deps_map.items():
conda_deps[name].extend(specs)
for name, specs in pypi_deps_map.items():
Expand All @@ -45,34 +46,30 @@ def analyze_dependencies(*packages: str, prefer_on_conda=True, channel="conda-fo
spec += f"=={version}"
pypi_deps[name].append(spec)

# deduplicate
conda_deps = {name: list(dict.fromkeys(specs)) for name, specs in conda_deps.items()}
pypi_deps = {name: list(dict.fromkeys(specs)) for name, specs in pypi_deps.items()}

return conda_deps, pypi_deps


def _recursive_dependencies(
pkg_name,
pkg_version="",
conda_deps_map=None,
pypi_deps_map=None,
visited_pypi_map=None,
):
def _recursive_grayskull(
pkg_name: str,
pkg_version: str = "",
conda_deps_map: dict[str, list[str]] | None = None,
pypi_deps_map: dict[str, list[str]] | None = None,
visited_pypi_map: dict[str, list[str]] | None = None,
) -> tuple[dict[str, list[str]], dict[str, list[str]], dict[str, list[str]]]:
conda_deps_map = conda_deps_map or defaultdict(list)
pypi_deps_map = pypi_deps_map or defaultdict(list)
visited_pypi_map = visited_pypi_map or defaultdict(list)
if (pkg_name, pkg_version) in visited_pypi_map:
return conda_deps_map, pypi_deps_map, visited_pypi_map

conda_deps, pypi_deps, config = _analyze_with_grayskull(pkg_name, pkg_version)
conda_deps, pypi_deps, config = _analyze_one_with_grayskull(pkg_name, pkg_version)
visited_pypi_map[(pkg_name, pkg_version)].append((config.name, config.version))

for name, dep in conda_deps.items():
conda_deps_map[name].append(dep)
for name, dep in pypi_deps.items():
pypi_deps_map[name].append(dep)
_recursive_dependencies(
_recursive_grayskull(
name,
conda_deps_map=conda_deps_map,
pypi_deps_map=pypi_deps_map,
Expand All @@ -82,7 +79,10 @@ def _recursive_dependencies(
return conda_deps_map, pypi_deps_map, visited_pypi_map


def _analyze_with_grayskull(package, version=""):
def _analyze_one_with_grayskull(
package: str,
version: str = "",
) -> tuple[dict[str, str], dict[str, str], GrayskullConfiguration]:
config = GrayskullConfiguration(name=package, version=version, is_strict_cf=True)
try:
with redirect_stdout(os.devnull), redirect_stderr(os.devnull):
Expand Down
70 changes: 70 additions & 0 deletions conda_pip/dependencies/pip.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
from __future__ import annotations

import json
import os
from logging import getLogger
from collections import defaultdict
from subprocess import run
from tempfile import NamedTemporaryFile

from conda.exceptions import CondaError

from ..utils import get_env_python

logger = getLogger(f"conda.{__name__}")


def _analyze_with_pip(
*packages: str,
prefix: str | None = None,
force_reinstall: bool = False,
) -> tuple[dict[str, list[str]], dict[str, list[str]]]:
# pip can output to stdout via `--report -` (dash), but this
# creates issues on Windows due to undecodable characters on some
# project descriptions (e.g. charset-normalizer, amusingly), which
# makes pip crash internally. Probably a bug on their end.
# So we use a temporary file instead to work with bytes.
json_output = NamedTemporaryFile(suffix=".json", delete=False)
json_output.close() # Prevent access errors on Windows

cmd = [
str(get_env_python(prefix)),
"-mpip",
"install",
"--dry-run",
"--ignore-installed",
*(("--force-reinstall",) if force_reinstall else ()),
"--report",
json_output.name,
*packages,
]
process = run(cmd, capture_output=True, text=True)
if process.returncode != 0:
raise CondaError(
f"Failed to analyze dependencies with pip:\n"
f" command: {' '.join(cmd)}\n"
f" exit code: {process.returncode}\n"
f" stderr:\n{process.stderr}\n"
f" stdout:\n{process.stdout}\n"
)
logger.debug("pip (%s) provided the following report:\n%s", " ".join(cmd), process.stdout)

with open(json_output.name, "rb") as f:
# We need binary mode because the JSON output might
# contain weird unicode stuff (as part of the project
# description or README).
report = json.loads(f.read())
os.unlink(json_output.name)

deps_from_pip = defaultdict(list)
conda_deps = defaultdict(list)
for item in report["install"]:
metadata = item["metadata"]
logger.debug("Analyzing %s", metadata["name"])
logger.debug(" metadata: %s", json.dumps(metadata, indent=2))
deps_from_pip[metadata["name"]].append(f"{metadata['name']}=={metadata['version']}")
if python_version := metadata.get("requires_python"):
conda_deps["python"].append(f"python {python_version}")

deps_from_pip = {name: list(dict.fromkeys(specs)) for name, specs in deps_from_pip.items()}
return conda_deps, deps_from_pip
Loading

0 comments on commit 9815fa1

Please sign in to comment.