Skip to content

Commit

Permalink
support.plugin: gracefully handle plugin load errors.
Browse files Browse the repository at this point in the history
This most notably improves the case where stale entry points metadata
refers to a nonexistent applet (e.g. because it was removed, or because
the applet was developed in a different branch of an editable install),
which before this commit entirely breaks the CLI. This commit catches
load errors and displays a nice error message in the UI.

This commit also improves help text for plugins with unmet requirements.
  • Loading branch information
whitequark committed Oct 24, 2023
1 parent 8bf6305 commit 4768c50
Show file tree
Hide file tree
Showing 2 changed files with 52 additions and 23 deletions.
6 changes: 3 additions & 3 deletions software/glasgow/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from . import __version__
from .support.logging import *
from .support.asignal import *
from .support.plugin import PluginRequirementsUnmet
from .support.plugin import PluginRequirementsUnmet, PluginLoadError
from .device import GlasgowDeviceError
from .device.config import GlasgowConfig
from .target.toolchain import ToolchainNotFound
Expand Down Expand Up @@ -138,7 +138,7 @@ def add_applet_arg(parser, mode, required=False):
subparsers = add_subparsers(parser, dest="applet", metavar="APPLET", required=required)

for handle, metadata in GlasgowAppletMetadata.all().items():
if not metadata.available:
if not metadata.loadable:
# fantastically cursed
p_applet = subparsers.add_parser(
handle, help=metadata.synopsis, description=metadata.description,
Expand Down Expand Up @@ -904,7 +904,7 @@ def startTest(test):
return 2

# Environment-related errors
except PluginRequirementsUnmet as e:
except (PluginRequirementsUnmet, PluginLoadError) as e:
logger.error(e)
print(e.metadata.description)
return 3
Expand Down
69 changes: 49 additions & 20 deletions software/glasgow/support/plugin.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import re
import sys
import traceback
import importlib.metadata
import packaging.requirements
import pathlib
import sysconfig
import textwrap


__all__ = ["PluginRequirementsUnmet", "PluginMetadata"]
__all__ = ["PluginRequirementsUnmet", "PluginLoadError", "PluginMetadata"]


# TODO(py3.10): remove
Expand Down Expand Up @@ -69,21 +69,31 @@ def _unmet_requirements_in(requirements):


def _install_command_for_requirements(requirements):
requirement_args = " ".join(f"'{r}'" for r in requirements)
if (pathlib.Path(sysconfig.get_path("data")) / "pipx_metadata.json").exists():
return f"pipx inject glasgow {' '.join(str(r) for r in requirements)}"
return f"pipx inject glasgow {requirement_args}"
if (pathlib.Path(sysconfig.get_path("data")) / "pyvenv.cfg").exists():
return f"pip install {' '.join(str(r) for r in requirements)}"
return f"pip install {requirement_args}"
else:
return f"pip install --user {' '.join(str(r) for r in requirements)}"
return f"pip install --user {requirement_args}"


class PluginRequirementsUnmet(Exception):
def __init__(self, metadata):
self.metadata = metadata

def __str__(self):
return (f"plugin {self.metadata.handle} has unmet requirements: "
f"{', '.join(str(r) for r in self.metadata.unmet_requirements)}")
return (f"{self.metadata.GROUP_NAME} plugin {self.metadata.handle!r} has unmet "
f"requirements: {', '.join(str(r) for r in self.metadata.unmet_requirements)}")


class PluginLoadError(Exception):
def __init__(self, metadata):
self.metadata = metadata

def __str__(self):
return (f"{self.metadata.GROUP_NAME} plugin {self.metadata.handle!r} raised an exception "
f"while being loaded")


class PluginMetadata:
Expand Down Expand Up @@ -115,19 +125,32 @@ def __init__(self, entry_point):

# Person-side metadata (how to display it, etc.)
self.handle = entry_point.name
if self.available:
self._cls = entry_point.load()
self.synopsis = self._cls.help
self.description = self._cls.description
if not self.unmet_requirements:
try:
self._cls = entry_point.load()
self.synopsis = self._cls.help
self.description = self._cls.description
except Exception as exn:
self._cls = None
# traceback.format_exception_only can return multiple lines
self.synopsis = (
f"/!\\ unavailable due to a load error: "
"".join(traceback.format_exception_only(exn)).splitlines()[0])
# traceback.format_exception can return lines with internal newlines
self.description = (
f"\nThis plugin is unavailable because attempting to load it has raised "
f"an exception. The exception is:\n\n " +
"".join(traceback.format_exception(exn)).replace("\n", "\n "))
else:
self.synopsis = (f"/!\\ unavailable due to unmet requirements: "
f"{', '.join(str(r) for r in self.unmet_requirements)}")
self.description = textwrap.dedent(f"""
This plugin is unavailable because it requires additional packages that are
not installed. To install them, run:
{_install_command_for_requirements(self.unmet_requirements)}
""")
self._cls = None
self.synopsis = (
f"/!\\ unavailable due to unmet requirements: "
f"{', '.join(str(r) for r in self.unmet_requirements)}")
self.description = (
f"\nThis plugin is unavailable because it requires additional packages to function "
f"that are not installed. To install them, run:\n\n " +
_install_command_for_requirements(self.unmet_requirements) +
f"\n")

@property
def unmet_requirements(self):
Expand All @@ -137,9 +160,15 @@ def unmet_requirements(self):
def available(self):
return not self.unmet_requirements

@property
def loadable(self):
return self._cls is not None

def load(self):
if not self.available:
if self.unmet_requirements:
raise PluginRequirementsUnmet(self)
if self._cls is None:
raise PluginLoadError(self)
return self._cls

def __repr__(self):
Expand Down

0 comments on commit 4768c50

Please sign in to comment.