diff --git a/pyempaq/unpacker.py b/pyempaq/unpacker.py index 8effc79..b0f9ea3 100644 --- a/pyempaq/unpacker.py +++ b/pyempaq/unpacker.py @@ -35,6 +35,9 @@ # so it's easily patchable by tests MAGIC_NUMBER = importlib.util.MAGIC_NUMBER[:-2].hex() +# the environment variable to specify a different action +ACTION_ENVVAR = "PYEMPAQ_ACTION" + # setup logging logger = logging.getLogger() handler = logging.StreamHandler() @@ -58,12 +61,49 @@ class ReturnCode(enum.IntEnum): restrictions_not_met = 64 unpack_basedir_missing = 65 unpack_basedir_notdir = 66 + bad_action = 67 def __init__(self, code): self.returncode = code super().__init__("") +# -- special PyEmpaq actions + +def special_action_info(pyempaq_dir, metadata): + """Provide information about different installations for the project.""" + print("Base PyEmpaq directory:", pyempaq_dir) + subdirs = sorted(pyempaq_dir.glob(f"{metadata['project_name']}-*")) + if not subdirs: + print("No installation found!") + return + + print("Current installations:") + for subdir in subdirs: + print(" ", subdir.name) + + +def special_action_uninstall(pyempaq_dir, metadata): + """Remove all installations for the given project.""" + subdirs = list(pyempaq_dir.glob(f"{metadata['project_name']}-*")) + if not subdirs: + print("No installation found!") + return + + print("Removing installation:") + for subdir in subdirs: + print(" ", subdir.name) + shutil.rmtree(subdir) + + +ACTION_CHOICES = { + "info": special_action_info, + "uninstall": special_action_uninstall, +} + +# -- + + def get_python_exec(project_dir: pathlib.Path) -> pathlib.Path: """Return the Python exec to use. @@ -265,6 +305,19 @@ def run(): pyempaq_dir = get_base_dir(platformdirs) logger.info("Base directory: %r", str(pyempaq_dir)) + indicated_action = os.environ.get(ACTION_ENVVAR) + if indicated_action is not None: + indicated_action = indicated_action.lower() + print(f"Running {indicated_action!r} action") + try: + func = ACTION_CHOICES[indicated_action] + except KeyError: + logger.error( + "Bad specified action %s=%r; valid options: %s", + ACTION_ENVVAR, indicated_action, ", ".join(ACTION_CHOICES.keys())) + raise FatalError(FatalError.ReturnCode.bad_action) + return func(pyempaq_dir, metadata) + # create a temp dir and extract the project there project_dir = pyempaq_dir / build_project_install_dir(pyempaq_filepath, metadata) original_project_dir = project_dir / "orig" diff --git a/tests/test_integration.py b/tests/test_integration.py index 295be7a..551a3a3 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -5,6 +5,7 @@ """Integration tests.""" import os +import shutil import subprocess import sys import textwrap @@ -12,7 +13,7 @@ import pytest import yaml -from pyempaq.unpacker import FatalError +from pyempaq.unpacker import FatalError, ACTION_ENVVAR, get_file_hexdigest def _pack(tmp_path, monkeypatch, config_text): @@ -40,8 +41,9 @@ def _unpack(packed_filepath, basedir, *, extra_env=None, expected_rc=None): """ # run the packed file in a clean directory cleandir = basedir / "cleandir" - cleandir.mkdir() - new_path = packed_filepath.rename(cleandir / "testproject.pyz") + cleandir.mkdir(exist_ok=True) + new_path = cleandir / "testproject.pyz" + shutil.copy(packed_filepath, new_path) os.chdir(cleandir) # set the install basedir so the user real one is not used, and then any extra @@ -276,3 +278,73 @@ def test_using_entrypoint(tmp_path, monkeypatch): assert proc.returncode == 0 assert proc.stdout.strip() == "crazyarg" + + +# -- check special actions + + +def test_action_info(tmp_path, monkeypatch): + """Use the special action 'info'.""" + # set up a basic test project, with an entrypoint that shows access to internals + projectpath = tmp_path / "fakeproject" + entrypoint = projectpath / "ep.py" + entrypoint.parent.mkdir() + entrypoint.write_text("print(42)") + + packed_filepath = _pack(tmp_path, monkeypatch, f""" + name: testproject + basedir: {projectpath} + exec: + script: ep.py + """) + + # unpack it once so it's already installed + proc, run_path = _unpack(packed_filepath, tmp_path, expected_rc=0) + assert proc.returncode == 0 + + # run the action + extra_env = {ACTION_ENVVAR: "info"} + proc, run_path = _unpack(packed_filepath, tmp_path, expected_rc=0, extra_env=extra_env) + assert proc.returncode == 0 + + hexdigest = get_file_hexdigest(packed_filepath) + assert proc.stdout == textwrap.dedent(f"""\ + Running 'info' action + Base PyEmpaq directory: {tmp_path} + Current installations: + testproject-{hexdigest[:20]}-cpython.3.10.6f0d + """) + + +def test_action_uninstall(tmp_path, monkeypatch): + """Use the special action 'uninstall'.""" + # set up a basic test project, with an entrypoint that shows access to internals + projectpath = tmp_path / "fakeproject" + entrypoint = projectpath / "ep.py" + entrypoint.parent.mkdir() + entrypoint.write_text("print(42)") + + packed_filepath = _pack(tmp_path, monkeypatch, f""" + name: testproject + basedir: {projectpath} + exec: + script: ep.py + """) + + # unpack it once so it's already installed + proc, run_path = _unpack(packed_filepath, tmp_path, expected_rc=0) + assert proc.returncode == 0 + assert len(list(tmp_path.glob("testproject-*"))) == 1 + + # run the action + extra_env = {ACTION_ENVVAR: "uninstall"} + proc, run_path = _unpack(packed_filepath, tmp_path, expected_rc=0, extra_env=extra_env) + assert proc.returncode == 0 + assert len(list(tmp_path.glob("testproject-*"))) == 0 + + hexdigest = get_file_hexdigest(packed_filepath) + assert proc.stdout == textwrap.dedent(f"""\ + Running 'uninstall' action + Removing installation: + testproject-{hexdigest[:20]}-cpython.3.10.6f0d + """) diff --git a/tests/test_unpacker.py b/tests/test_unpacker.py index 42f4f90..985724f 100644 --- a/tests/test_unpacker.py +++ b/tests/test_unpacker.py @@ -8,6 +8,7 @@ import json import os import platform +import textwrap import time import zipfile from pathlib import Path @@ -28,6 +29,8 @@ get_base_dir, run_command, setup_project_directory, + special_action_info, + special_action_uninstall, ) @@ -399,3 +402,60 @@ def test_installdirname_custombase_not_dir(monkeypatch, tmp_path): with pytest.raises(FatalError) as cm: get_base_dir(platformdirs) assert cm.value.returncode is FatalError.ReturnCode.unpack_basedir_notdir + + +# --- tests for the special actions + + +def test_specialaction_info_simple(tmp_path, capsys): + """A couple of installs to show.""" + (tmp_path / "testproj-whatever-123").mkdir() + (tmp_path / "testproj-another one").mkdir() + special_action_info(tmp_path, {"project_name": "testproj"}) + + out, _ = capsys.readouterr() + assert out == textwrap.dedent(f"""\ + Base PyEmpaq directory: {tmp_path} + Current installations: + testproj-another one + testproj-whatever-123 + """) + + +def test_specialaction_info_nothing(tmp_path, capsys): + """No install to show information.""" + special_action_info(tmp_path, {"project_name": "testproj"}) + + out, _ = capsys.readouterr() + assert out == textwrap.dedent(f"""\ + Base PyEmpaq directory: {tmp_path} + No installation found! + """) + + +def test_specialaction_uninstall_simple(tmp_path, capsys): + """Some installs to remove.""" + inst1 = tmp_path / "testproj-whatever-123" + inst1.mkdir() + inst2 = tmp_path / "testproj-another one" + inst2.mkdir() + special_action_uninstall(tmp_path, {"project_name": "testproj"}) + + out, _ = capsys.readouterr() + assert out == textwrap.dedent("""\ + Removing installation: + testproj-another one + testproj-whatever-123 + """) + assert not inst1.exists() + assert not inst2.exists() + + +def test_specialaction_uninstall_nothing(tmp_path, capsys): + """No install to remove.""" + special_action_uninstall(tmp_path, {"project_name": "testproj"}) + + out, _ = capsys.readouterr() + assert out == textwrap.dedent("""\ + No installation found! + """)