diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 31efa14..81caf0c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -29,32 +29,25 @@ jobs: include: - os: ubuntu-latest subdir: linux-64 - python-version: "3.9" - os: macos-latest subdir: osx-64 - python-version: "3.9" - os: windows-latest subdir: win-64 - python-version: "3.9" steps: - uses: actions/checkout@v3 - uses: conda-incubator/setup-miniconda@v2 with: auto-activate-base: true activate-environment: "" - miniforge-variant: Mambaforge - miniforge-version: latest - use-mamba: true run-post: false - - name: Install conda-libmamba-solver + + - name: Install dependencies shell: bash -el {0} - run: | - mamba install "conda>=22.11.1" conda-libmamba-solver conda-build anaconda-client - conda update --all --solver=libmamba + run: conda install -y "conda-build!=3.28.0,!=3.28.1" anaconda-client + - name: Build recipe shell: bash -el {0} env: - CONDA_SOLVER: libmamba CONDA_BLD_PATH: ${{ runner.temp }}/bld run: conda build recipe diff --git a/news/36-update-menuinst b/news/36-update-menuinst new file mode 100644 index 0000000..959bb2f --- /dev/null +++ b/news/36-update-menuinst @@ -0,0 +1,20 @@ +### Enhancements + +* Bump to `python` 3.10.13, `conda` 23.11.0, `conda-libmamba-solver` 23.11.1 and `libmambapy` 1.5.3. (#36) +* Bundle `menuinst` v2.0.1 and import `menuinst` directly without relying on `constructor`'s `_nsis.py` module. (#36) + +### Bug fixes + +* + +### Deprecations + +* + +### Docs + +* + +### Other + +* diff --git a/recipe/meta.yaml b/recipe/meta.yaml index 042a77f..a468b46 100644 --- a/recipe/meta.yaml +++ b/recipe/meta.yaml @@ -1,8 +1,8 @@ -{% set conda_version = "23.10.0" %} -{% set conda_libmamba_solver_version = "23.11.0" %} +{% set conda_version = "23.11.0" %} +{% set conda_libmamba_solver_version = "23.11.1" %} {% set libmambapy_version = "1.5.3" %} {% set constructor_version = "3.5.0" %} -{% set python_version = "3.9.15" %} +{% set python_version = "3.10.13" %} {% set pyver = "".join(python_version.split(".")[:2]) %} package: @@ -11,8 +11,9 @@ package: source: - path: ../ + - url: https://github.com/conda/conda/archive/{{ conda_version }}.tar.gz - sha256: 273d49db8ea723426b87866381daf2df27c7bca2c3474196460d637b62de2a1c + sha256: 9276686c8a6ee536dc451cc6557685724fe275a44949ac4f741066fd23cdc7b4 folder: conda_src patches: - ../src/conda_patches/0001-Rename-and-replace-entrypoint-stub-exe.patch @@ -20,7 +21,7 @@ source: - ../src/conda_patches/0003-Return-unknown-module-in-deprecations.patch - url: https://github.com/conda/constructor/archive/{{ constructor_version }}.tar.gz # [win] - sha256: 787ffd85e9414bdf70fe531f01eab3987a040e3f6a6ac3a01409f4d332f7de9e # [win] + sha256: 928ddd32942093a89563ef2eb3abb116be17795cf277545ea9f6e9a0a4ba7a17 # [win] folder: constructor_src # [win] build: @@ -35,7 +36,7 @@ requirements: - python ={{ python_version }} - conda ={{ conda_version }} - conda-package-handling >=1.6 - - menuinst >=1.4.18,<2.0a0 # [win] + - menuinst >=2.0.1 - conda-libmamba-solver ={{ conda_libmamba_solver_version }} - libmambapy ={{ libmambapy_version }} run_constrained: @@ -44,6 +45,7 @@ requirements: test: requires: - pytest + - menuinst >=2 source_files: - tests commands: diff --git a/src/conda.exe.spec b/src/conda.exe.spec index 06f9714..f2168c8 100644 --- a/src/conda.exe.spec +++ b/src/conda.exe.spec @@ -14,7 +14,7 @@ else: block_cipher = None sitepackages = os.environ.get( - "SP_DIR", # site-packages in conda-build's host environment + "SP_DIR", # site-packages in conda-build's host environment # if not defined, get running Python's site-packages # Windows puts sys.prefix in this list first next( @@ -23,8 +23,15 @@ sitepackages = os.environ.get( ) ) - extra_exe_kwargs = {} +# Non imported files need to be added manually via datas or binaries: +# Datas are not analyzed, just copied over. Binaries go through some +# linkage analysis to also bring necessary libs. This includes plain +# text files like JSON, modules never imported, or standalone binaries +# Shared objects and DLLs should have been caught by pyinstaller import hooks, +# but if not, add them. +# Format: a list of tuples like (file-path, target-DIRECTORY) +binaries = [] datas = [ (os.path.join(sitepackages, 'archspec', 'json', 'COPYRIGHT'), 'archspec/json'), (os.path.join(sitepackages, 'archspec', 'json', 'NOTICE'), 'archspec/json'), @@ -32,18 +39,26 @@ datas = [ (os.path.join(sitepackages, 'archspec', 'json', 'LICENSE-MIT'), 'archspec/json'), (os.path.join(sitepackages, 'archspec', 'json', 'cpu', 'microarchitectures.json'), 'archspec/json/cpu'), (os.path.join(sitepackages, 'archspec', 'json', 'cpu', 'microarchitectures_schema.json'), 'archspec/json/cpu'), + (os.path.join(sitepackages, 'menuinst', 'data', 'menuinst.default.json'), 'menuinst/data'), + (os.path.join(sitepackages, 'menuinst', 'data', 'menuinst.schema.json'), 'menuinst/data'), ] if sys.platform == "win32": datas += [ (os.path.join(os.getcwd(), 'constructor_src', 'constructor', 'nsis', '_nsis.py'), 'Lib'), - (os.path.join(os.getcwd(), 'entry_point_base.exe'), '.')] - + (os.path.join(os.getcwd(), 'entry_point_base.exe'), '.'), + ] elif sys.platform == "darwin": + datas += [ + (os.path.join(sitepackages, 'menuinst', 'data', 'osx_launcher_arm64'), 'menuinst/data'), + (os.path.join(sitepackages, 'menuinst', 'data', 'osx_launcher_x86_64'), 'menuinst/data'), + (os.path.join(sitepackages, 'menuinst', 'data', 'appkit_launcher_arm64'), 'menuinst/data'), + (os.path.join(sitepackages, 'menuinst', 'data', 'appkit_launcher_x86_64'), 'menuinst/data'), + ] extra_exe_kwargs["entitlements_file"] = os.path.join(HERE, "entitlements.plist") a = Analysis(['entry_point.py', 'imports.py'], pathex=['.'], - binaries=[], + binaries=binaries, datas=datas, hiddenimports=['pkg_resources.py2_warn'], hookspath=[], diff --git a/src/entry_point.py b/src/entry_point.py index 89c729b..24e3597 100755 --- a/src/entry_point.py +++ b/src/entry_point.py @@ -9,6 +9,7 @@ import os import sys from multiprocessing import freeze_support +from pathlib import Path def _create_dummy_executor(*args, **kwargs): @@ -169,23 +170,12 @@ def _constructor_extract_tarball(): def _constructor_menuinst(prefix, pkg_names=None, root_prefix=None, remove=False): - import importlib.util - - root_prefix = root_prefix or prefix - - utility_script = os.path.join(root_prefix, "Lib", "_nsis.py") - spec = importlib.util.spec_from_file_location("constructor_utils", utility_script) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - if remove: - module.rm_menus(prefix=prefix, root_prefix=prefix) - elif pkg_names is not None: - module.mk_menus( - remove=False, - prefix=prefix, - pkg_names=pkg_names, - root_prefix=prefix, - ) + from menuinst import install + + for json_path in Path(prefix, "Menu").glob("*.json"): + if pkg_names and json_path.stem not in pkg_names: + continue + install(str(json_path), remove=remove, prefix=prefix, root_prefix=root_prefix) def _constructor_subcommand(): @@ -196,9 +186,6 @@ def _constructor_subcommand(): - extract conda packages - extract the tarball payload contained in the shell installers - invoke menuinst to create and remove menu items on Windows - - It is supported by a module included in `constructor`, `_nsis.py`, which is placed - in `$INSTDIR\Lib\_nsis.py` on Windows installations. """ args, _ = _constructor_parse_cli() os.chdir(args.prefix) @@ -212,10 +199,6 @@ def _constructor_subcommand(): # when called with --make-menus and no package names, the value is an empty list # hence the explicit check for None elif (args.make_menus is not None) or args.rm_menus: - if sys.platform != "win32": - raise NotImplementedError( - "Menu creation and removal is only supported on Windows" - ) _constructor_menuinst( prefix=args.prefix, pkg_names=args.make_menus, @@ -292,7 +275,7 @@ def _conda_main(): from conda.cli import main _fix_sys_path() - main() + return main() def main(): diff --git a/src/imports.py b/src/imports.py index 7ea0e2f..a0ac74c 100644 --- a/src/imports.py +++ b/src/imports.py @@ -14,7 +14,15 @@ "conda_libmamba_solver", "libmambapy", ] -site_packages = os.getenv("SP_DIR", site.getsitepackages()[0]) +site_packages = os.environ.get( + "SP_DIR", # site-packages in conda-build's host environment + # if not defined, get running Python's site-packages + # Windows puts sys.prefix in this list first + next( + path for path in site.getsitepackages() + if path.endswith("site-packages") + ) +) files = [ f for package in packages @@ -263,7 +271,28 @@ import libmambapy.__init__ import libmambapy._version import libmambapy.bindings +import menuinst.__init__ + +# import menuinst._schema +import menuinst.api +import menuinst.platforms.__init__ +import menuinst.platforms.base +import menuinst.platforms.linux +import menuinst.platforms.osx +import menuinst.utils +if os.name == "nt": + import menuinst._legacy.__init__ + import menuinst._legacy.cwp + import menuinst._legacy.main + import menuinst._legacy.utils + import menuinst._legacy.win32 + import menuinst.platforms.win + import menuinst.platforms.win_utils.__init__ + import menuinst.platforms.win_utils.knownfolders + import menuinst.platforms.win_utils.registry + import menuinst.platforms.win_utils.win_elevate + import menuinst.platforms.win_utils.winshortcut try: import conda_env.__main__ except Exception: diff --git a/tests/requirements.txt b/tests/requirements.txt index e079f8a..5be11a5 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -1 +1,2 @@ pytest +menuinst>=2 diff --git a/tests/test_main.py b/tests/test_main.py index 8e53682..352ab38 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -6,6 +6,8 @@ import pytest +# TIP: You can debug the tests with this setup: +# CONDA_STANDALONE=src/entry_point.py pytest ... CONDA_EXE = os.environ.get( "CONDA_STANDALONE", os.path.join(sys.prefix, "standalone_conda", "conda.exe"), @@ -13,8 +15,31 @@ HERE = Path(__file__).parent -def run_conda(*args, **kwargs): - return subprocess.run([CONDA_EXE, *args], **kwargs) +def run_conda(*args, **kwargs) -> subprocess.CompletedProcess: + check = kwargs.pop("check", False) + process = subprocess.run([CONDA_EXE, *args], **kwargs) + if check: + if kwargs.get("capture_output") and process.returncode: + print(process.stdout) + print(process.stderr, file=sys.stderr) + process.check_returncode() + return process + + +def _get_shortcut_dirs(): + if sys.platform == "win32": + from menuinst.platforms.win_utils.knownfolders import dirs_src as win_locations + + return Path(win_locations["user"]["start"][0]), Path( + win_locations["system"]["start"][0] + ) + if sys.platform == "darwin": + return Path(os.environ["HOME"], "Applications"), Path("/Applications") + if sys.platform == "linux": + return Path(os.environ["HOME"], ".local", "share", "applications"), Path( + "/usr/share/applications" + ) + raise NotImplementedError(sys.platform) @pytest.mark.parametrize("solver", ["classic", "libmamba"]) @@ -56,11 +81,143 @@ def test_extract_conda_pkgs_num_processors(tmp_path: Path): ) +_pkg_specs = [ + ( + "conda-test/label/menuinst-tests::package_1", + { + "win32": "Package 1/A.lnk", + "darwin": "A.app/Contents/MacOS/a", + "linux": "package-1_a.desktop", + }, + ), +] +if os.name == "nt": + _pkg_specs.append( + ( + "conda-forge::miniforge_console_shortcut", + {"win32": "{base}/{base} Prompt ({name}).lnk"}, + ), + ) +_pkg_specs_params = pytest.mark.parametrize("pkg_spec, shortcut_path", _pkg_specs) + + +@_pkg_specs_params +def test_menuinst_conda(tmp_path: Path, pkg_spec: str, shortcut_path: str): + "Check 'regular' conda can process menuinst JSONs" + env = os.environ.copy() + env["CONDA_ROOT_PREFIX"] = sys.prefix + # The shortcut will take 'root_prefix' as the base, but conda-standalone + # sets that to its temporary 'sys.prefix' as provided by the pyinstaller + # self-extraction. We override it via 'CONDA_ROOT_PREFIX' in the same + # way 'constructor' will do it. + variables = {"base": Path(sys.prefix).name, "name": tmp_path.name} + process = run_conda( + "create", + "-vvv", + "-p", + tmp_path, + "-y", + pkg_spec, + "--no-deps", + env=env, + capture_output=True, + text=True, + check=True, + ) + print(process.stdout) + print(process.stderr, file=sys.stderr) + assert "menuinst Exception" not in process.stdout + assert list(tmp_path.glob("Menu/*.json")) + assert any( + (folder / shortcut_path[sys.platform].format(**variables)).is_file() + for folder in _get_shortcut_dirs() + ) + process = run_conda( + "remove", + "-vvv", + "-p", + tmp_path, + "-y", + pkg_spec.split("::")[-1], + env=env, + capture_output=True, + text=True, + check=True, + ) + print(process.stdout) + print(process.stderr, file=sys.stderr) + assert all( + not (folder / shortcut_path[sys.platform].format(**variables)).is_file() + for folder in _get_shortcut_dirs() + ) + + +@_pkg_specs_params +def test_menuinst_constructor(tmp_path: Path, pkg_spec: str, shortcut_path: str): + "The constructor helper should also be able to process menuinst JSONs" + run_kwargs = dict(capture_output=True, text=True, check=True) + variables = {"base": Path(sys.prefix).name, "name": tmp_path.name} + process = run_conda( + "create", + "-vvv", + "-p", + tmp_path, + "-y", + pkg_spec, + "--no-deps", + "--no-shortcuts", + **run_kwargs, + ) + print(process.stdout) + print(process.stderr, file=sys.stderr) + assert list(tmp_path.glob("Menu/*.json")) + + env = os.environ.copy() + env["CONDA_ROOT_PREFIX"] = sys.prefix + process = run_conda( + "constructor", + # Not supported in micromamba's interface yet + # use CONDA_ROOT_PREFIX instead + # "--root-prefix", + # sys.prefix, + "--prefix", + tmp_path, + "--make-menus", + **run_kwargs, + env=env, + ) + print(process.stdout) + print(process.stderr, file=sys.stderr) + assert any( + (folder / shortcut_path[sys.platform].format(**variables)).is_file() + for folder in _get_shortcut_dirs() + ) + + process = run_conda( + "constructor", + # Not supported in micromamba's interface yet + # use CONDA_ROOT_PREFIX instead + # "--root-prefix", + # sys.prefix, + "--prefix", + tmp_path, + "--rm-menus", + **run_kwargs, + env=env, + ) + print(process.stdout) + print(process.stderr, file=sys.stderr) + assert all( + not (folder / shortcut_path[sys.platform].format(**variables)).is_file() + for folder in _get_shortcut_dirs() + ) + + def test_python(): - p = run_conda("python", "-V", check=True, capture_output=True, text=True) - assert p.stdout.startswith("Python 3.") + process = run_conda("python", "-V", check=True, capture_output=True, text=True) + assert process.stdout.startswith("Python 3.") - p = run_conda( + process = run_conda( "python", "-m", "calendar", @@ -70,9 +227,9 @@ def test_python(): capture_output=True, text=True, ) - assert "2023" in p.stdout + assert "2023" in process.stdout - p = run_conda( + process = run_conda( "python", "-c", "import sys; print(sys.argv)", @@ -81,4 +238,4 @@ def test_python(): capture_output=True, text=True, ) - assert eval(p.stdout) == ["-c", "extra-arg"] + assert eval(process.stdout) == ["-c", "extra-arg"]