diff --git a/CHANGELOG b/CHANGELOG index 0fe38fe..0fcf0a7 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,12 @@ +v 1.0.0 [05-nov-2023] + - Thanks to https://git.cuates.net/AmourSpirit zaz-pip now work in: + * Mac Os + * Windows + * Linux, sudo installed LibreOffice + * Linux, Snap installed LibreOffice + * Linux, Flatpak installed LibreOffice + * Linux, AppImage LibreOffice + v 0.10.2 [22-apr-2022] - Fix - issue #10 diff --git a/README.md b/README.md index 7bc6a24..65250f3 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,11 @@ SWIFT / BIC: TRWIBEB1XXX * [Mira la wiki](https://git.cuates.net/elmau/zaz-pip/wiki/home_es) +Thanks for code: + +* @AmourSpirit + + Thanks for translations: * English - @LibreOfficiant diff --git a/VERSION b/VERSION index 42624f3..afaf360 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.10.2 \ No newline at end of file +1.0.0 \ No newline at end of file diff --git a/conf.py b/conf.py index 78f0c2b..baef879 100644 --- a/conf.py +++ b/conf.py @@ -26,7 +26,7 @@ import logging TYPE_EXTENSION = 1 # ~ https://semver.org/ -VERSION = '0.10.2' +VERSION = '1.0.0' # ~ Your great extension name, not used spaces NAME = 'ZAZPip' diff --git a/extension/ZAZPip_v0.10.2.oxt b/extension/ZAZPip_v0.10.2.oxt deleted file mode 100644 index 897da46..0000000 Binary files a/extension/ZAZPip_v0.10.2.oxt and /dev/null differ diff --git a/extension/ZAZPip_v1.0.0.oxt b/extension/ZAZPip_v1.0.0.oxt new file mode 100644 index 0000000..560102c Binary files /dev/null and b/extension/ZAZPip_v1.0.0.oxt differ diff --git a/source/META-INF/manifest.xml b/source/META-INF/manifest.xml index 29ceecf..3fcb2d4 100644 --- a/source/META-INF/manifest.xml +++ b/source/META-INF/manifest.xml @@ -1,6 +1,8 @@ + + diff --git a/source/description.xml b/source/description.xml index c87fc3a..87c4097 100644 --- a/source/description.xml +++ b/source/description.xml @@ -1,7 +1,7 @@ - + diff --git a/source/job.xcu b/source/job.xcu new file mode 100644 index 0000000..7228846 --- /dev/null +++ b/source/job.xcu @@ -0,0 +1,20 @@ + + + + + + + net.elmau.zaz.pip.PipRunner + + + + + + + + + + + \ No newline at end of file diff --git a/source/pip_runner.py b/source/pip_runner.py new file mode 100644 index 0000000..c7c253e --- /dev/null +++ b/source/pip_runner.py @@ -0,0 +1,57 @@ +# region Imports +from __future__ import unicode_literals, annotations +import os +import sys +from typing import TYPE_CHECKING, Tuple +from pathlib import Path +import uno +import unohelper +from com.sun.star.task import XJob + +if TYPE_CHECKING: + # just for design time + from com.sun.star.beans import NamedValue + +# endregion Imports + +# region Constants + +implementation_name = "net.elmau.zaz.pip.PipRunner" +implementation_services = ("com.sun.star.task.Job",) +# endregion Constants + + + +# region XJob +class PipRunner(unohelper.Base, XJob): + + def __init__(self, ctx): + self._is_flatpak = bool(os.getenv("FLATPAK_ID", "")) + if self._is_flatpak: + site_packages = self.get_flatpak_site_packages_dir() + ss = str(site_packages) + if site_packages.exists() and ss not in sys.path: + sys.path.append(ss) + + def execute(self, *args: Tuple[NamedValue, ...]) -> None: + pass + + def get_flatpak_site_packages_dir(self) -> Path: + # should never be all users + sand_box = os.getenv("FLATPAK_SANDBOX_DIR", "") or str( + Path.home() / ".var/app/org.libreoffice.LibreOffice/sandbox" + ) + major_minor = f"{sys.version_info.major}.{sys.version_info.minor}" + site_packages = Path(sand_box) / f"lib/python{major_minor}/site-packages" + site_packages.mkdir(parents=True, exist_ok=True) + return site_packages + +# endregion XJob + +# region Implementation + +g_TypeTable = {} +g_ImplementationHelper = unohelper.ImplementationHelper() + +g_ImplementationHelper.addImplementation(PipRunner, implementation_name, implementation_services) +# endregion Implementation \ No newline at end of file diff --git a/source/pythonpath/easymacro.py b/source/pythonpath/easymacro.py index cbb26a0..14fadd8 100644 --- a/source/pythonpath/easymacro.py +++ b/source/pythonpath/easymacro.py @@ -31,6 +31,7 @@ import logging import os import platform import re +import site import shlex import shutil import socket @@ -167,6 +168,9 @@ OBJ_GRAPHIC = 'SwXTextGraphicObject' OBJ_TEXTS = 'SwXTextRanges' OBJ_TEXT = 'SwXTextRange' +IS_FLATPAK = bool(os.getenv("FLATPAK_ID", "")) +IS_APP_IMAGE = bool(os.getenv("APPIMAGE", "")) + # ~ from com.sun.star.sheet.FilterOperator import EMPTY, NO_EMPTY, EQUAL, NOT_EQUAL @@ -595,13 +599,24 @@ def call_macro(args, in_thread=False): result = _call_macro(args) return result - +def get_env(): + """ + Gets Environment used for subprocess. + """ + my_env = os.environ.copy() + py_path = "" + p_sep = ";" if IS_WIN else ":" + for d in sys.path: + py_path = py_path + d + p_sep + my_env["PYTHONPATH"] = py_path + return my_env + def run(command, capture=False, split=True): if not split: - return subprocess.check_output(command, shell=True).decode() + return subprocess.check_output(command, shell=True, env=get_env()).decode() cmd = shlex.split(command) - result = subprocess.run(cmd, capture_output=capture, text=True, shell=IS_WIN) + result = subprocess.run(cmd, capture_output=capture, text=True, shell=IS_WIN, env=get_env()) if capture: result = result.stdout else: @@ -612,7 +627,7 @@ def run(command, capture=False, split=True): def popen(command): try: proc = subprocess.Popen(shlex.split(command), shell=IS_WIN, - stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=get_env()) for line in proc.stdout: yield line.decode().rstrip() except Exception as e: @@ -625,6 +640,60 @@ def sleep(seconds): return + +def get_site_packages_dir() -> str: + """Gets the site-packages folder for the current user.""" + major_minor = f"{sys.version_info.major}.{sys.version_info.minor}" + + def get_windows_site_packages_dir() -> str: + nonlocal major_minor + if site.USER_SITE: + site_packages = Path(site.USER_SITE).resolve() + else: + site_packages = ( + Path.home() / f"'/AppData/Roaming/Python/Python{major_minor}/site-packages'" + ) + site_packages.mkdir(parents=True, exist_ok=True) + return str(site_packages) + + def get_flatpak_site_packages_dir() -> str: + # should never be all users + nonlocal major_minor + sand_box = os.getenv("FLATPAK_SANDBOX_DIR", "") or str( + Path.home() / ".var/app/org.libreoffice.LibreOffice/sandbox" + ) + site_packages = Path(sand_box) / f"lib/python{major_minor}/site-packages" + site_packages.mkdir(parents=True, exist_ok=True) + return str(site_packages) + + def get_mac_site_packages_dir() -> str: + nonlocal major_minor + if site.USER_SITE: + site_packages = Path(site.USER_SITE).resolve() + else: + site_packages = ( + Path.home() / f"Library/LibreOfficePython/{major_minor}/lib/python/site-packages" + ) + site_packages.mkdir(parents=True, exist_ok=True) + return str(site_packages) + + def get_default_site_packages_dir() -> str: + nonlocal major_minor + if site.USER_SITE: + site_packages = Path(site.USER_SITE).resolve() + else: + site_packages = Path.home() / f".local/lib/python{major_minor}/site-packages" + site_packages.mkdir(parents=True, exist_ok=True) + return str(site_packages) + + if IS_WIN: + return get_windows_site_packages_dir() + if IS_MAC: + return get_mac_site_packages_dir() + if IS_FLATPAK: + return get_flatpak_site_packages_dir() + return get_default_site_packages_dir() + class TimerThread(threading.Thread): def __init__(self, event, seconds, macro): @@ -4146,6 +4215,7 @@ class EventsFocus(EventsListenerBase, XFocusListener): if service in self.CONTROLS: obj = event.Source.Model obj.BackgroundColor = COLOR_ON_FOCUS + obj.TextColor = TEXT_COLOR_ON_FOCUS return def focusLost(self, event): @@ -6184,7 +6254,10 @@ class Paths(object): elif IS_MAC: path = self.join(self.config('Module'), '..', 'Resources', PYTHON) else: - path = sys.executable + if IS_APP_IMAGE: + path = self.join(self.config("Module"), PYTHON) + else: + path = sys.executable return path @classmethod @@ -6306,7 +6379,7 @@ class Paths(object): if IS_WIN: os.startfile(path) else: - pid = subprocess.Popen(['xdg-open', path]).pid + pid = subprocess.Popen(['xdg-open', path, ], env=get_env()).pid return @classmethod @@ -6839,6 +6912,7 @@ def get_color(value): COLOR_ON_FOCUS = get_color('LightYellow') +TEXT_COLOR_ON_FOCUS = get_color('black') class LOServer(object): @@ -6890,7 +6964,7 @@ class LOServer(object): for i in range(3): self._server = subprocess.Popen(self.CMD, - stdout=subprocess.PIPE, stderr=subprocess.PIPE) + stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=get_env()) time.sleep(3) if self.is_running: break diff --git a/source/pythonpath/install_flatpak.py b/source/pythonpath/install_flatpak.py new file mode 100644 index 0000000..5f98199 --- /dev/null +++ b/source/pythonpath/install_flatpak.py @@ -0,0 +1,214 @@ +from __future__ import annotations +from typing import cast +import os +import sys +import tempfile +import subprocess +from pathlib import Path +from typing import Any, List, Dict +import easymacro as app +from contextlib import contextmanager +import uno + + +@contextmanager +def change_dir(directory): + """ + A context manager that changes the current working directory to the specified directory + temporarily and then changes it back when the context is exited. + """ + current_dir = os.getcwd() + os.chdir(directory) + try: + yield + finally: + os.chdir(current_dir) + + +class InstallPipFromWheel: + """Download and install PIP from wheel url""" + + def __init__(self, pip_wheel_url: str, lo_identifier: str) -> None: + self._pip_url = pip_wheel_url + self._lo_identifier = lo_identifier + + def install(self, dst: str | Path = "") -> None: + """ + Install pip from wheel file. + + Downloads the pip wheel file from the url provided in the config file and unzips it to the destination directory. + + Args: + dst (str | Path, Optional): The destination directory where the pip wheel file will be installed. If not provided, the ``pythonpath`` location will be used. + + Returns: + None: + + Raises: + None: + """ + if not self._pip_url: + app.error("PIP installation has failed - No wheel url") + return + + if not dst: + root_pth = Path(app.Paths.from_id(self._lo_identifier)) + dst = root_pth / "pythonpath" + + with tempfile.TemporaryDirectory() as temp_dir: + # temp_dir = tempfile.gettempdir() + path_pip = Path(temp_dir) + + filename = path_pip / "pip-wheel.whl" + + data, _, err = app.url_open(self._pip_url, verify=False) + if err: + app.error("Unable to download PIP installation wheel file") + return + filename.write_bytes(data) + + if filename.exists(): + app.info("PIP wheel file has been saved") + else: + app.error("PIP wheel file has not been saved") + return + + try: + self._unzip_wheel(filename=filename, dst=dst) + except Exception: + return + # now that pip has been installed from wheel force a reinstall to ensure it is the latest version + self._force_install_pip() + + def _unzip_wheel(self, filename: Path, dst: str | Path) -> None: + """Unzip the downloaded wheel file""" + if isinstance(dst, str): + dst = Path(dst) + try: + # app.zip.unzip(source=str(filename), target=str(dst)) + self.unzip_file(zip_file=filename, dest_dir=dst) + if dst.exists(): + app.debug(f"PIP wheel file has been unzipped to {dst}") + else: + app.error("PIP wheel file has not been unzipped") + raise Exception("PIP wheel file has not been unzipped") + except Exception as err: + app.error(f"Unable to unzip wheel file: {err}", exc_info=True) + raise + + def _force_install_pip(self) -> None: + """Now that pip has been installed, force reinstall it to ensure it is the latest version""" + cmd = [app.paths.python, "-m", "pip", "install", "--upgrade", "pip"] + app.popen(command=" ".join(cmd)) + + def unzip_file(self, zip_file: str | Path, dest_dir: str | Path = "") -> None: + """ + Unzip the given zip file to the specified destination directory. + + Args: + zip_file (str | Path): The zip file to unzip. + dest_dir (str | Path, optional): The destination directory to unzip to. + + Returns: + None: + """ + from zipfile import ZipFile + + zip_file_path = Path(zip_file) if isinstance(zip_file, str) else zip_file + if not zip_file_path.is_file(): + raise ValueError(f"Expected file, got '{zip_file_path}'") + if not zip_file_path.is_absolute(): + zip_file_path = zip_file_path.absolute() + if not zip_file_path.exists(): + raise FileNotFoundError(f"File '{zip_file_path}' not found") + + if isinstance(dest_dir, str): + dest_dir = zip_file_path.parent if dest_dir == "" else Path(dest_dir) + else: + dest_dir = dest_dir.absolute() + + if not dest_dir.is_dir(): + raise ValueError(f"Expected folder, got '{dest_dir}'") + if not dest_dir.exists(): + try: + dest_dir.mkdir(parents=True) + except Exception as e: + raise FileNotFoundError( + f"Folder '{dest_dir}' not found, unable to create folder." + ) from e + if not dest_dir.exists(): + raise FileNotFoundError(f"Folder '{dest_dir}' not found") + + with change_dir(dest_dir): + with ZipFile(zip_file_path) as f: + f.extractall(dest_dir) + # with change_dir(dest_dir): + # shutil.unpack_archive(zip_file_path, dest_dir) + + +class FlatpakInstaller: + """class for the PIP install.""" + + def __init__(self, pip_wheel_url: str, lo_identifier: str) -> None: + self.path_python = app.Paths.python + app.debug(f"Python path: {self.path_python}") + self._pip_url = pip_wheel_url + self._lo_identifier = lo_identifier + self._site_packages = cast(str, app.get_flatpak_site_packages_dir()) + + def install_pip(self) -> None: + if self.is_pip_installed(): + app.info("PIP is already installed") + return + if self._install_wheel(): + if self.is_pip_installed(): + app.info("PIP was installed successfully") + else: + app.error("PIP installation has failed") + + return + + def _get_pip_cmd(self, filename: Path) -> List[str]: + return [str(self.path_python), f"{filename}", "--user"] + + def _get_env(self) -> Dict[str, str]: + """ + Gets Environment used for subprocess. + """ + my_env = os.environ.copy() + py_path = "" + p_sep = ";" if app.IS_WIN else ":" + for d in sys.path: + py_path = py_path + d + p_sep + my_env["PYTHONPATH"] = py_path + return my_env + + def _cmd_pip(self, *args: str) -> List[str]: + cmd: List[str] = [str(self.path_python), "-m", "pip", *args] + return cmd + + def _install_wheel(self) -> bool: + result = False + + installer = InstallPipFromWheel( + pip_wheel_url=self._pip_url, lo_identifier=self._lo_identifier + ) + try: + installer.install(self._site_packages) + if self._site_packages not in sys.path: + sys.path.append(self._site_packages) + result = True + except Exception as err: + app.error(err) + return result + return result + + def is_pip_installed(self) -> bool: + """Check if PIP is installed.""" + # cmd = self._cmd_pip("--version") + # cmd = '"{}" -m pip -V'.format(self.path_python) + cmd = [str(self.path_python), "-m", "pip", "-V"] + result = subprocess.run( + cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=self._get_env() + ) + return result.returncode == 0 diff --git a/source/pythonpath/link_cpython.py b/source/pythonpath/link_cpython.py new file mode 100644 index 0000000..e9d2237 --- /dev/null +++ b/source/pythonpath/link_cpython.py @@ -0,0 +1,146 @@ +""" +On some systems such as Mac and AppImage (Linux) the python extension suffix does not match the +cpython suffix used by the embedded python interpreter. + +This class creates symlinks for all .so files in dest folder that match the current python embedded suffix. + +For example a file named ``indexers.cpython-38-x86_64-linux-gnu.so`` would be symlinked to ``indexers.cpython-3.8.so``. +This renaming allows the python interpreter to find the import. +""" +from __future__ import annotations +from typing import List +from pathlib import Path +from importlib import machinery +import easymacro as app + + +class LinkCPython: + def __init__(self, pth: str) -> None: + """ + Constructor + + Args: + pth (str): Path to site-packages folder. + """ + self._current_suffix = self._get_current_suffix() + app.debug("CPythonLink.__init__") + self._link_root = Path(pth) + if not self._link_root.exists(): + raise FileNotFoundError(f"Path does not exist {self._link_root}") + app.debug("CPythonLink.__init__ done") + + def _get_current_suffix(self) -> str: + """Gets suffix currently used by the embedded python interpreter such as ``cpython-3.8``""" + for suffix in machinery.EXTENSION_SUFFIXES: + if suffix.startswith(".cpython-") and suffix.endswith(".so"): + # remove leading . and trailing .so + return suffix[1:][:-3] + return "" + + def _get_all_files(self, path: Path) -> List[Path]: + return [p for p in path.glob(f"**/*{self.file_suffix}.so") if p.is_file()] + + def _get_all_links(self, path: Path) -> List[Path]: + return [p for p in path.glob(f"**/*{self.current_suffix}.so") if p.is_symlink()] + + def _create_symlink(self, src: Path, dst: Path, overwrite: bool) -> None: + if dst.is_symlink(): + if overwrite: + app.debug(f"Removing existing symlink {dst}") + dst.unlink() + else: + app.debug(f"Symlink already exists {dst}") + return + dst.symlink_to(src) + app.debug(f"Created symlink {dst} -> {src}") + + def _find_current_installed_suffix(self, path: Path) -> str: + """ + Finds the current suffix from the current installed python so files such as ``cpython-38-x86_64-linux-gnu``. + + Args: + path (Path): Path to search in. Usually site-packages. + + Returns: + str: suffix if found, otherwise empty string. + """ + return next( + (str(p).rsplit(".", 2)[1] for p in path.glob("**/*.cpython-*.so") if not p.is_symlink()), + "", + ) + + def link(self, overwrite:bool = False) -> None: + """ + Creates symlinks for all .so files in site-packages that match the current suffix. + + Args: + overwrite (bool, optional): Override any existing sys links. Defaults to False. + """ + app.debug("CPythonLink.link starting") + if not self._link_root: + app.debug("No site-packages found") + return + if not self.file_suffix: + app.debug("No current file suffix found") + return + if not self._link_root.exists(): + app.debug(f"Site-packages does not exist {self._link_root}") + return + app.debug(f"Python current suffix: {self._current_suffix}") + app.debug(f"Found file suffix: {self.file_suffix}") + files = self._get_all_files(self._link_root) + if not files: + app.debug(f"No files found in {self._link_root}") + return + cp_old = self.file_suffix + cp_new = self._current_suffix + if cp_old == cp_new: + app.debug(f"Suffixes match, no need to link: {cp_old} == {cp_new}") + return + + for file in files: + ln_name = file.name.replace(cp_old, cp_new) + src = file + if not src.is_absolute(): + src = file.resolve() + dst = src.parent / ln_name + self._create_symlink(src, dst, overwrite) + app.debug("CPythonLink.link done") + + def unlink(self) -> None: + """Unlinks all broken sys links""" + links = self._get_all_links(self._link_root) + if not links: + app.debug(f"No links found in {self._link_root}") + return + for link in links: + if not link.exists(): + app.debug(f"Removing broken symlink {link}") + link.unlink() + + + # region Properties + @property + def cpy_name(self) -> str: + """Gets/Sets CPython name, e.g. cpython-3.8""" + return self._current_suffix + + @cpy_name.setter + def cpy_name(self, value: str) -> None: + self._current_suffix = value + + @property + def current_suffix(self) -> str: + """Current Suffix such as ``cpython-3.8``""" + return self._current_suffix + + @property + def file_suffix(self) -> str: + """Current Suffix such as ``cpython-38-x86_64-linux-gnu``""" + try: + return self._file_suffix + except AttributeError: + self._file_suffix = self._find_current_installed_suffix(self._link_root) + return self._file_suffix + + # endregion Properties diff --git a/source/pythonpath/main.py b/source/pythonpath/main.py index fc8a41a..46fa86a 100644 --- a/source/pythonpath/main.py +++ b/source/pythonpath/main.py @@ -10,6 +10,7 @@ _ = None TITLE = 'ZAZ-PIP' URL_PIP = 'https://bootstrap.pypa.io/get-pip.py' +URL_PIP_WHEEL = 'https://files.pythonhosted.org/packages/47/6a/453160888fab7c6a432a6e25f8afe6256d0d9f2cbd25971021da6491d899/pip-23.3.1-py3-none-any.whl' URL_TEST = 'http://duckduckgo.com' PIP = 'pip' URL_GIT = 'https://git.cuates.net/elmau/zaz-pip' @@ -85,8 +86,7 @@ class Controllers(object): self._install_pip() return - @app.run_in_thread - def _install_pip(self): + def _install_pip_normal(self) -> None: self.d.link_proyect.visible = False self.d.lst_log.visible = True path_pip = app.paths.tmp() @@ -119,7 +119,7 @@ class Controllers(object): self.d.lbl_pip.value = label self.d.cmd_install_pip.visible = False self.d.cmd_admin_pip.visible = True - msg = _('PIP was installed sucessfully') + msg = _('PIP was installed successfully') app.msgbox(msg) else: msg = _('PIP installation has failed, see log') @@ -129,6 +129,35 @@ class Controllers(object): return + def _install_pip_flatpak(self) -> None: + try: + from install_flatpak import FlatpakInstaller + installer = FlatpakInstaller(pip_wheel_url=URL_PIP_WHEEL, lo_identifier=ID_EXTENSION) + installer.install_pip() + msg = _('PIP was installed successfully') + # app.msgbox(msg) + cmd = self._cmd_pip('-V') + label = app.run(cmd, True) + if label: + self.d.lbl_pip.value = label + self.d.cmd_install_pip.visible = False + self.d.cmd_admin_pip.visible = True + msg = _('PIP was installed successfully') + app.msgbox(msg) + else: + msg = _('PIP installation has failed, see log') + app.warning(msg) + except Exception as e: + app.errorbox(e) + + @app.run_in_thread + def _install_pip(self): + if app.IS_FLATPAK: + self._install_pip_flatpak() + else: + self._install_pip_normal() + return + def _cmd_pip(self, args): cmd = '"{}" -m pip {}'.format(self.path_python, args) return cmd @@ -230,7 +259,10 @@ class Controllers(object): self.d.lst_log.visible = True line = '' - cmd = ' install --upgrade --user' + if app.IS_FLATPAK: + cmd = f' install --upgrade --target={app.get_site_packages_dir()}' + else: + cmd = ' install --upgrade --user' if value: name = value.split(' ')[0].strip() cmd = self._cmd_pip(f'{cmd} {name}') @@ -242,6 +274,31 @@ class Controllers(object): self.d.lst_log.insert(line, 'ok.png') else: self.d.lst_log.insert(line) + self._link_cpython() + return + + def _link_cpython(self): + if not app.IS_MAC and not app.IS_APP_IMAGE: + return + try: + app.debug("Linking CPython") + from link_cpython import LinkCPython + cpy_link = LinkCPython(pth=app.get_site_packages_dir()) + cpy_link.link() + except Exception as err: + app.error(err) + return + + def _unlink_cpython(self): + if not app.IS_MAC and not app.IS_APP_IMAGE: + return + try: + app.debug("Unlinking CPython") + from link_cpython import LinkCPython + cpy_link = LinkCPython(pth=app.get_site_packages_dir()) + cpy_link.unlink() + except Exception as err: + app.error(err) return def lst_package_double_click(self, event): @@ -272,6 +329,7 @@ class Controllers(object): self.d.lst_log.insert(line, 'ok.png') else: self.d.lst_log.insert(line) + self._unlink_cpython() return def cmd_uninstall_action(self, event):