diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7a1ded9..7058887 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,6 +64,9 @@ jobs: strategy: matrix: container: + - ghcr.io/robotpy/crossenv-ci-images:py308-arm64-24.04-qemu + - ghcr.io/robotpy/crossenv-ci-images:py309-arm64-24.04-qemu + - ghcr.io/robotpy/crossenv-ci-images:py310-arm64-24.04-qemu - ghcr.io/robotpy/crossenv-ci-images:py311-arm64-24.04-qemu - ghcr.io/robotpy/crossenv-ci-images:py312-arm64-24.04-qemu - ghcr.io/robotpy/crossenv-ci-images:py313-arm64-24.04-qemu diff --git a/crossenv/__init__.py b/crossenv/__init__.py index 7f03e37..f72b262 100644 --- a/crossenv/__init__.py +++ b/crossenv/__init__.py @@ -538,6 +538,7 @@ def ensure_directories(self, env_dir): utils.remove_path(os.path.join(env_dir, sub)) context = super().ensure_directories(env_dir) + context.sys_executable = sys.executable context.lib_path = os.path.join(env_dir, "lib") context.exposed_libs = os.path.join(context.lib_path, "exposed.txt") utils.mkdir_if_needed(context.lib_path) @@ -893,7 +894,6 @@ def make_cross_python(self, context): "subprocess-patch.py", "distutils-sysconfig-patch.py", "pip-_vendor-distlib-scripts-patch.py", - "pkg_resources-patch.py", "packaging-tags-patch.py", ] diff --git a/crossenv/scripts/cross-expose.py.tmpl b/crossenv/scripts/cross-expose.py.tmpl index f7a17af..362e400 100644 --- a/crossenv/scripts/cross-expose.py.tmpl +++ b/crossenv/scripts/cross-expose.py.tmpl @@ -1,16 +1,89 @@ #!{{context.build_env_exe}} import sys -import os import importlib import argparse import logging -import pkg_resources +import pathlib +import re +import subprocess +import tempfile + + +try: + import importlib.metadata + + def _get_package(name): + try: + dist = importlib.metadata.distribution(name) + return dist.version + except importlib.metadata.PackageNotFoundError: + return None + +except ImportError: + import pkg_resources + + def _get_package(name): + try: + dist = pkg_resources.get_distribution(name) + return dist.version + except pkg_resources.DistributionNotFound: + return None + logger = logging.getLogger() EXPOSED_LIBS = {{repr(context.exposed_libs)}} +def install_fake_wheel(name, version): + # https://packaging.python.org/en/latest/specifications/name-normalization/#name-normalization + name = re.sub(r"[-_.]+", "-", name).lower() + wheel_dist = name.replace("-", "_") + + with tempfile.TemporaryDirectory() as tname: + tpath = pathlib.Path(tname) + dist_info = tpath / f"{wheel_dist}-{version}.dist-info" + dist_info.mkdir(parents=True, exist_ok=True) + + with open(dist_info / "METADATA", "w") as fp: + fp.write( + "Metadata-Version: 2.1\n" f"Name: {name}\n" f"Version: {version}\n" + ) + + with open(dist_info / "WHEEL", "w") as fp: + fp.write( + "Wheel-Version: 1.0\n" + "Generator: crossenv\n" + "Root-Is-Purelib: true\n" + "Tag: py3-none-any\n" + ) + + subprocess.check_call( + [ + {{repr(context.sys_executable)}}, + "-m", + "wheel", + "pack", + tname, + "--dest-dir", + tname, + ] + ) + + whl = list(tpath.glob("*.whl"))[0] + + subprocess.check_call( + [ + {{repr(context.cross_env_exe)}}, + "-m", + "pip", + "--disable-pip-version-check", + "install", + str(whl), + ] + ) + + def get_exposed(): exposed = set() try: @@ -34,15 +107,19 @@ def list_exposed(): def expose_packages(names, unexpose=False): exposed = get_exposed() names = set(names) + added = set() + removed = [] if not unexpose: for name in names: - try: - pkg_resources.require(name) - except pkg_resources.DistributionNotFound: - logger.warning("%r was not found in build-python. Skipping.", name) - else: + if name in exposed: + continue + version = _get_package(name) + if version is not None: exposed.add(name) + added.add((name, version)) + else: + logger.warning("%r was not found in build-python. Skipping.", name) else: for name in names: @@ -50,6 +127,23 @@ def expose_packages(names, unexpose=False): logger.warning("%r was not exposed. Skipping.", name) else: exposed.remove(name) + removed.append(name) + + for name, version in added: + install_fake_wheel(name, version) + + if removed: + subprocess.check_call( + [ + {{repr(context.cross_env_exe)}}, + "-m", + "pip", + "--disable-pip-version-check", + "uninstall", + "-y", + ] + + removed + ) with open(EXPOSED_LIBS, "w") as fp: for item in sorted(exposed): @@ -129,7 +223,7 @@ def main(): expose_packages(args.MODULE, args.unexpose) except Exception as e: exit_code = 1 - logger.error("Cannot %s %s: %s", action, mod, e) + logger.error("Cannot %s %s: %s", action, args.MODULE, e) logger.error("Traceback:", exc_info=True) sys.exit(exit_code) diff --git a/crossenv/scripts/pkg_resources-patch.py.tmpl b/crossenv/scripts/pkg_resources-patch.py.tmpl deleted file mode 100644 index 5cc4330..0000000 --- a/crossenv/scripts/pkg_resources-patch.py.tmpl +++ /dev/null @@ -1,46 +0,0 @@ -# Modify sys.path in a way that we can selectivly let setuptools know -# about packages in build-python. This won't change how things are imported: -# just whether or not setuptools thinks they are installed. -import sys - -_EXPOSED_LIBS = os.path.realpath({{repr(context.exposed_libs)}}) -_ALLOWED = set() -try: - with open(_EXPOSED_LIBS, "r") as fp: - for line in fp: - allow = line.split("#", 1)[0].strip() - if allow: - _ALLOWED.add(allow) -except IOError: - pass - - -class BuildPathEntryFinder: - def __init__(self, path): - if os.path.realpath(path) != _EXPOSED_LIBS: - raise ImportError() - - def invalidate_caches(cls): - pass - - def find_module(self, fullname): - return None - - def find_loader(self, fullname): - return None, [] - - def find_spec(self, fullname, target=None): - return None - - -def find_on_build_path(importer, path_item, only=False): - for path in sys.build_path: - for dist in find_on_path(importer, path, only): - if dist.project_name in _ALLOWED: - yield dist - - -sys.path_hooks.append(BuildPathEntryFinder) -sys.path.append(_EXPOSED_LIBS) -register_finder(BuildPathEntryFinder, find_on_build_path) -working_set.add_entry(_EXPOSED_LIBS) diff --git a/crossenv/scripts/site.py.tmpl b/crossenv/scripts/site.py.tmpl index cc2a980..13c9309 100644 --- a/crossenv/scripts/site.py.tmpl +++ b/crossenv/scripts/site.py.tmpl @@ -122,9 +122,7 @@ class CrossenvFinder(importlib.abc.MetaPathFinder): "distutils.sysconfig": "{{context.lib_path}}/distutils-sysconfig-patch.py", "distutils.sysconfig_pypy": "{{context.lib_path}}/distutils-sysconfig-patch.py", "platform": "{{context.lib_path}}/platform-patch.py", - "pkg_resources": "{{context.lib_path}}/pkg_resources-patch.py", "pip._vendor.distlib.scripts": "{{context.lib_path}}/pip-_vendor-distlib-scripts-patch.py", - "pip._vendor.pkg_resources": "{{context.lib_path}}/pkg_resources-patch.py", "pip._vendor.packaging.tags": "{{context.lib_path}}/packaging-tags-patch.py", "packaging.tags": "{{context.lib_path}}/packaging-tags-patch.py", } diff --git a/pyproject.toml b/pyproject.toml index 7c0e7a4..b7400dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,9 @@ classifiers = [ "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", ] +dependencies = [ + "wheel" +] [project.urls] Homepage = "https://github.com/benfogle/crossenv" diff --git a/tests/test_environment.py b/tests/test_environment.py index 4b93a7c..5cb11b5 100644 --- a/tests/test_environment.py +++ b/tests/test_environment.py @@ -248,7 +248,6 @@ def test_run_sysconfig_module(crossenv): assert destdirs_cmdline == out -@pytest.mark.xfail(reason="cross-expose needs to patch importlib.metadata") def test_cross_expose(crossenv): out = crossenv.check_output(["pip", "freeze"]) assert b"colorama" not in out