codekingpro/portable-devtools
114k
1"""Wheels support."""2 3import contextlib4import email5import functools6import itertools7import os8import posixpath9import re10import zipfile11from collections.abc import Iterator12 13from packaging.requirements import Requirement14from packaging.tags import sys_tags15from packaging.utils import canonicalize_name16from packaging.version import Version as parse_version17 18import setuptools19from setuptools.archive_util import _unpack_zipfile_obj20from setuptools.command.egg_info import _egg_basename, write_requirements21 22from ._discovery import extras_from_deps23from ._importlib import metadata24from .unicode_utils import _read_utf8_with_fallback25 26from distutils.util import get_platform27 28WHEEL_NAME = re.compile(29 r"""^(?P<project_name>.+?)-(?P<version>\d.*?)30 ((-(?P<build>\d.*?))?-(?P<py_version>.+?)-(?P<abi>.+?)-(?P<platform>.+?)31 )\.whl$""",32 re.VERBOSE,33).match34 35NAMESPACE_PACKAGE_INIT = "__import__('pkg_resources').declare_namespace(__name__)\n"36 37 38@functools.cache39def _get_supported_tags():40 # We calculate the supported tags only once, otherwise calling41 # this method on thousands of wheels takes seconds instead of42 # milliseconds.43 return {(t.interpreter, t.abi, t.platform) for t in sys_tags()}44 45 46def unpack(src_dir, dst_dir) -> None:47 """Move everything under `src_dir` to `dst_dir`, and delete the former."""48 for dirpath, dirnames, filenames in os.walk(src_dir):49 subdir = os.path.relpath(dirpath, src_dir)50 for f in filenames:51 src = os.path.join(dirpath, f)52 dst = os.path.join(dst_dir, subdir, f)53 os.renames(src, dst)54 for n, d in reversed(list(enumerate(dirnames))):55 src = os.path.join(dirpath, d)56 dst = os.path.join(dst_dir, subdir, d)57 if not os.path.exists(dst):58 # Directory does not exist in destination,59 # rename it and prune it from os.walk list.60 os.renames(src, dst)61 del dirnames[n]62 # Cleanup.63 for dirpath, dirnames, filenames in os.walk(src_dir, topdown=True):64 assert not filenames65 os.rmdir(dirpath)66 67 68@contextlib.contextmanager69def disable_info_traces() -> Iterator[None]:70 """71 Temporarily disable info traces.72 """73 from distutils import log74 75 saved = log.set_threshold(log.WARN)76 try:77 yield78 finally:79 log.set_threshold(saved)80 81 82class Wheel:83 def __init__(self, filename) -> None:84 match = WHEEL_NAME(os.path.basename(filename))85 if match is None:86 raise ValueError(f'invalid wheel name: {filename!r}')87 self.filename = filename88 for k, v in match.groupdict().items():89 setattr(self, k, v)90 91 def tags(self):92 """List tags (py_version, abi, platform) supported by this wheel."""93 return itertools.product(94 self.py_version.split('.'),95 self.abi.split('.'),96 self.platform.split('.'),97 )98 99 def is_compatible(self):100 """Is the wheel compatible with the current platform?"""101 return next((True for t in self.tags() if t in _get_supported_tags()), False)102 103 def egg_name(self):104 return (105 _egg_basename(106 self.project_name,107 self.version,108 platform=(None if self.platform == 'any' else get_platform()),109 )110 + ".egg"111 )112 113 def get_dist_info(self, zf):114 # find the correct name of the .dist-info dir in the wheel file115 for member in zf.namelist():116 dirname = posixpath.dirname(member)117 if dirname.endswith('.dist-info') and canonicalize_name(dirname).startswith(118 canonicalize_name(self.project_name)119 ):120 return dirname121 raise ValueError("unsupported wheel format. .dist-info not found")122 123 def install_as_egg(self, destination_eggdir) -> None:124 """Install wheel as an egg directory."""125 with zipfile.ZipFile(self.filename) as zf:126 self._install_as_egg(destination_eggdir, zf)127 128 def _install_as_egg(self, destination_eggdir, zf):129 dist_basename = f'{self.project_name}-{self.version}'130 dist_info = self.get_dist_info(zf)131 dist_data = f'{dist_basename}.data'132 egg_info = os.path.join(destination_eggdir, 'EGG-INFO')133 134 self._convert_metadata(zf, destination_eggdir, dist_info, egg_info)135 self._move_data_entries(destination_eggdir, dist_data)136 self._fix_namespace_packages(egg_info, destination_eggdir)137 138 @staticmethod139 def _convert_metadata(zf, destination_eggdir, dist_info, egg_info):140 def get_metadata(name):141 with zf.open(posixpath.join(dist_info, name)) as fp:142 value = fp.read().decode('utf-8')143 return email.parser.Parser().parsestr(value)144 145 wheel_metadata = get_metadata('WHEEL')146 # Check wheel format version is supported.147 wheel_version = parse_version(wheel_metadata.get('Wheel-Version'))148 wheel_v1 = parse_version('1.0') <= wheel_version < parse_version('2.0dev0')149 if not wheel_v1:150 raise ValueError(f'unsupported wheel format version: {wheel_version}')151 # Extract to target directory.152 _unpack_zipfile_obj(zf, destination_eggdir)153 dist_info = os.path.join(destination_eggdir, dist_info)154 install_requires, extras_require = Wheel._convert_requires(155 destination_eggdir, dist_info156 )157 os.rename(dist_info, egg_info)158 os.rename(159 os.path.join(egg_info, 'METADATA'),160 os.path.join(egg_info, 'PKG-INFO'),161 )162 setup_dist = setuptools.Distribution(163 attrs=dict(164 install_requires=install_requires,165 extras_require=extras_require,166 ),167 )168 with disable_info_traces():169 write_requirements(170 setup_dist.get_command_obj('egg_info'),171 None,172 os.path.join(egg_info, 'requires.txt'),173 )174 175 @staticmethod176 def _convert_requires(destination_eggdir, dist_info):177 md = metadata.Distribution.at(dist_info).metadata178 deps = md.get_all('Requires-Dist') or []179 reqs = list(map(Requirement, deps))180 181 extras = extras_from_deps(deps)182 183 # Note: Evaluate and strip markers now,184 # as it's difficult to convert back from the syntax:185 # foobar; "linux" in sys_platform and extra == 'test'186 def raw_req(req):187 req = Requirement(str(req))188 req.marker = None189 return str(req)190 191 def eval(req, **env):192 return not req.marker or req.marker.evaluate(env)193 194 def for_extra(req):195 try:196 markers = req.marker._markers197 except AttributeError:198 markers = ()199 return set(200 marker[2].value201 for marker in markers202 if isinstance(marker, tuple) and marker[0].value == 'extra'203 )204 205 install_requires = list(206 map(raw_req, filter(eval, itertools.filterfalse(for_extra, reqs)))207 )208 extras_require = {209 extra: list(210 map(211 raw_req,212 (req for req in reqs if for_extra(req) and eval(req, extra=extra)),213 )214 )215 for extra in extras216 }217 return install_requires, extras_require218 219 @staticmethod220 def _move_data_entries(destination_eggdir, dist_data):221 """Move data entries to their correct location."""222 dist_data = os.path.join(destination_eggdir, dist_data)223 dist_data_scripts = os.path.join(dist_data, 'scripts')224 if os.path.exists(dist_data_scripts):225 egg_info_scripts = os.path.join(destination_eggdir, 'EGG-INFO', 'scripts')226 os.mkdir(egg_info_scripts)227 for entry in os.listdir(dist_data_scripts):228 # Remove bytecode, as it's not properly handled229 # during easy_install scripts install phase.230 if entry.endswith('.pyc'):231 os.unlink(os.path.join(dist_data_scripts, entry))232 else:233 os.rename(234 os.path.join(dist_data_scripts, entry),235 os.path.join(egg_info_scripts, entry),236 )237 os.rmdir(dist_data_scripts)238 for subdir in filter(239 os.path.exists,240 (241 os.path.join(dist_data, d)242 for d in ('data', 'headers', 'purelib', 'platlib')243 ),244 ):245 unpack(subdir, destination_eggdir)246 if os.path.exists(dist_data):247 os.rmdir(dist_data)248 249 @staticmethod250 def _fix_namespace_packages(egg_info, destination_eggdir):251 namespace_packages = os.path.join(egg_info, 'namespace_packages.txt')252 if os.path.exists(namespace_packages):253 namespace_packages = _read_utf8_with_fallback(namespace_packages).split()254 255 for mod in namespace_packages:256 mod_dir = os.path.join(destination_eggdir, *mod.split('.'))257 mod_init = os.path.join(mod_dir, '__init__.py')258 if not os.path.exists(mod_dir):259 os.mkdir(mod_dir)260 if not os.path.exists(mod_init):261 with open(mod_init, 'w', encoding="utf-8") as fp:262 fp.write(NAMESPACE_PACKAGE_INIT)263 