First draft of new API plugin system

This commit is contained in:
Joel Collins 2019-12-13 13:48:50 +00:00
parent 3a63f7d200
commit 477abb6970
9 changed files with 234 additions and 49 deletions

View file

@ -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():
"""

View file

@ -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!")

View file

@ -0,0 +1 @@
from . import tasks, labthings

View file

@ -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

View file

@ -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

View file

@ -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: