"""
Convert Python `*.dist-info/METADATA` to conda `info/index.json`
"""
import dataclasses
import json
import logging
import pkgutil
import sys
import time
from importlib.metadata import Distribution, PathDistribution
try:
from importlib.metadata import PackageMetadata
except ImportError:
# Python < 3.10 compatibility
PackageMetadata = Distribution
from pathlib import Path
from typing import Any, Optional, List, Dict
from conda.models.match_spec import MatchSpec
from packaging.requirements import InvalidRequirement, Requirement
from packaging.utils import canonicalize_name
log = logging.getLogger(__name__)
[docs]
class FileDistribution(Distribution):
"""
From a file e.g. a single `.metadata` fetched from pypi instead of a
`*.dist-info` folder.
"""
def __init__(self, raw_text):
self.raw_text = raw_text
[docs]
def read_text(self, filename: str) -> Optional[str]:
if filename == "METADATA":
return self.raw_text
else:
return None
[docs]
def locate_file(self, path):
"""
Given a path to a file in this distribution, return a path
to it.
"""
return None
[docs]
@dataclasses.dataclass
class PackageRecord:
# what goes in info/index.json
name: str
version: str
subdir: str
depends: List[str]
extras: Dict[str, List[str]]
build_number: int = 0
build_text: str = "pypi" # e.g. hash
license_family: str = ""
license: str = ""
noarch: str = ""
timestamp: int = 0
[docs]
def to_index_json(self):
return {
"build_number": self.build_number,
"build": self.build,
"depends": self.depends,
"extras": self.extras,
"license_family": self.license_family,
"license": self.license,
"name": self.name,
"noarch": self.noarch,
"subdir": self.subdir,
"timestamp": self.timestamp,
"version": self.version,
}
@property
def build(self):
return f"{self.build_text}_{self.build_number}"
@property
def stem(self):
return f"{self.name}-{self.version}-{self.build}"
# The keys are pypi names
# conda_pypi.dist_repodata.grayskull_pypi_mapping['zope-hookable']
# {
# "pypi_name": "zope-hookable",
# "conda_name": "zope.hookable",
# "import_name": "zope.hookable",
# "mapping_source": "regro-bot",
# }
grayskull_pypi_mapping = json.loads(
pkgutil.get_data("conda_pypi", "grayskull_pypi_mapping.json") or "{}"
)
[docs]
def requires_to_conda(requires: Optional[List[str]]):
from collections import defaultdict
extras: Dict[str, List[str]] = defaultdict(list)
requirements = []
for requirement in [Requirement(dep) for dep in requires or []]:
# requirement.marker.evaluate
# if requirement.marker and not requirement.marker.evaluate():
# # excluded by environment marker
# # see also marker evaluation according to given sys.executable
# continue
name = canonicalize_name(requirement.name)
requirement.name = pypi_to_conda_name(name)
as_conda = f"{requirement.name} {requirement.specifier}"
if (marker := requirement.marker) is not None:
# for var, _, value in marker._markers:
for mark in marker._markers:
if isinstance(mark, tuple):
var, _, value = mark
if str(var) == "extra":
extras[str(value)].append(as_conda)
else:
requirements.append(f"{requirement.name} {requirement.specifier}".strip())
return requirements, dict(extras)
# if there is a url or extras= here we have extra work, may need to
# yield Requirement not str
# sorted(packaging.requirements.SpecifierSet("<5,>3")._specs, key=lambda x: x.version)
# or just sorted lexicographically in str(SpecifierSet)
# yield f"{requirement.name} {requirement.specifier}"
[docs]
def conda_to_requires(matchspec: MatchSpec):
name = matchspec.name
if isinstance(name, str):
pypi_name = conda_to_pypi_name(name)
# XXX ugly 'omits = for exact version'
# .spec omits package[version='>=1.0'] bracket format when possible
best_format = str(matchspec)
if "version=" in best_format:
best_format = matchspec.spec
try:
return Requirement(best_format.replace(name, pypi_name))
except InvalidRequirement:
# attempt to catch 'httpcore 1.*' style conda requirement
best_format = "==".join(matchspec.spec.split())
return Requirement(best_format.replace(name, pypi_name))
[docs]
def pypi_to_conda_name(pypi_name: str):
pypi_name = canonicalize_name(pypi_name)
return grayskull_pypi_mapping.get(
pypi_name,
{
"pypi_name": pypi_name,
"conda_name": pypi_name,
"import_name": None,
"mapping_source": None,
},
)["conda_name"]
_to_pypi_name_map = {}
[docs]
def conda_to_pypi_name(name: str):
if not _to_pypi_name_map:
for value in grayskull_pypi_mapping.values():
conda_name = value["conda_name"]
# XXX sometimes conda:pypi is n:1
_to_pypi_name_map[conda_name] = value
found = _to_pypi_name_map.get(name)
if found:
name = found["pypi_name"]
return canonicalize_name(name)
if __name__ == "__main__": # pragma: no cover
base = sys.argv[1]
for path in Path(base).glob("*.dist-info"):
print(CondaMetadata.from_distribution(PathDistribution(path)))