From 477abb6970735c51bf880f881dbc37e8e0af065c Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Fri, 13 Dec 2019 13:48:50 +0000 Subject: [PATCH] First draft of new API plugin system --- openflexure_microscope/api/app.py | 77 +++++------- openflexure_microscope/api/microscope.py | 48 +++++++ openflexure_microscope/common/__init__.py | 1 + .../common/labthings/__init__.py | 37 ++++++ .../common/labthings/fields.py | 0 .../common/labthings/plugins.py | 119 ++++++++++++++++++ .../common/labthings/schema.py | 0 .../common/labthings/views.py | 0 openflexure_microscope/config.py | 1 + 9 files changed, 234 insertions(+), 49 deletions(-) create mode 100644 openflexure_microscope/api/microscope.py create mode 100644 openflexure_microscope/common/labthings/__init__.py create mode 100644 openflexure_microscope/common/labthings/fields.py create mode 100644 openflexure_microscope/common/labthings/plugins.py create mode 100644 openflexure_microscope/common/labthings/schema.py create mode 100644 openflexure_microscope/common/labthings/views.py diff --git a/openflexure_microscope/api/app.py b/openflexure_microscope/api/app.py index c3db5f89..c7d2a7b9 100644 --- a/openflexure_microscope/api/app.py +++ b/openflexure_microscope/api/app.py @@ -16,25 +16,18 @@ from flask_cors import CORS from openflexure_microscope.api.exceptions import JSONExceptionHandler from openflexure_microscope.api.utilities import list_routes -from openflexure_microscope import Microscope - -from openflexure_microscope.camera.capture import build_captures_from_exif -from openflexure_microscope.config import settings_file_path, JSONEncoder +from openflexure_microscope.config import ( + settings_file_path, + JSONEncoder, + USER_PLUGINS_PATH, +) from openflexure_microscope.api.v1 import blueprints from openflexure_microscope.api import v2 -# Import device modules -# NB this will eventually be handled by the RC file, so you can choose what device -# class should be attached. -try: - from openflexure_microscope.camera.pi import PiCameraStreamer -except ImportError: - logging.warning("Unable to import PiCameraStreamer") -from openflexure_microscope.camera.mock import MockStreamer - -from openflexure_microscope.stage.sanga import SangaStage -from openflexure_microscope.stage.mock import MockStage +from openflexure_microscope.common.labthings import LabThing +from openflexure_microscope.common.labthings.plugins import find_plugins +from openflexure_microscope.api.microscope import default_microscope as api_microscope # Handle logging is_gunicorn = "gunicorn" in os.environ.get("SERVER_SOFTWARE", "") @@ -54,7 +47,7 @@ else: ) rotating_logfile = logging.handlers.RotatingFileHandler( - DEFAULT_LOGFILE, maxBytes=1000000, backupCount=7 + DEFAULT_LOGFILE, maxBytes=1_000_000, backupCount=7 ) error_handlers = [rotating_logfile, logging.StreamHandler()] @@ -65,39 +58,6 @@ else: root.setLevel(logging.getLogger("gunicorn.error").level) -# Create a dummy microscope object, with no hardware attachments -api_microscope = Microscope() - -# Initialise camera -logging.debug("Creating camera object...") -try: - api_camera = PiCameraStreamer() -except Exception as e: - logging.error(e) - logging.warning("No valid camera hardware found. Falling back to mock camera!") - api_camera = MockStreamer() - -# Initialise stage -logging.debug("Creating stage object...") -try: - api_stage = SangaStage() -except Exception as e: - logging.error(e) - logging.warning("No valid stage hardware found. Falling back to mock stage!") - api_stage = MockStage() - -# Attach devices to microscope -logging.debug("Attaching devices to microscope...") -api_microscope.attach(api_camera, api_stage) - -# Restore loaded capture array to camera object -logging.debug("Restoring captures...") -api_microscope.camera.images = build_captures_from_exif( - api_microscope.camera.paths["default"] -) - -logging.debug("Microscope successfully attached!") - # Generate API URI based on version from filename def uri(suffix, api_version, base=None): @@ -121,6 +81,10 @@ CORS(app, resources=r"*") # Make errors more API friendly handler = JSONExceptionHandler(app) +# Attach lab devices +labthing = LabThing(app) +labthing.register_device(api_microscope, "openflexure_microscope") + # WEBAPP ROUTES # Base routes @@ -176,6 +140,21 @@ v2_actions_blueprint = v2.blueprints.actions.construct_blueprint(api_microscope) app.register_blueprint(v2_actions_blueprint, url_prefix=uri("/actions", "v2")) +plugins = find_plugins(USER_PLUGINS_PATH) +print(plugins.__plugins__) + +for plugin_obj in plugins.__plugins__: + for plugin_view_id, plugin_view in plugin_obj.views.items(): + # Add route to the plugins blueprint + app.add_url_rule( + "/new-plugins" + plugin_view["rule"], + view_func=plugin_view["view"].as_view( + f"{plugin_obj._name_python_safe}_{plugin_view_id}" + ), + **plugin_view["kwargs"], + ) + + @app.route("/routes") def routes(): """ diff --git a/openflexure_microscope/api/microscope.py b/openflexure_microscope/api/microscope.py new file mode 100644 index 00000000..c950b83d --- /dev/null +++ b/openflexure_microscope/api/microscope.py @@ -0,0 +1,48 @@ +from openflexure_microscope import Microscope +from openflexure_microscope.camera.capture import build_captures_from_exif + +import logging + +# Import device modules +# NB this will eventually be handled by the RC file, so you can choose what device +# class should be attached. +try: + from openflexure_microscope.camera.pi import PiCameraStreamer +except ImportError: + logging.warning("Unable to import PiCameraStreamer") +from openflexure_microscope.camera.mock import MockStreamer + +from openflexure_microscope.stage.sanga import SangaStage +from openflexure_microscope.stage.mock import MockStage + +default_microscope = Microscope() + +# Initialise camera +logging.debug("Creating camera object...") +try: + api_camera = PiCameraStreamer() +except Exception as e: + logging.error(e) + logging.warning("No valid camera hardware found. Falling back to mock camera!") + api_camera = MockStreamer() + +# Initialise stage +logging.debug("Creating stage object...") +try: + api_stage = SangaStage() +except Exception as e: + logging.error(e) + logging.warning("No valid stage hardware found. Falling back to mock stage!") + api_stage = MockStage() + +# Attach devices to microscope +logging.debug("Attaching devices to microscope...") +default_microscope.attach(api_camera, api_stage) + +# Restore loaded capture array to camera object +logging.debug("Restoring captures...") +default_microscope.camera.images = build_captures_from_exif( + default_microscope.camera.paths["default"] +) + +logging.debug("Microscope successfully attached!") diff --git a/openflexure_microscope/common/__init__.py b/openflexure_microscope/common/__init__.py index e69de29b..80899ff0 100644 --- a/openflexure_microscope/common/__init__.py +++ b/openflexure_microscope/common/__init__.py @@ -0,0 +1 @@ +from . import tasks, labthings diff --git a/openflexure_microscope/common/labthings/__init__.py b/openflexure_microscope/common/labthings/__init__.py new file mode 100644 index 00000000..0ed5c46d --- /dev/null +++ b/openflexure_microscope/common/labthings/__init__.py @@ -0,0 +1,37 @@ +from flask import current_app, _app_ctx_stack + +EXTENSION_NAME = "flask-lab" + + +class LabThing(object): + def __init__(self, app=None): + self.app = app + self.devices = {} + if app is not None: + self.init_app(app) + + def init_app(self, app): + app.teardown_appcontext(self.teardown) + + app.extensions = getattr(app, "extensions", {}) + app.extensions[EXTENSION_NAME] = self + + def teardown(self, exception): + print(f"Tearing down devices: {self.devices}") + + def register_device(self, device_object, device_name: str): + self.devices[device_name] = device_object + + +def find_device(device_name): + app = current_app + + if not app: + return None + + pylot_instance = app.extensions[EXTENSION_NAME] + + if device_name in pylot_instance.devices: + return pylot_instance.devices[device_name] + else: + return None diff --git a/openflexure_microscope/common/labthings/fields.py b/openflexure_microscope/common/labthings/fields.py new file mode 100644 index 00000000..e69de29b diff --git a/openflexure_microscope/common/labthings/plugins.py b/openflexure_microscope/common/labthings/plugins.py new file mode 100644 index 00000000..3926abbf --- /dev/null +++ b/openflexure_microscope/common/labthings/plugins.py @@ -0,0 +1,119 @@ +import logging +import collections +import copy + +from importlib import util +import sys + +from openflexure_microscope.config import USER_CONFIG_DIR +from openflexure_microscope.utilities import camel_to_snake, camel_to_spine + + +class BasePlugin: + """ + Parent class for all plugins. + + Handles binding route views and forms. + """ + + def __init__(self): + self._views = ( + {} + ) # Key: Full, Python-safe ID. Val: Original rule, and view class + self._rules = {} # Key: Original rule. Val: View class + self._gui = None + + @property + def views(self): + return self._views + + def add_view(self, rule, view_class, **kwargs): + # Remove all leading slashes from view route + cleaned_rule = rule + while cleaned_rule[0] == "/": + cleaned_rule = cleaned_rule[1:] + + # Expand the rule to include plugin name + full_rule = "/{}/{}".format(self._name_uri_safe, cleaned_rule) + + view_id = cleaned_rule.replace("/", "_").replace("<", "").replace(">", "") + + # Create a Python-safe route ID + logging.debug(view_id) + + # Store route information in a dictionary + d = {"rule": full_rule, "view": view_class, "kwargs": kwargs} + + # Add view to private views dictionary + self._views[view_id] = d + # Store the rule expansion information + self._rules[rule] = self._views[view_id] + + @property + def gui(self): + print(self._gui) + # Handle missing/no GUI + if not self._gui: + return None + # Handle GUI as a dictionary-returning callable function + elif isinstance(self._gui, collections.Callable): + api_gui = self._gui() + # Handle GUI as a static dictionary + elif isinstance(self._gui, dict): + api_gui = copy.deepcopy(self._gui) + # Handle borked GUI + else: + raise ValueError( + "GUI must be a dictionary, or a dictionary-returning function with no arguments." + ) + + api_gui["id"] = self._name + + if "forms" in api_gui and isinstance(api_gui["forms"], list): + for form in api_gui["forms"]: + if "route" in form and form["route"] in self._rules.keys(): + form["route"] = self._rules[form["route"]]["rule"] + else: + logging.warn( + "No valid expandable route found for {}".format(form["route"]) + ) + return api_gui + + def set_gui(self, form_dictionary: dict): + self._gui = form_dictionary + + @property + def _name(self): + return self.__class__.__name__ + + @property + def _name_python_safe(self): + return camel_to_snake(self._name) + + @property + def _name_uri_safe(self): + return camel_to_spine(self._name) + + def _full_name(self): + module = self.__class__.__module__ + if module is None or module == str.__class__.__module__: + return self.__class__.__name__ # Avoid reporting __builtin__ + else: + return module + "." + self.__class__.__name__ + + +def find_plugins(plugin_path, module_name="plugins"): + print(f"Loading plugins from {plugin_path}") + # plugin_path = os.path.join(USER_CONFIG_DIR, "microscope_plugins") + # plugins = importlib.machinery.SourceFileLoader( + # module_name, plugin_path + # ).exec_module() + logging.debug(f"Loading plugins from {plugin_path}") + + spec = util.spec_from_file_location(module_name, plugin_path) + mod = util.module_from_spec(spec) + sys.modules[spec.name] = mod + + spec.loader.exec_module(mod) + + return mod diff --git a/openflexure_microscope/common/labthings/schema.py b/openflexure_microscope/common/labthings/schema.py new file mode 100644 index 00000000..e69de29b diff --git a/openflexure_microscope/common/labthings/views.py b/openflexure_microscope/common/labthings/views.py new file mode 100644 index 00000000..e69de29b diff --git a/openflexure_microscope/config.py b/openflexure_microscope/config.py index 0c6e56a3..cf4c990a 100644 --- a/openflexure_microscope/config.py +++ b/openflexure_microscope/config.py @@ -255,6 +255,7 @@ DEFAULT_CONFIG_FILE_PATH = os.path.join(HERE, "microscope_settings.default.json" USER_CONFIG_DIR = os.path.join(os.path.expanduser("~"), ".openflexure") USER_CONFIG_FILE_PATH = os.path.join(USER_CONFIG_DIR, "microscope_settings.json") +USER_PLUGINS_PATH = os.path.join(USER_CONFIG_DIR, "microscope_plugins", "__init__.py") # Load the default config with open(DEFAULT_CONFIG_FILE_PATH, "r") as default_rc: