Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Normalize PyPI specs #13

Merged
merged 7 commits into from
Apr 9, 2024
Merged
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 conda_pip/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ def execute(args: argparse.Namespace) -> int:
packages_to_process = args.packages if args.force_reinstall else packages_not_installed
if not packages_to_process:
print("All packages are already installed.", file=sys.stderr)
return
return 0

with Spinner("Analyzing dependencies", enabled=not args.quiet, json=args.json):
conda_deps, pypi_deps = analyze_dependencies(
Expand Down
55 changes: 33 additions & 22 deletions conda_pip/dependencies/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
from conda_libmamba_solver.index import LibMambaIndexHelper as Index
from ruamel.yaml import YAML

from ..utils import pypi_spec_variants

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

Expand All @@ -28,7 +30,7 @@


def analyze_dependencies(
*packages: str,
*pypi_specs: str,
prefer_on_conda: bool = True,
channel: str = "conda-forge",
backend: Literal["grayskull", "pip"] = "pip",
Expand All @@ -37,17 +39,20 @@ def analyze_dependencies(
) -> 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)
for pypi_spec in pypi_specs:
if prefer_on_conda:
pkg_is_on_conda, conda_spec = _is_pkg_on_conda(pypi_spec, channel=channel)
if pkg_is_on_conda:
# TODO: check if version is available too
logger.info(
"Package %s is available on %s as %s. Skipping analysis.",
pypi_spec,
channel,
conda_spec,
)
conda_deps[MatchSpec(conda_spec).name].append(conda_spec)
continue
needs_analysis.append(pypi_spec)

if not needs_analysis:
return conda_deps, {}
Expand Down Expand Up @@ -92,24 +97,30 @@ def _classify_dependencies(
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)
if prefer_on_conda:
on_conda, conda_depname = _is_pkg_on_conda(depname, channel=channel)
if on_conda:
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)
continue
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"):
def _is_pkg_on_conda(pypi_spec: str, channel: str = "conda-forge") -> tuple[bool, str]:
"""
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)
for spec_variant in pypi_spec_variants(pypi_spec):
conda_spec = _pypi_spec_to_conda_spec(spec_variant)
records = index.search(conda_spec)
if records:
return True, conda_spec
return False, pypi_spec


@lru_cache(maxsize=None)
Expand Down
13 changes: 7 additions & 6 deletions conda_pip/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from conda.exceptions import CondaError, CondaSystemExit
from conda.models.match_spec import MatchSpec

from .utils import get_env_python, get_externally_managed_path
from .utils import get_env_python, get_externally_managed_path, pypi_spec_variants

logger = getLogger(f"conda.{__name__}")
HERE = Path(__file__).parent.resolve()
Expand All @@ -35,11 +35,12 @@ def validate_target_env(path: Path, packages: Iterable[str]) -> Iterable[str]:

packages_to_process = []
for pkg in packages:
spec = MatchSpec(pkg)
if list(pd.query(spec)):
logger.warning("package %s is already installed; ignoring", spec)
continue
packages_to_process.append(pkg)
for spec_variant in pypi_spec_variants(pkg):
if list(pd.query(spec_variant)):
logger.warning("package %s is already installed; ignoring", pkg)
break
else:
packages_to_process.append(pkg)
return packages_to_process


Expand Down
13 changes: 13 additions & 0 deletions conda_pip/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from typing import Iterator

from conda.base.context import context, locate_prefix_by_name
from conda.models.match_spec import MatchSpec


logger = getLogger(f"conda.{__name__}")
Expand Down Expand Up @@ -58,3 +59,15 @@ def get_externally_managed_path(prefix: os.PathLike = None) -> Iterator[Path]:
yield Path(python_dir, "EXTERNALLY-MANAGED")
if not found:
raise ValueError("Could not locate EXTERNALLY-MANAGED file")

def pypi_spec_variants(spec_str: str) -> Iterator[str]:
yield spec_str
spec = MatchSpec(spec_str)
seen = {spec_str}
for name_variant in (
spec.name.replace("-", "_"),
spec.name.replace("_", "-"),
):
if name_variant not in seen: # only yield if actually different
yield str(MatchSpec(spec, name=name_variant))
seen.add(name_variant)
18 changes: 16 additions & 2 deletions tests/test_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
# and later renamed to python-build; conda-forge::build is
# only available til 0.7, but conda-forge::python-build has 1.x
("build>=1", "python-build>=1", "conda-forge"),
# ib-insync is only available with dashes, not with underscores
("ib_insync", "ib-insync", "conda-forge"),
# these won't be ever published in conda-forge, I guess
("aaargh", None, "pypi"),
("5-exercise-upload-to-pypi", None, "pypi"),
Expand Down Expand Up @@ -53,7 +55,7 @@ def test_conda_pip_install(
for name in (
MatchSpec(pypi_spec).name,
MatchSpec(pypi_spec).name.replace("-", "_"), # pip normalizes this
MatchSpec(conda_spec).name
MatchSpec(conda_spec).name,
)
)
PrefixData._cache_.clear()
Expand All @@ -65,4 +67,16 @@ def test_conda_pip_install(
records = list(pd.query(conda_spec))
assert len(records) == 1
assert records[0].channel.name == channel



def test_spec_normalization(
tmp_env: TmpEnvFixture,
conda_cli: CondaCLIFixture,
):
with tmp_env("python=3.9", "pip", "pytest-cov") as prefix:
for spec in ("pytest-cov", "pytest_cov", "PyTest-Cov"):
out, err, rc = conda_cli("pip", "--dry-run", "-p", prefix, "--yes", "install", spec)
print(out)
print(err, file=sys.stderr)
assert rc == 0
assert "All packages are already installed." in out + err
Loading