From c995a15fdca64c924714658da8c0fdbdaf37ebb2 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Thu, 6 Dec 2018 15:37:51 +0000 Subject: [PATCH 01/25] Restructured entire API into blueprints --- openflexure_microscope/api/app.py | 243 +++--- openflexure_microscope/api/utilities.py | 9 +- openflexure_microscope/api/v0.py | 172 ----- openflexure_microscope/api/v1.py | 695 ------------------ openflexure_microscope/api/v1/__init__.py | 0 .../api/v1/blueprints/__init__.py | 1 + .../api/v1/blueprints/base.py | 92 +++ .../api/v1/blueprints/camera/__init__.py | 34 + .../api/v1/blueprints/camera/capture.py | 289 ++++++++ .../api/v1/blueprints/camera/preview.py | 38 + .../api/v1/blueprints/camera/record.py | 0 .../api/v1/blueprints/stage.py | 177 +++++ openflexure_microscope/api/v1/views.py | 13 + 13 files changed, 739 insertions(+), 1024 deletions(-) delete mode 100644 openflexure_microscope/api/v0.py delete mode 100644 openflexure_microscope/api/v1.py create mode 100644 openflexure_microscope/api/v1/__init__.py create mode 100644 openflexure_microscope/api/v1/blueprints/__init__.py create mode 100644 openflexure_microscope/api/v1/blueprints/base.py create mode 100644 openflexure_microscope/api/v1/blueprints/camera/__init__.py create mode 100644 openflexure_microscope/api/v1/blueprints/camera/capture.py create mode 100644 openflexure_microscope/api/v1/blueprints/camera/preview.py create mode 100644 openflexure_microscope/api/v1/blueprints/camera/record.py create mode 100644 openflexure_microscope/api/v1/blueprints/stage.py create mode 100644 openflexure_microscope/api/v1/views.py diff --git a/openflexure_microscope/api/app.py b/openflexure_microscope/api/app.py index 4a109823..1ae28f16 100644 --- a/openflexure_microscope/api/app.py +++ b/openflexure_microscope/api/app.py @@ -1,187 +1,118 @@ #!/usr/bin/env python +""" +TODO: Implement API route to cleanly shut down server +TODO: Implement plugin API routes somehow +""" -from pprint import pprint +import numpy as np from importlib import import_module import time import datetime +import os from flask import ( - Flask, render_template, Response, - redirect, request, jsonify, send_file) + Flask, render_template, Response, url_for, + redirect, request, jsonify, send_file, abort, + make_response) -import numpy as np +from flask.views import MethodView +from werkzeug.exceptions import default_exceptions +from openflexure_microscope.api.utilities import parse_payload, get_from_payload, gen, get_bool, list_routes + +from openflexure_microscope import Microscope, config from openflexure_microscope.camera.pi import StreamingCamera +from openflexure_stage import OpenFlexureStage +import atexit +import logging, sys + +logging.basicConfig(stream=sys.stderr, level=logging.DEBUG) + +# Create a dummy microscope object, with no hardware attachments +api_microscope = Microscope(None, None) +logging.debug("Created an empty microscope in global.") + + +# Generate API URI based on version from filename +def uri(suffix, api_version, base=None): + if not base: + base = "/api/{}".format(api_version) + uri = base + suffix + logging.debug("Created app route: {}".format(uri)) + return uri + +# Create flask app app = Flask(__name__) -cam = StreamingCamera() +app.url_map.strict_slashes = False -def parse_payload(request): - """Convert request to JSON. Will eventually handle error-checking.""" - # TODO: Handle invalid JSON payloads - state = request.get_json() - return state +# Make errors more API friendly + +def _handle_http_exception(e): + return make_response( + jsonify({ + 'status_code': e.code, + 'error': e.name, + 'details': e.description + }), + e.code) + +for code in default_exceptions: + app.errorhandler(code)(_handle_http_exception) -def gen(camera): - """Video streaming generator function.""" - while True: - # the obtained frame is a jpeg - frame = camera.get_frame() +# After app starts, but before first request, attach hardware to global microscope +@app.before_first_request +def attach_microscope(): + # Create the microscope object globally (common to all spawned server threads) + global api_microscope + logging.debug("First request made. Populating microscope with hardware...") + openflexurerc = config.load_config() # Load default user config - yield (b'--frame\r\n' - b'Content-Type: image/jpeg\r\n\r\n' + frame + b'\r\n') + api_microscope.attach( + StreamingCamera(config=openflexurerc), + OpenFlexureStage("/dev/ttyUSB0") + ) + logging.debug("Microscope successfully attached!") + + +##### WEBAPP ROUTES ###### @app.route('/') def index(): - """Video streaming home page.""" - cam.start_worker() # Start the stream - return render_template('index.html') - - -@app.route('/stream') -def stream(): - """Video streaming route. Put this in the src attribute of an img tag.""" - return Response( - gen(cam), - mimetype='multipart/x-mixed-replace; boundary=frame') - - -# TODO: Be able to change the image resolution with an option -@app.route('/capture/', methods=['GET', 'POST', 'PUT']) -def capture(): """ - POST/PUT: Capture a single image. - GET: Return capture status, or download latest capture. + API demo app """ - if request.method == 'POST' or request.method == 'PUT': - state = parse_payload(request) - print(state) + return render_template( + 'index_v1.html' + ) - # Handle filename argument - if 'filename' in state: - filename = state['filename'] - else: - filename = None +##### API ROUTES ###### +from openflexure_microscope.api.v1 import blueprints - if 'write_to_file' in state: - write_to_file = bool(state['write_to_file']) - else: - write_to_file = False +# Base routes +base_blueprint = blueprints.base.construct_blueprint(api_microscope) +app.register_blueprint(base_blueprint, url_prefix=uri('', 'v1')) - if 'use_video_port' in state: - use_video_port = bool(state['use_video_port']) - else: - use_video_port = False +# Stage routes +stage_blueprint = blueprints.stage.construct_blueprint(api_microscope) +app.register_blueprint(stage_blueprint, url_prefix=uri('/stage', 'v1')) - if 'resize' in state: - resize_h = int(state['resize']) - resize_w = int(resize_h*(4/3)) - resize = (resize_w, resize_h) - else: - resize = None +# Camera routes +camera_blueprint = blueprints.camera.construct_blueprint(api_microscope) +app.register_blueprint(camera_blueprint, url_prefix=uri('/camera', 'v1')) - response = cam.capture( - write_to_file=write_to_file, - use_video_port=use_video_port, - filename=filename, - resize=resize) +# List all routes +list_routes(app) - return str(response) + "\n" +# Automatically clean up microscope at exit +def cleanup(): + global api_microscope + api_microscope.close() - else: # If GET request +atexit.register(cleanup) - download = request.args.get('download') - - state = cam.state - - if (( - download == 'true' or - download == 'True' or - download == '1') and - cam.image is not None): - - return send_file(cam.image.data, mimetype='image/jpeg') - else: - return jsonify(state) - - -@app.route('/record/', methods=['GET', 'POST', 'PUT']) -def record(): - """ - POST/PUT: Start or stop a video recording. - GET: Return recording status, or download latest recording - """ - if request.method == 'POST' or request.method == 'PUT': - state = request.get_json() - print(state) - - # Handle filename argument - if 'filename' in state: - filename = state['filename'] - else: - filename = None - - # Handle status argument - if 'status' in state: - status = bool(state['status']) - - # synchronise the arduino_time - if status is True: - response = cam.start_recording(filename=filename) - return str(response) - - elif status is False: - response = cam.stop_recording() - return str(response) - - else: - download = request.args.get('download') - - state = cam.state - - if (( - download == 'true' or - download == 'True' or - download == '1') and - cam.state['recent_video'] is not None): - - return send_file( - cam.state['recent_video'], - mimetype='video/H264', - as_attachment=True) - else: - return jsonify(state) - - -@app.route('/preview/', methods=['GET', 'POST', 'PUT']) -def preview(): - """ - POST/PUT: Start or stop the direct-to-GPU camera preview on the Pi. - - GET: Return preview status - """ - if request.method == 'POST' or request.method == 'PUT': - state = request.get_json() - print(state) - - if 'status' in state: - status = bool(state['status']) - - if status is False: - response = cam.stop_preview() - else: - response = cam.start_preview() - - return str(response) - - else: - # Get args passed via URL (not useful here. Just for reference.) - state = cam.state - return jsonify(state) - - -if __name__ == '__main__': - app.run(host='0.0.0.0', threaded=True, debug=True, use_reloader=False) +if __name__ == "__main__": + app.run(host='0.0.0.0', port="5000", threaded=True, debug=True, use_reloader=False) diff --git a/openflexure_microscope/api/utilities.py b/openflexure_microscope/api/utilities.py index f82a6f42..f5b8de98 100644 --- a/openflexure_microscope/api/utilities.py +++ b/openflexure_microscope/api/utilities.py @@ -1,3 +1,5 @@ +import pprint + def parse_payload(request): """Convert request to JSON. Will eventually handle error-checking.""" # TODO: Handle invalid JSON payloads @@ -31,4 +33,9 @@ def get_bool(get_arg): get_arg == '1'): return True else: - return False \ No newline at end of file + return False + + +def list_routes(app): + """Print available functions.""" + pprint.pprint(list(map(lambda x: repr(x), app.url_map.iter_rules()))) \ No newline at end of file diff --git a/openflexure_microscope/api/v0.py b/openflexure_microscope/api/v0.py deleted file mode 100644 index 5987a9c0..00000000 --- a/openflexure_microscope/api/v0.py +++ /dev/null @@ -1,172 +0,0 @@ -#!/usr/bin/env python - -from pprint import pprint -from importlib import import_module -import time -import datetime - -from flask import ( - Flask, render_template, Response, - redirect, request, jsonify, send_file) - -import numpy as np - -from openflexure_microscope.api.utilities import parse_payload, gen -from openflexure_microscope.camera.pi import StreamingCamera - -app = Flask(__name__) -cam = StreamingCamera() - - - -@app.route('/') -def index(): - """Video streaming home page.""" - cam.start_worker() # Start the stream - return render_template('index_v0.html') - - -@app.route('/stream') -def stream(): - """Video streaming route. Put this in the src attribute of an img tag.""" - return Response( - gen(cam), - mimetype='multipart/x-mixed-replace; boundary=frame') - - -# TODO: Be able to change the image resolution with an option -@app.route('/capture/', methods=['GET', 'POST', 'PUT']) -def capture(): - """ - POST/PUT: Capture a single image. - GET: Return capture status, or download latest capture. - """ - if request.method == 'POST' or request.method == 'PUT': - state = parse_payload(request) - print(state) - - # Handle filename argument - if 'filename' in state: - filename = state['filename'] - else: - filename = None - - if 'write_to_file' in state: - write_to_file = bool(state['write_to_file']) - else: - write_to_file = False - - if 'use_video_port' in state: - use_video_port = bool(state['use_video_port']) - else: - use_video_port = False - - if 'resize' in state: - resize_h = int(state['resize']) - resize_w = int(resize_h*(4/3)) - resize = (resize_w, resize_h) - else: - resize = None - - response = cam.capture( - write_to_file=write_to_file, - use_video_port=use_video_port, - filename=filename, - resize=resize) - - return str(response) + "\n" - - else: # If GET request - - download = request.args.get('download') - - state = cam.state - - if (( - download == 'true' or - download == 'True' or - download == '1') and - cam.image is not None): - - return send_file(cam.image.data, mimetype='image/jpeg') - else: - return jsonify(state) - - -@app.route('/record/', methods=['GET', 'POST', 'PUT']) -def record(): - """ - POST/PUT: Start or stop a video recording. - GET: Return recording status, or download latest recording - """ - if request.method == 'POST' or request.method == 'PUT': - state = request.get_json() - print(state) - - # Handle filename argument - if 'filename' in state: - filename = state['filename'] - else: - filename = None - - # Handle status argument - if 'status' in state: - status = bool(state['status']) - - # synchronise the arduino_time - if status is True: - response = cam.start_recording(filename=filename) - return str(response) - - elif status is False: - response = cam.stop_recording() - return str(response) - - else: - download = request.args.get('download') - - state = cam.state - - if (( - download == 'true' or - download == 'True' or - download == '1') and - cam.state['recent_video'] is not None): - - return send_file( - cam.state['recent_video'], - mimetype='video/H264', - as_attachment=True) - else: - return jsonify(state) - - -@app.route('/preview/', methods=['GET', 'POST', 'PUT']) -def preview(): - """ - POST/PUT: Start or stop the direct-to-GPU camera preview on the Pi. - - GET: Return preview status - """ - if request.method == 'POST' or request.method == 'PUT': - state = request.get_json() - print(state) - - if 'status' in state: - status = bool(state['status']) - - if status is False: - response = cam.stop_preview() - else: - response = cam.start_preview() - - return str(response) - - else: - # Get args passed via URL (not useful here. Just for reference.) - state = cam.state - return jsonify(state) - - -if __name__ == '__main__': - app.run(host='0.0.0.0', threaded=True, debug=True, use_reloader=False) diff --git a/openflexure_microscope/api/v1.py b/openflexure_microscope/api/v1.py deleted file mode 100644 index c403894a..00000000 --- a/openflexure_microscope/api/v1.py +++ /dev/null @@ -1,695 +0,0 @@ -#!/usr/bin/env python -""" -TODO: Add proper docstrings -TODO: Implement API route to cleanly shut down server -TODO: Implement microscope function API routes (autofocus etc) -""" - -import numpy as np -from importlib import import_module -import time -import datetime -import os - -from flask import ( - Flask, render_template, Response, url_for, - redirect, request, jsonify, send_file, abort, - make_response) - -from flask.views import MethodView -from werkzeug.exceptions import default_exceptions - -from openflexure_microscope.api.utilities import parse_payload, get_from_payload, gen, get_bool - -from openflexure_microscope import Microscope, config -from openflexure_microscope.camera.pi import StreamingCamera -from openflexure_stage import OpenFlexureStage - -import atexit -import logging, sys - -logging.basicConfig(stream=sys.stderr, level=logging.DEBUG) - -# Create a dummy microscope object, with no hardware attachments -api_microscope = Microscope(None, None) -logging.debug("Created an empty microscope in global.") - -# Generate API URI based on version from filename -def uri(suffix, base=None): - if not base: - api_ver = os.path.splitext(os.path.basename(__file__))[0] - base = "/api/{}".format(api_ver) - uri = base + suffix - logging.debug("Created app route: {}".format(uri)) - return uri - -# Create flask app -app = Flask(__name__) -app.url_map.strict_slashes = False - - -# Make errors more API friendly - -def _handle_http_exception(e): - return make_response( - jsonify({ - 'status_code': e.code, - 'error': e.name, - 'details': e.description - }), - e.code) - -for code in default_exceptions: - app.errorhandler(code)(_handle_http_exception) - - -# After app starts, but before first request, attach hardware to global microscope -@app.before_first_request -def attach_microscope(): - # Create the microscope object globally (common to all spawned server threads) - global api_microscope - logging.debug("First request made. Populating microscope with hardware...") - openflexurerc = config.load_config() # Load default user config - - api_microscope.attach( - StreamingCamera(config=openflexurerc), - OpenFlexureStage("/dev/ttyUSB0") - ) - - logging.debug("Microscope successfully attached!") - - -##### WEBAPP ROUTES ###### - -@app.route('/') -def index(): - """ - API demo app - """ - return render_template( - 'index_v1.html' - ) - -##### API ROUTES ###### - - -# Basic microscope view - -class MicroscopeView(MethodView): - - def __init__(self, microscope, **kwargs): - """ - Create a generic MethodView with a globally available - microscope object passed as an argument. - """ - self.microscope = microscope - - MethodView.__init__(self, **kwargs) - - -class StreamAPI(MicroscopeView): - - def get(self): - """ - Real-time MJPEG stream from the microscope camera - - .. :quickref: State; Camera stream - - :>header Accept: image/jpeg - :>header Content-Type: image/jpeg - :status 200: stream active - """ - # Restart stream worker thread - self.microscope.camera.start_worker() - - return Response( - gen(self.microscope.camera), - mimetype='multipart/x-mixed-replace; boundary=frame') - -app.add_url_rule( - uri('/stream/'), - view_func=StreamAPI.as_view('stream', microscope=api_microscope)) - - -class StateAPI(MicroscopeView): - - def get(self): - """ - JSON representation of the microscope object. - - .. :quickref: State; Microscope state - - **Example request**: - - .. sourcecode:: http - - GET /state/ HTTP/1.1 - Accept: application/json - - **Example response**: - - .. sourcecode:: http - - HTTP/1.1 200 OK - Vary: Accept - Content-Type: application/json - - { - "camera": { - "preview_active": false, - "record_active": false, - "stream_active": true - }, - "plugin": {}, - "stage": { - "backlash": { - "x": 128, - "y": 128, - "z": 128 - }, - "position": { - "x": -8080, - "y": 5665, - "z": -12600 - } - } - } - - :>header Accept: application/json - :>header Content-Type: application/json - :status 200: state available - """ - return jsonify(self.microscope.state) - -app.add_url_rule( - uri('/state/'), - view_func=StateAPI.as_view('state', microscope=api_microscope)) - - -class PositionAPI(MicroscopeView): - - def get(self): - """ - Return current x, y and z positions of the stage. - - .. :quickref: Position; Get current position - - **Example request**: - - .. sourcecode:: http - - GET /stage/position/ HTTP/1.1 - Accept: application/json - - **Example response**: - - .. sourcecode:: http - - HTTP/1.1 200 OK - Vary: Accept - Content-Type: application/json - - { - "x": 0, - "y": 0, - "z": 0 - } - - :>json int x: x steps - :>json int y: y steps - :>json int z: z steps - """ - return jsonify(self.microscope.state['stage']['position']) - - def post(self): - """ - Set x, y and z positions of the stage. - - .. :quickref: Position; Update current position - - :reqheader Accept: application/json - : travel_limit: - # Respond with 400 Bad Request - response = {'error': 'Cannot move to absolute position beyond the safeguard limit.'} - return jsonify(response), 400 - - self.microscope.stage.move_rel(position) - - return jsonify(self.microscope.state['stage']['position']) - -app.add_url_rule( - uri('/stage/position/'), - view_func=PositionAPI.as_view('position', microscope=api_microscope)) - - -class StageParamsAPI(MicroscopeView): - - def get(self): - """ - Return current parameters of the stage. - - .. :quickref: Stage params; Get current stage parameters - - **Example request**: - - .. sourcecode:: http - - GET /stage/params HTTP/1.1 - Accept: application/json - - **Example response**: - - .. sourcecode:: http - - HTTP/1.1 200 OK - Vary: Accept - Content-Type: application/json - - { - "backlash": { - "x": 0, - "y": 0, - "z": 128 - }, - } - - """ - - return jsonify(self.microscope.state['stage']) - - def post(self): - """ - Set parameters of the stage. - - .. :quickref: Stage params; Set current stage parameters - - :reqheader Accept: application/json - :header Accept: application/json - :query include_unavailable: return json representations of captures that have been completely deleted - - :>jsonarr boolean available: availability of capture data - :>jsonarr string filename: filename of capture - :>jsonarr string id: unique id of the capture object - :>jsonarr boolean keep_on_disk: keep the capture file on microscope after closing - :>jsonarr boolean locked: file locked for modifications (mostly used for video recording) - :>jsonarr string path: path on pi storage to the capture file, if available - :>jsonarr boolean stream: capture stored in-memory as a BytesIO stream - :>jsonarr json uri: - **download** *(string)*: api uri to the capture file download - - **metadata** *(string)*: api uri to the capture json representation - - :>header Content-Type: application/json - :status 200: capture found - :status 404: no capture found with that id - """ - include_unavailable = get_bool(request.args.get('include_unavailable')) - - if include_unavailable: - captures = [image.metadata for image in self.microscope.camera.images] - else: - captures = [image.metadata for image in self.microscope.camera.images if image.metadata['available']] - - return jsonify(captures) - - def delete(self): - """ - Delete all captures (not yet implemented) - - .. :quickref: Capture collection; Delete all captures - """ - for image in self.microscope.camera.images: - image.delete() - - captures = [image.metadata for image in self.microscope.camera.images] - - return jsonify(captures) - - def post(self): - """ - Create a new image capture. - - .. :quickref: Capture collection; New capture - - **Example request**: - - .. sourcecode:: http - - POST /camera/capture HTTP/1.1 - Accept: application/json - - { - "filename": "myfirstcapture", - "keep_on_disk": true, - "use_video_port": true, - "size": { - "x": 640, - "y": 480 - } - } - - :>header Accept: application/json - - :json boolean available: availability of capture data - :>json string filename: filename of capture - :>json string id: unique id of the capture object - :>json boolean keep_on_disk: keep the capture file on microscope after closing - :>json boolean locked: file locked for modifications (mostly used for video recording) - :>json string path: path on pi storage to the capture file, if available - :>json boolean stream: capture stored in-memory as a BytesIO stream - :>json json uri: - **download** *(string)*: api uri to the capture file download - - **metadata** *(string)*: api uri to the capture json representation - - :
json boolean available: availability of capture data - :>json string filename: filename of capture - :>json string id: unique id of the capture object - :>json boolean keep_on_disk: keep the capture file on microscope after closing - :>json boolean locked: file locked for modifications (mostly used for video recording) - :>json string path: path on pi storage to the capture file, if available - :>json boolean stream: capture stored in-memory as a BytesIO stream - :>json json uri: - **download** *(string)*: api uri to the capture file download - - **metadata** *(string)*: api uri to the capture json representation - - """ - capture_obj = self.microscope.camera.image_from_id(capture_id) - - if not capture_obj: - return abort(404) # 404 Not Found - - # Get capture metadata - capture_metadata = capture_obj.metadata - - # TODO: Tidy up adding URI to metadata - # Add API routes to returned metadata - uri_dict = { - 'uri': {'metadata': uri('/capture/{}/'.format(capture_id))} - } - - # If available, also add download link - if capture_metadata['available']: - uri_dict['uri']['download'] = uri('/capture/{}/download/{}'.format(capture_obj.id, capture_obj.filename)) - - capture_metadata.update(uri_dict) - - return jsonify(capture_metadata) - - def delete(self, capture_id): - """ - Delete all capture data from the Pi, even if `keep_on_disk=true;`. - - .. :quickref: Capture; Delete capture. - """ - capture_obj = self.microscope.camera.image_from_id(capture_id) - - if not capture_obj: - return abort(404) # 404 Not Found - - capture_obj.delete() - - return jsonify({"return": capture_id}) - - def put(self, capture_id): - """ - Modify the metadata of a capture (not yet implemented) - - .. :quickref: Capture; Update capture metadata - """ - return jsonify({"return": capture_id}) - -app.add_url_rule( - uri('/camera/capture//'), - view_func=CaptureAPI.as_view('capture', microscope=api_microscope)) - - -class CaptureDownloadRedirectAPI(MicroscopeView): - def get(self, capture_id): - """ - Redirect to download the capture under it's currently set filename. - I.e., `/(capture_id)/download` will - redirect to `/(capture_id)/download/(filename)`. - - Note: This route may be deprecated in the future. Where at all - possible, please include a filename to download as. - - .. :quickref: Capture; Redirect to download - """ - capture_obj = self.microscope.camera.image_from_id(capture_id) - - if not capture_obj or not capture_obj.metadata['available']: - return abort(404) # 404 Not Found - - as_attachment = get_bool(request.args.get('as_attachment')) - thumbnail = get_bool(request.args.get('thumbnail')) - - return redirect(url_for('capture_download', capture_id=capture_id, filename=capture_obj.filename, as_attachment=as_attachment, thumbnail=thumbnail), code=307) - -# Route for shortcut where no filename is specified -app.add_url_rule( - uri('/camera/capture//download'), - view_func=CaptureDownloadRedirectAPI.as_view('capture_download_redirect', microscope=api_microscope)) - - -class CaptureDownloadAPI(MicroscopeView): - def get(self, capture_id, filename): - """ - Return image data for a capture. - - Return capture data as an image file with the requested filename. - I.e., `/(capture_id)/download/foo.jpeg` will download the image as - `foo.jpeg`, regardless of the capture's initially set filename. - - .. :quickref: Capture; Download capture file - - **Example request**: - - .. sourcecode:: http - - GET /camera/capture/d0b2067abbb946f19351e075c5e7cd5b/download/2018-11-20_16-04-17.jpeg HTTP/1.1 - Accept: image/jpeg - - :>header Accept: image/jpeg - :query thumbnail: return an image thumbnail e.g. ?thumbnail=true - :query as_attachment: return the image as an attachment download e.g. ?as_attachment=true - - :>header Content-Type: image/jpeg - :status 200: capture data found - :status 404: no capture found with that id - """ - capture_obj = self.microscope.camera.image_from_id(capture_id) - - if not capture_obj or not capture_obj.metadata['available']: - return abort(404) # 404 Not Found - - as_attachment = get_bool(request.args.get('as_attachment')) - thumbnail = get_bool(request.args.get('thumbnail')) - - # If no filename is specified, redirect to the capture's currently set filename - if not filename: - return redirect(url_for('capture_download', capture_id=capture_id, filename=capture_obj.filename, as_attachment=as_attachment, thumbnail=thumbnail), code=307) - - # Download the image data using the requested filename - if thumbnail: - img = capture_obj.thumbnail - else: - img = capture_obj.data - - return send_file( - img, - mimetype='image/jpeg', - as_attachment=as_attachment, - attachment_filename=filename) - -app.add_url_rule( - uri('/camera/capture//download/'), - view_func=CaptureDownloadAPI.as_view('capture_download', microscope=api_microscope)) - - -# Preview -class GPUPreviewAPI(MicroscopeView): - - def post(self, operation): - """ - Create a new image capture. - - .. :quickref: GPU Preview; Start/stop preview - - **Example requests**: - - .. sourcecode:: http - - POST /camera/preview/start HTTP/1.1 - Accept: application/json - - .. sourcecode:: http - - POST /camera/preview/stop HTTP/1.1 - Accept: application/json - - :>header Accept: application/json - - :
'), - view_func=GPUPreviewAPI.as_view('gpu_preview', microscope=api_microscope)) - -# Automatically clean up microscope at exit -def cleanup(): - global api_microscope - api_microscope.close() - -atexit.register(cleanup) - -if __name__ == "__main__": - app.run(host='0.0.0.0', port="5000", threaded=True, debug=True, use_reloader=False) diff --git a/openflexure_microscope/api/v1/__init__.py b/openflexure_microscope/api/v1/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/openflexure_microscope/api/v1/blueprints/__init__.py b/openflexure_microscope/api/v1/blueprints/__init__.py new file mode 100644 index 00000000..5bee2fc7 --- /dev/null +++ b/openflexure_microscope/api/v1/blueprints/__init__.py @@ -0,0 +1 @@ +from . import camera, stage, base diff --git a/openflexure_microscope/api/v1/blueprints/base.py b/openflexure_microscope/api/v1/blueprints/base.py new file mode 100644 index 00000000..da4205c7 --- /dev/null +++ b/openflexure_microscope/api/v1/blueprints/base.py @@ -0,0 +1,92 @@ +from openflexure_microscope.api.utilities import parse_payload, get_from_payload, gen, get_bool +from openflexure_microscope.api.v1.views import MicroscopeView + +from flask import Response, Blueprint, jsonify + + +class StreamAPI(MicroscopeView): + + def get(self): + """ + Real-time MJPEG stream from the microscope camera + + .. :quickref: State; Camera stream + + :>header Accept: image/jpeg + :>header Content-Type: image/jpeg + :status 200: stream active + """ + # Restart stream worker thread + self.microscope.camera.start_worker() + + return Response( + gen(self.microscope.camera), + mimetype='multipart/x-mixed-replace; boundary=frame') + + +class StateAPI(MicroscopeView): + + def get(self): + """ + JSON representation of the microscope object. + + .. :quickref: State; Microscope state + + **Example request**: + + .. sourcecode:: http + + GET /state/ HTTP/1.1 + Accept: application/json + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "camera": { + "preview_active": false, + "record_active": false, + "stream_active": true + }, + "plugin": {}, + "stage": { + "backlash": { + "x": 128, + "y": 128, + "z": 128 + }, + "position": { + "x": -8080, + "y": 5665, + "z": -12600 + } + } + } + + :>header Accept: application/json + :>header Content-Type: application/json + :status 200: state available + """ + return jsonify(self.microscope.state) + + +def construct_blueprint(microscope_obj): + + blueprint = Blueprint('base_blueprint', __name__) + + blueprint.add_url_rule( + '/stream', + view_func=StreamAPI.as_view('stream', microscope=microscope_obj) + ) + + blueprint.add_url_rule( + '/state', + view_func=StateAPI.as_view('state', microscope=microscope_obj) + ) + + return(blueprint) diff --git a/openflexure_microscope/api/v1/blueprints/camera/__init__.py b/openflexure_microscope/api/v1/blueprints/camera/__init__.py new file mode 100644 index 00000000..c367c97c --- /dev/null +++ b/openflexure_microscope/api/v1/blueprints/camera/__init__.py @@ -0,0 +1,34 @@ +from openflexure_microscope.api.v1.views import MicroscopeView + +from flask import Blueprint + +from . import capture, record, preview + + +def construct_blueprint(microscope_obj): + + blueprint = Blueprint('camera_blueprint', __name__) + + # Capture routes + blueprint.add_url_rule( + '/capture//download/', + view_func=capture.DownloadAPI.as_view('capture_download', microscope=microscope_obj)) + + blueprint.add_url_rule( + '/capture//download', + view_func=capture.DownloadRedirectAPI.as_view('capture_download_redirect', microscope=microscope_obj)) + + blueprint.add_url_rule( + '/capture//', + view_func=capture.CaptureAPI.as_view('capture', microscope=microscope_obj)) + + blueprint.add_url_rule( + '/capture/', + view_func=capture.ListAPI.as_view('capture_list', microscope=microscope_obj)) + + # Preview routes + blueprint.add_url_rule( + '/preview/', + view_func=preview.GPUPreviewAPI.as_view('gpu_preview', microscope=microscope_obj)) + + return(blueprint) \ No newline at end of file diff --git a/openflexure_microscope/api/v1/blueprints/camera/capture.py b/openflexure_microscope/api/v1/blueprints/camera/capture.py new file mode 100644 index 00000000..5fe959cc --- /dev/null +++ b/openflexure_microscope/api/v1/blueprints/camera/capture.py @@ -0,0 +1,289 @@ +from openflexure_microscope.api.utilities import parse_payload, get_from_payload, gen, get_bool +from openflexure_microscope.api.v1.views import MicroscopeView + +from flask import Response, Blueprint, jsonify, request, abort, url_for, redirect, send_file + +import logging + + +class ListAPI(MicroscopeView): + + def get(self): + """ + Get list of image captures. + + .. :quickref: Capture collection; Get collection of captures + + :>header Accept: application/json + :query include_unavailable: return json representations of captures that have been completely deleted + + :>jsonarr boolean available: availability of capture data + :>jsonarr string filename: filename of capture + :>jsonarr string id: unique id of the capture object + :>jsonarr boolean keep_on_disk: keep the capture file on microscope after closing + :>jsonarr boolean locked: file locked for modifications (mostly used for video recording) + :>jsonarr string path: path on pi storage to the capture file, if available + :>jsonarr boolean stream: capture stored in-memory as a BytesIO stream + :>jsonarr json uri: - **download** *(string)*: api uri to the capture file download + - **metadata** *(string)*: api uri to the capture json representation + + :>header Content-Type: application/json + :status 200: capture found + :status 404: no capture found with that id + """ + include_unavailable = get_bool(request.args.get('include_unavailable')) + + if include_unavailable: + captures = [image.metadata for image in self.microscope.camera.images] + else: + captures = [image.metadata for image in self.microscope.camera.images if image.metadata['available']] + + return jsonify(captures) + + def delete(self): + """ + Delete all captures (not yet implemented) + + .. :quickref: Capture collection; Delete all captures + """ + for image in self.microscope.camera.images: + image.delete() + + captures = [image.metadata for image in self.microscope.camera.images] + + return jsonify(captures) + + def post(self): + """ + Create a new image capture. + + .. :quickref: Capture collection; New capture + + **Example request**: + + .. sourcecode:: http + + POST /camera/capture HTTP/1.1 + Accept: application/json + + { + "filename": "myfirstcapture", + "keep_on_disk": true, + "use_video_port": true, + "size": { + "x": 640, + "y": 480 + } + } + + :>header Accept: application/json + + :json boolean available: availability of capture data + :>json string filename: filename of capture + :>json string id: unique id of the capture object + :>json boolean keep_on_disk: keep the capture file on microscope after closing + :>json boolean locked: file locked for modifications (mostly used for video recording) + :>json string path: path on pi storage to the capture file, if available + :>json boolean stream: capture stored in-memory as a BytesIO stream + :>json json uri: - **download** *(string)*: api uri to the capture file download + - **metadata** *(string)*: api uri to the capture json representation + + :
json boolean available: availability of capture data + :>json string filename: filename of capture + :>json string id: unique id of the capture object + :>json boolean keep_on_disk: keep the capture file on microscope after closing + :>json boolean locked: file locked for modifications (mostly used for video recording) + :>json string path: path on pi storage to the capture file, if available + :>json boolean stream: capture stored in-memory as a BytesIO stream + :>json json uri: - **download** *(string)*: api uri to the capture file download + - **metadata** *(string)*: api uri to the capture json representation + + """ + capture_obj = self.microscope.camera.image_from_id(capture_id) + + if not capture_obj: + return abort(404) # 404 Not Found + + # Get capture metadata + capture_metadata = capture_obj.metadata + + # TODO: Tidy up adding URI to metadata + # Add API routes to returned metadata + uri_dict = { + 'uri': {'metadata': '{}/{}'.format(url_for(self), capture_id)} + } + + # If available, also add download link + if capture_metadata['available']: + uri_dict['uri']['download'] = '{}/{}/download/{}'.format(url_for(self), capture_obj.id, capture_obj.filename) + + capture_metadata.update(uri_dict) + + return jsonify(capture_metadata) + + def delete(self, capture_id): + """ + Delete all capture data from the Pi, even if `keep_on_disk=true;`. + + .. :quickref: Capture; Delete capture. + """ + capture_obj = self.microscope.camera.image_from_id(capture_id) + + if not capture_obj: + return abort(404) # 404 Not Found + + capture_obj.delete() + + return jsonify({"return": capture_id}) + + def put(self, capture_id): + """ + Modify the metadata of a capture (not yet implemented) + + .. :quickref: Capture; Update capture metadata + """ + return jsonify({"return": capture_id}) + + +class DownloadRedirectAPI(MicroscopeView): + def get(self, capture_id): + """ + Redirect to download the capture under it's currently set filename. + I.e., `/(capture_id)/download` will + redirect to `/(capture_id)/download/(filename)`. + + Note: This route may be deprecated in the future. Where at all + possible, please include a filename to download as. + + .. :quickref: Capture; Redirect to download + """ + capture_obj = self.microscope.camera.image_from_id(capture_id) + + if not capture_obj or not capture_obj.metadata['available']: + return abort(404) # 404 Not Found + + as_attachment = get_bool(request.args.get('as_attachment')) + thumbnail = get_bool(request.args.get('thumbnail')) + + return redirect(url_for('.capture_download', capture_id=capture_id, filename=capture_obj.filename, as_attachment=as_attachment, thumbnail=thumbnail), code=307) + + +class DownloadAPI(MicroscopeView): + def get(self, capture_id, filename): + """ + Return image data for a capture. + + Return capture data as an image file with the requested filename. + I.e., `/(capture_id)/download/foo.jpeg` will download the image as + `foo.jpeg`, regardless of the capture's initially set filename. + + .. :quickref: Capture; Download capture file + + **Example request**: + + .. sourcecode:: http + + GET /camera/capture/d0b2067abbb946f19351e075c5e7cd5b/download/2018-11-20_16-04-17.jpeg HTTP/1.1 + Accept: image/jpeg + + :>header Accept: image/jpeg + :query thumbnail: return an image thumbnail e.g. ?thumbnail=true + :query as_attachment: return the image as an attachment download e.g. ?as_attachment=true + + :>header Content-Type: image/jpeg + :status 200: capture data found + :status 404: no capture found with that id + """ + capture_obj = self.microscope.camera.image_from_id(capture_id) + + if not capture_obj or not capture_obj.metadata['available']: + return abort(404) # 404 Not Found + + as_attachment = get_bool(request.args.get('as_attachment')) + thumbnail = get_bool(request.args.get('thumbnail')) + + # If no filename is specified, redirect to the capture's currently set filename + if not filename: + return redirect(url_for('capture_download', capture_id=capture_id, filename=capture_obj.filename, as_attachment=as_attachment, thumbnail=thumbnail), code=307) + + # Download the image data using the requested filename + if thumbnail: + img = capture_obj.thumbnail + else: + img = capture_obj.data + + return send_file( + img, + mimetype='image/jpeg', + as_attachment=as_attachment, + attachment_filename=filename) \ No newline at end of file diff --git a/openflexure_microscope/api/v1/blueprints/camera/preview.py b/openflexure_microscope/api/v1/blueprints/camera/preview.py new file mode 100644 index 00000000..12d59529 --- /dev/null +++ b/openflexure_microscope/api/v1/blueprints/camera/preview.py @@ -0,0 +1,38 @@ +from openflexure_microscope.api.utilities import parse_payload, get_from_payload, gen, get_bool +from openflexure_microscope.api.v1.views import MicroscopeView + +from flask import Response, Blueprint, jsonify, request, abort, url_for, redirect, send_file + +import logging + + +class GPUPreviewAPI(MicroscopeView): + + def post(self, operation): + """ + Create a new image capture. + + .. :quickref: GPU Preview; Start/stop preview + + **Example requests**: + + .. sourcecode:: http + + POST /camera/preview/start HTTP/1.1 + Accept: application/json + + .. sourcecode:: http + + POST /camera/preview/stop HTTP/1.1 + Accept: application/json + + :>header Accept: application/json + + :
json int x: x steps + :>json int y: y steps + :>json int z: z steps + """ + return jsonify(self.microscope.state['stage']['position']) + + def post(self): + """ + Set x, y and z positions of the stage. + + .. :quickref: Position; Update current position + + :reqheader Accept: application/json + : travel_limit: + # Respond with 400 Bad Request + response = {'error': 'Cannot move to absolute position beyond the safeguard limit.'} + return jsonify(response), 400 + + self.microscope.stage.move_rel(position) + + return jsonify(self.microscope.state['stage']['position']) + + +class StageParamsAPI(MicroscopeView): + + def get(self): + """ + Return current parameters of the stage. + + .. :quickref: Stage params; Get current stage parameters + + **Example request**: + + .. sourcecode:: http + + GET /stage/params HTTP/1.1 + Accept: application/json + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "backlash": { + "x": 0, + "y": 0, + "z": 128 + }, + } + + """ + + return jsonify(self.microscope.state['stage']) + + def post(self): + """ + Set parameters of the stage. + + .. :quickref: Stage params; Set current stage parameters + + :reqheader Accept: application/json + : Date: Thu, 6 Dec 2018 15:43:41 +0000 Subject: [PATCH 02/25] PEP8 stuff --- openflexure_microscope/api/app.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openflexure_microscope/api/app.py b/openflexure_microscope/api/app.py index 1ae28f16..15012127 100644 --- a/openflexure_microscope/api/app.py +++ b/openflexure_microscope/api/app.py @@ -107,6 +107,7 @@ app.register_blueprint(camera_blueprint, url_prefix=uri('/camera', 'v1')) # List all routes list_routes(app) + # Automatically clean up microscope at exit def cleanup(): global api_microscope From 39aed3f122fc399d9b0adf35d1f5d7dc736997d4 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Thu, 6 Dec 2018 15:43:50 +0000 Subject: [PATCH 03/25] Fixed API module links --- docs/source/api.rst | 4 ++-- start_interface | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/source/api.rst b/docs/source/api.rst index cbc93554..4d409ccd 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -3,14 +3,14 @@ REST API Summary ------- -.. qrefflask:: openflexure_microscope.api.v1:app +.. qrefflask:: openflexure_microscope.api.app:app :undoc-endpoints: index :undoc-static: :endpoints: Details ----------- -.. autoflask:: openflexure_microscope.api.v1:app +.. autoflask:: openflexure_microscope.api.app:app :undoc-endpoints: index :undoc-static: :endpoints: diff --git a/start_interface b/start_interface index 13b267f7..b4ccb02b 100644 --- a/start_interface +++ b/start_interface @@ -1 +1 @@ -gunicorn --threads 5 --workers 1 --bind 0.0.0.0:5000 openflexure_microscope.api.v1:app \ No newline at end of file +gunicorn --threads 5 --workers 1 --bind 0.0.0.0:5000 openflexure_microscope.api.app:app \ No newline at end of file From 3f6bf67c12f428ec9f8919df9d9d61001c2bd1ef Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Thu, 6 Dec 2018 15:48:56 +0000 Subject: [PATCH 04/25] Removed obsolete static files --- openflexure_microscope/api/templates/index.html | 9 --------- openflexure_microscope/api/templates/index_v0.html | 9 --------- 2 files changed, 18 deletions(-) delete mode 100644 openflexure_microscope/api/templates/index.html delete mode 100644 openflexure_microscope/api/templates/index_v0.html diff --git a/openflexure_microscope/api/templates/index.html b/openflexure_microscope/api/templates/index.html deleted file mode 100644 index d1f67f86..00000000 --- a/openflexure_microscope/api/templates/index.html +++ /dev/null @@ -1,9 +0,0 @@ - - - Video Streaming Demonstration - - -

Video Streaming Demonstration

- - - diff --git a/openflexure_microscope/api/templates/index_v0.html b/openflexure_microscope/api/templates/index_v0.html deleted file mode 100644 index d1f67f86..00000000 --- a/openflexure_microscope/api/templates/index_v0.html +++ /dev/null @@ -1,9 +0,0 @@ - - - Video Streaming Demonstration - - -

Video Streaming Demonstration

- - - From 5ef9215051cebcc7e5e4ac02b36efdb364af999e Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Tue, 11 Dec 2018 13:32:16 +0000 Subject: [PATCH 05/25] Additional debug logging --- openflexure_microscope/api/app.py | 11 +++++++++-- openflexure_microscope/microscope.py | 11 +++++++++-- openflexure_microscope/plugins/loader.py | 8 +++----- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/openflexure_microscope/api/app.py b/openflexure_microscope/api/app.py index 15012127..155e8475 100644 --- a/openflexure_microscope/api/app.py +++ b/openflexure_microscope/api/app.py @@ -70,9 +70,15 @@ def attach_microscope(): logging.debug("First request made. Populating microscope with hardware...") openflexurerc = config.load_config() # Load default user config + logging.debug("Creating camera object.") + api_camera = StreamingCamera(config=openflexurerc) + logging.debug("Creating stage object.") + api_stage = OpenFlexureStage("/dev/ttyUSB0") + + logging.debug("Attaching to microscope.") api_microscope.attach( - StreamingCamera(config=openflexurerc), - OpenFlexureStage("/dev/ttyUSB0") + api_camera, + api_stage ) logging.debug("Microscope successfully attached!") @@ -92,6 +98,7 @@ def index(): ##### API ROUTES ###### from openflexure_microscope.api.v1 import blueprints +logging.debug("Registering blueprints...") # Base routes base_blueprint = blueprints.base.construct_blueprint(api_microscope) app.register_blueprint(base_blueprint, url_prefix=uri('', 'v1')) diff --git a/openflexure_microscope/microscope.py b/openflexure_microscope/microscope.py index ed1c19d8..bfb9737b 100644 --- a/openflexure_microscope/microscope.py +++ b/openflexure_microscope/microscope.py @@ -60,14 +60,20 @@ class Microscope(object): apply_config (bool): Should the microscope config dictionary be applied to the attached hardware? """ + logging.debug("Attaching camera...") self.camera = camera #: :py:class:`openflexure_microscope.camera.pi.StreamingCamera`: Picamera object - if isinstance(camera, StreamingCamera): + if isinstance(self.camera, StreamingCamera): logging.info("Attached camera {}".format(camera)) + elif not self.camera: + logging.info("Attached dummy camera.") + logging.debug("Attaching stage...") self.stage = stage #: :py:class:`openflexure_stage.stage.OpenFlexureStage`: OpenFlexure stage object if isinstance(self.stage, OpenFlexureStage): # If a stage object has been attached logging.info("Attached stage {}".format(stage)) self.stage.backlash = np.zeros(3, dtype=np.int) + elif not self.stage: + logging.info("Attached dummy stage.") def find_plugins(self, plugin_paths=[], include_default=True): """ @@ -77,12 +83,13 @@ class Microscope(object): plugin_paths (list): List of strings of plugin directories. include_default (bool): Also load plugins from the module default directory (DEFAULT_PLUGIN_PATH) """ - # TODO: Get paths from rc file + logging.debug("Attaching plugins...") plugins_list = search_plugin_dirs(plugin_paths, include_default=include_default) for i in plugins_list: module = load_plugin(i) self.plugin.attach(module) + logging.debug("Plugins attached.") # Create unified state @property diff --git a/openflexure_microscope/plugins/loader.py b/openflexure_microscope/plugins/loader.py index 7276c145..2ca4c71c 100644 --- a/openflexure_microscope/plugins/loader.py +++ b/openflexure_microscope/plugins/loader.py @@ -104,8 +104,7 @@ class PluginMount(object): # Grant plugin access to the hardware assert(isinstance(plugin_object, MicroscopePlugin)) - plugin_object.camera = self.parent.camera - plugin_object.stage = self.parent.stage + plugin_object.microscope = self.parent print("Adding plugin: {}".format(plugin_name)) @@ -114,9 +113,8 @@ class MicroscopePlugin(): """ Parent class for all microscope plugins. - Initially only defines an empty object for camera and stage. All plugins + Initially only defines an empty object for microscope. All plugins must be an instance of this class to successfully attach to PluginMount. """ def __init__(self): - self.camera = None #: :py:class:`openflexure_microscope.camera.pi.StreamingCamera`: Picamera object - self.stage = None #: :py:class:`openflexure_stage.stage.OpenFlexureStage`: OpenFlexure stage object + self.microscope = None #: :py:class:`openflexure_microscope.microscope.Microscope`: Microscope object From 9a2c5850461550fa3ccf1059f50f5a4d83c11c1a Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Tue, 11 Dec 2018 13:32:29 +0000 Subject: [PATCH 06/25] Broken plugin into microscope plugin and API endpoints --- .../plugins/default/test_plugin/__init__.py | 36 ++----------------- .../plugins/default/test_plugin/api.py | 3 ++ .../plugins/default/test_plugin/plugin.py | 34 ++++++++++++++++++ 3 files changed, 39 insertions(+), 34 deletions(-) create mode 100644 openflexure_microscope/plugins/default/test_plugin/api.py create mode 100644 openflexure_microscope/plugins/default/test_plugin/plugin.py diff --git a/openflexure_microscope/plugins/default/test_plugin/__init__.py b/openflexure_microscope/plugins/default/test_plugin/__init__.py index 8cd3872a..867153d5 100644 --- a/openflexure_microscope/plugins/default/test_plugin/__init__.py +++ b/openflexure_microscope/plugins/default/test_plugin/__init__.py @@ -1,34 +1,2 @@ -from openflexure_microscope.plugins import MicroscopePlugin - - -class FirstPlugin(MicroscopePlugin): - """ - An example Microscope plugin. - """ - def run(self): - """ - Demonstrate access to Microscope.camera, and Microscope.stage - """ - print("Parent camera:") - print(self.camera) - - print("Parent stage:") - print(self.stage) - - -class OtherPlugin(MicroscopePlugin): - """ - An example of a second Microscope plugin, loaded from the same plugin file. - """ - def run(self): - """ - Demonstrate access to Microscope.camera, and Microscope.stage - """ - print("Stage plugin parent stage:") - print(self.stage) - - -PLUGINS = { - 'test1': FirstPlugin, - 'test2': OtherPlugin, -} #: dict: Dictionary describing the plugins. Keys are the names of the plugin's namescape, with a value corresponding to the class of that plugin. \ No newline at end of file +from .plugin import PLUGINS +from .api import ENDPOINTS diff --git a/openflexure_microscope/plugins/default/test_plugin/api.py b/openflexure_microscope/plugins/default/test_plugin/api.py new file mode 100644 index 00000000..84712458 --- /dev/null +++ b/openflexure_microscope/plugins/default/test_plugin/api.py @@ -0,0 +1,3 @@ +from openflexure_microscope.api.v1.views import MicroscopeView + +ENDPOINTS = {} diff --git a/openflexure_microscope/plugins/default/test_plugin/plugin.py b/openflexure_microscope/plugins/default/test_plugin/plugin.py new file mode 100644 index 00000000..17d2acdd --- /dev/null +++ b/openflexure_microscope/plugins/default/test_plugin/plugin.py @@ -0,0 +1,34 @@ +from openflexure_microscope.plugins import MicroscopePlugin + + +class FirstPlugin(MicroscopePlugin): + """ + An example Microscope plugin. + """ + def run(self): + """ + Demonstrate access to Microscope.camera, and Microscope.stage + """ + print("Parent camera:") + print(self.microscope.camera) + + print("Parent stage:") + print(self.microscope.stage) + + +class OtherPlugin(MicroscopePlugin): + """ + An example of a second Microscope plugin, loaded from the same plugin file. + """ + def run(self): + """ + Demonstrate access to Microscope.camera, and Microscope.stage + """ + print("Stage plugin parent stage:") + print(self.microscope.stage) + + +PLUGINS = { + 'test1': FirstPlugin, + 'test2': OtherPlugin, +} #: dict: Dictionary describing the plugins. Keys are the names of the plugin's namescape, with a value corresponding to the class of that plugin. \ No newline at end of file From 14a4e3d909940e8af9209bf782407d100e5a59e6 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Tue, 11 Dec 2018 14:34:36 +0000 Subject: [PATCH 07/25] Restructured plugin validity check --- openflexure_microscope/plugins/loader.py | 25 ++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/openflexure_microscope/plugins/loader.py b/openflexure_microscope/plugins/loader.py index 2ca4c71c..f3071441 100644 --- a/openflexure_microscope/plugins/loader.py +++ b/openflexure_microscope/plugins/loader.py @@ -91,22 +91,23 @@ class PluginMount(object): Args: plugin_module: A loaded module to be attached. Module can be loaded using :py:meth:`openflexure_microscope.plugins.load_plugin` """ - if not hasattr(plugin_module, 'PLUGINS') or not isinstance(plugin_module.PLUGINS, dict): - raise Exception("No falid PLUGINS dictionary found in {}".format(plugin_module)) + if hasattr(plugin_module, 'PLUGINS') and isinstance(plugin_module.PLUGINS, dict): - for plugin_name, plugin_class in plugin_module.PLUGINS.items(): + for plugin_name, plugin_class in plugin_module.PLUGINS.items(): - plugin_object = plugin_class() - if hasattr(self, plugin_name): - warnings.warn("A plugin named {} has already been loaded. Skipping {}.".format(plugin_name, plugin_class)) - else: - setattr(self, plugin_name, plugin_object) + plugin_object = plugin_class() + if hasattr(self, plugin_name): + warnings.warn("A plugin named {} has already been loaded. Skipping {}.".format(plugin_name, plugin_class)) + else: + setattr(self, plugin_name, plugin_object) - # Grant plugin access to the hardware - assert(isinstance(plugin_object, MicroscopePlugin)) - plugin_object.microscope = self.parent + # Grant plugin access to the hardware + assert(isinstance(plugin_object, MicroscopePlugin)) + plugin_object.microscope = self.parent - print("Adding plugin: {}".format(plugin_name)) + print("Adding plugin: {}".format(plugin_name)) + else: + warnings.warn("No valid PLUGINS dictionary found in {}".format(plugin_module)) class MicroscopePlugin(): From 8edf0fa027565e2bd018690b1e5ea2aac5dc7954 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Tue, 11 Dec 2018 17:19:39 +0000 Subject: [PATCH 08/25] Show full microscope plugin list --- tests/test_plugins.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 939245da..47d7815e 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -14,6 +14,8 @@ if __name__ == '__main__': microscope.find_plugins() # Automatically find microscope plugins + print(dir(microscope.plugin)) + # Check that default tets plugins have loaded print("RUNNING PLUGIN METHODS:") microscope.plugin.test1.run() From 5867afa5b57599e33e7c91fe347dcf569991ec2f Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Tue, 11 Dec 2018 17:20:00 +0000 Subject: [PATCH 09/25] Attach plugins to webapp --- openflexure_microscope/api/app.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/openflexure_microscope/api/app.py b/openflexure_microscope/api/app.py index 155e8475..3a6c184e 100644 --- a/openflexure_microscope/api/app.py +++ b/openflexure_microscope/api/app.py @@ -70,17 +70,20 @@ def attach_microscope(): logging.debug("First request made. Populating microscope with hardware...") openflexurerc = config.load_config() # Load default user config - logging.debug("Creating camera object.") + logging.debug("Creating camera object...") api_camera = StreamingCamera(config=openflexurerc) - logging.debug("Creating stage object.") + logging.debug("Creating stage object...") api_stage = OpenFlexureStage("/dev/ttyUSB0") - logging.debug("Attaching to microscope.") + logging.debug("Attaching devices to microscope...") api_microscope.attach( api_camera, api_stage ) + logging.debug("Attaching plugins to microscope...") + api_microscope.find_plugins() # Automatically find microscope plugins + logging.debug("Microscope successfully attached!") @@ -111,6 +114,10 @@ app.register_blueprint(stage_blueprint, url_prefix=uri('/stage', 'v1')) camera_blueprint = blueprints.camera.construct_blueprint(api_microscope) app.register_blueprint(camera_blueprint, url_prefix=uri('/camera', 'v1')) +# Pluginroutes +plugin_blueprint = blueprints.plugins.construct_blueprint(api_microscope) +app.register_blueprint(plugin_blueprint, url_prefix=uri('/plugin', 'v1')) + # List all routes list_routes(app) From 87886d35a078dcd990c69fb22de4b18e4bc67c4f Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Tue, 11 Dec 2018 17:20:12 +0000 Subject: [PATCH 10/25] Fixed relative imports --- .../plugins/default/test_plugin/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openflexure_microscope/plugins/default/test_plugin/__init__.py b/openflexure_microscope/plugins/default/test_plugin/__init__.py index 867153d5..d132c52b 100644 --- a/openflexure_microscope/plugins/default/test_plugin/__init__.py +++ b/openflexure_microscope/plugins/default/test_plugin/__init__.py @@ -1,2 +1,2 @@ -from .plugin import PLUGINS -from .api import ENDPOINTS +from openflexure_microscope.plugins.default.test_plugin.api import ENDPOINTS +from openflexure_microscope.plugins.default.test_plugin.plugin import PLUGINS \ No newline at end of file From c1630f91e68bc8e777668106abc2aef09dbc3754 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Tue, 11 Dec 2018 17:20:27 +0000 Subject: [PATCH 11/25] Include plugins blueprints --- openflexure_microscope/api/v1/blueprints/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openflexure_microscope/api/v1/blueprints/__init__.py b/openflexure_microscope/api/v1/blueprints/__init__.py index 5bee2fc7..f987ef03 100644 --- a/openflexure_microscope/api/v1/blueprints/__init__.py +++ b/openflexure_microscope/api/v1/blueprints/__init__.py @@ -1 +1 @@ -from . import camera, stage, base +from . import camera, stage, base, plugins From 39e325a52ae301e580e1f969a717d85ac64f80c4 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Tue, 11 Dec 2018 17:20:46 +0000 Subject: [PATCH 12/25] Automatically load API endpoint plugins --- .../api/v1/blueprints/plugins.py | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 openflexure_microscope/api/v1/blueprints/plugins.py diff --git a/openflexure_microscope/api/v1/blueprints/plugins.py b/openflexure_microscope/api/v1/blueprints/plugins.py new file mode 100644 index 00000000..8a8c7b0b --- /dev/null +++ b/openflexure_microscope/api/v1/blueprints/plugins.py @@ -0,0 +1,50 @@ +from openflexure_microscope.api.utilities import parse_payload, get_from_payload, gen, get_bool +from openflexure_microscope.api.v1.views import MicroscopeView + +from openflexure_microscope.plugins import load_plugin, search_plugin_dirs + +from flask import Response, Blueprint, jsonify + +import logging, warnings + + +def add_endpoints(plugin_module, endpoint_dict): + """ + Fetch valid endpoints from a plugin_module + + Args: + plugin_module: A loaded module to be attached. Module can be loaded using :py:meth:`openflexure_microscope.plugins.load_plugin` + """ + if hasattr(plugin_module, 'ENDPOINTS') and isinstance(plugin_module.ENDPOINTS, dict): # If plugin contains valid endpoints + + for endpoint_name, endpoint_class in plugin_module.ENDPOINTS.items(): # For each defined endpoint + + if endpoint_name in endpoint_dict: # Check if endpoint name clashes + warnings.warn("An endpoint /{} has already been loaded. Skipping {}.".format(endpoint_name, endpoint_class)) + else: + endpoint_dict[endpoint_name] = endpoint_class # Add endpoint to main endpoint dictionary + else: + warnings.warn("No valid ENDPOINTS dictionary found in {}".format(plugin_module)) + + +def construct_blueprint(microscope_obj, plugin_paths=[], include_default=True): + + blueprint = Blueprint('plugin_blueprint', __name__) + + logging.debug("Attaching plugins...") + plugins_list = search_plugin_dirs(plugin_paths, include_default=include_default) + + endpoint_dict = {} # Store all endpoints + + for plugin_file in plugins_list: + plugin_module = load_plugin(plugin_file) + add_endpoints(plugin_module, endpoint_dict) + + for endpoint_name, endpoint_class in endpoint_dict.items(): # For each valid endpoint + + blueprint.add_url_rule( + '/{}'.format(endpoint_name), + view_func=endpoint_class.as_view('plugin_{}'.format(endpoint_name), microscope=microscope_obj) + ) + + return(blueprint) From 2fdabccfb0cafb3edc129352303983be36c851e9 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Tue, 11 Dec 2018 17:21:18 +0000 Subject: [PATCH 13/25] Added user plugin directory to defaults path --- openflexure_microscope/plugins/loader.py | 72 ++++++++++++++++++++++-- 1 file changed, 66 insertions(+), 6 deletions(-) diff --git a/openflexure_microscope/plugins/loader.py b/openflexure_microscope/plugins/loader.py index f3071441..634a868d 100644 --- a/openflexure_microscope/plugins/loader.py +++ b/openflexure_microscope/plugins/loader.py @@ -3,9 +3,12 @@ import os import warnings import logging +from openflexure_microscope.config import USER_CONFIG_DIR + MAIN_MODULE = '__init__' HERE = os.path.abspath(os.path.dirname(__file__)) DEFAULT_PLUGIN_PATH = os.path.join(HERE, 'default') +USER_PLUGIN_DIR = os.path.join(USER_CONFIG_DIR, "plugins") def search_plugin_dirs(plugin_paths, include_default=True): @@ -14,12 +17,16 @@ def search_plugin_dirs(plugin_paths, include_default=True): Args: plugin_paths (list): List of strings of plugin directories. - include_default (bool): Also load plugins from the module default directory (DEFAULT_PLUGIN_PATH) + include_default (bool): Also load plugins from the module default directory (DEFAULT_PLUGIN_PATH, USER_PLUGIN_DIR) """ - global DEFAULT_PLUGIN_PATH + global DEFAULT_PLUGIN_PATH, USER_PLUGIN_DIR + + if not os.path.exists(USER_PLUGIN_DIR): # If user config file already exists + os.makedirs(USER_PLUGIN_DIR) if include_default: # If including default plugins plugin_paths.append(DEFAULT_PLUGIN_PATH) # Add default directory to the search paths + plugin_paths.append(USER_PLUGIN_DIR) # Add user directory to the search paths logging.debug(plugin_paths) @@ -31,6 +38,36 @@ def search_plugin_dirs(plugin_paths, include_default=True): return plugins +def find_plugins_legacy(plugin_dir): + """ + Find all plugins residing within a directory + + Args: + plugin_dir (str): String of directory to be searched + """ + plugins = [] + plugins_folders = os.listdir(plugin_dir) + + loader_details = ( + importlib.machinery.SourceFileLoader, + importlib.machinery.SOURCE_SUFFIXES + ) + + for i in plugins_folders: + plugin_folder = os.path.join(plugin_dir, i) + logging.info(plugin_folder) + + if not os.path.isdir(plugin_folder): # If plugin folder doesn't exist + continue # Skip this iteration + if not MAIN_MODULE + '.py' in os.listdir(plugin_folder): # If no __init__ file in plugin folder + continue # Skip this iteration + + module_spec = importlib.machinery.FileFinder(plugin_folder, loader_details).find_spec(MAIN_MODULE) + + plugins.append(module_spec) + return plugins + + def find_plugins(plugin_dir): """ Find all plugins residing within a directory @@ -50,10 +87,10 @@ def find_plugins(plugin_dir): plugin_folder = os.path.join(plugin_dir, i) logging.info(plugin_folder) - if not os.path.isdir(plugin_folder): - continue - if not MAIN_MODULE + '.py' in os.listdir(plugin_folder): - continue + if not os.path.isdir(plugin_folder): # If plugin folder doesn't exist + continue # Skip this iteration + if not MAIN_MODULE + '.py' in os.listdir(plugin_folder): # If no __init__ file in plugin folder + continue # Skip this iteration module_spec = importlib.machinery.FileFinder(plugin_folder, loader_details).find_spec(MAIN_MODULE) @@ -73,6 +110,17 @@ def load_plugin(module_spec): return module +def load_plugin_legacy(module_spec): + """ + Load a source file from a given spec. + + Args: + module_spec: Module spec of module to be returned + """ + module = importlib.util.module_from_spec(module_spec) + module_spec.loader.exec_module(module) + return module + class PluginMount(object): """ A mount-point for all loaded plugins. Attaches to a Microscope object. @@ -91,6 +139,8 @@ class PluginMount(object): Args: plugin_module: A loaded module to be attached. Module can be loaded using :py:meth:`openflexure_microscope.plugins.load_plugin` """ + print("LOADING MODULE {}".format(plugin_module.__name__)) + if hasattr(plugin_module, 'PLUGINS') and isinstance(plugin_module.PLUGINS, dict): for plugin_name, plugin_class in plugin_module.PLUGINS.items(): @@ -110,6 +160,16 @@ class PluginMount(object): warnings.warn("No valid PLUGINS dictionary found in {}".format(plugin_module)) +class PluginGroup(): + """ + A class used to group plugin methods within a PluginMount. + + Currently useless aside from creating a namespace in which plugin methods will reside. + """ + def __init__(self): + pass + + class MicroscopePlugin(): """ Parent class for all microscope plugins. From 4cbfe16cc2aaedfe57b12402664a040ea831bfcb Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Tue, 11 Dec 2018 17:21:37 +0000 Subject: [PATCH 14/25] Edit return methods for API plugin example --- .../plugins/default/test_plugin/plugin.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/openflexure_microscope/plugins/default/test_plugin/plugin.py b/openflexure_microscope/plugins/default/test_plugin/plugin.py index 17d2acdd..337278a6 100644 --- a/openflexure_microscope/plugins/default/test_plugin/plugin.py +++ b/openflexure_microscope/plugins/default/test_plugin/plugin.py @@ -9,23 +9,20 @@ class FirstPlugin(MicroscopePlugin): """ Demonstrate access to Microscope.camera, and Microscope.stage """ - print("Parent camera:") - print(self.microscope.camera) - print("Parent stage:") - print(self.microscope.stage) + response = "My parent camera is {}, and my parent stage is {}.".format(self.microscope.camera, self.microscope.stage) + return response class OtherPlugin(MicroscopePlugin): """ An example of a second Microscope plugin, loaded from the same plugin file. """ - def run(self): + def do_something(self): """ Demonstrate access to Microscope.camera, and Microscope.stage """ - print("Stage plugin parent stage:") - print(self.microscope.stage) + return "Stage plugin parent stage: {}".format(self.microscope.stage) PLUGINS = { From be084f6f57762b5718809f242111716e68df9766 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Tue, 11 Dec 2018 17:22:01 +0000 Subject: [PATCH 15/25] Added API plugin demo --- .../plugins/default/test_plugin/api.py | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/openflexure_microscope/plugins/default/test_plugin/api.py b/openflexure_microscope/plugins/default/test_plugin/api.py index 84712458..500962ea 100644 --- a/openflexure_microscope/plugins/default/test_plugin/api.py +++ b/openflexure_microscope/plugins/default/test_plugin/api.py @@ -1,3 +1,28 @@ +from openflexure_microscope.api.utilities import parse_payload from openflexure_microscope.api.v1.views import MicroscopeView -ENDPOINTS = {} +from flask import request, Response + +import logging + + +class PluginTestAPI(MicroscopeView): + + def get(self): + data = self.microscope.plugin.test1.run() # Call a method from our plugin + return Response(data) + + def post(self): + # Get payload + state = parse_payload(request) + logging.debug(state) + + # Handle absolute positioning + if 'data' in state: # If value is given in payload JSON + data = str(state['data']) # Process and store that value + + return Response(data) + +ENDPOINTS = { + 'test1': PluginTestAPI +} From 87901131a009c215af468f92b14275e37ace8795 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Tue, 11 Dec 2018 17:22:17 +0000 Subject: [PATCH 16/25] Make default plugins importable --- openflexure_microscope/plugins/default/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 openflexure_microscope/plugins/default/__init__.py diff --git a/openflexure_microscope/plugins/default/__init__.py b/openflexure_microscope/plugins/default/__init__.py new file mode 100644 index 00000000..e69de29b From ac5f26ef6ba4279ac63b1c8f585f596be70eddf7 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Tue, 11 Dec 2018 17:27:44 +0000 Subject: [PATCH 17/25] Removed pointless second plugin from one file --- .../plugins/default/test_plugin/plugin.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/openflexure_microscope/plugins/default/test_plugin/plugin.py b/openflexure_microscope/plugins/default/test_plugin/plugin.py index 337278a6..873a781b 100644 --- a/openflexure_microscope/plugins/default/test_plugin/plugin.py +++ b/openflexure_microscope/plugins/default/test_plugin/plugin.py @@ -14,18 +14,6 @@ class FirstPlugin(MicroscopePlugin): return response -class OtherPlugin(MicroscopePlugin): - """ - An example of a second Microscope plugin, loaded from the same plugin file. - """ - def do_something(self): - """ - Demonstrate access to Microscope.camera, and Microscope.stage - """ - return "Stage plugin parent stage: {}".format(self.microscope.stage) - - PLUGINS = { 'test1': FirstPlugin, - 'test2': OtherPlugin, } #: dict: Dictionary describing the plugins. Keys are the names of the plugin's namescape, with a value corresponding to the class of that plugin. \ No newline at end of file From bde2e9de2ad55ed8a9857eb1cddf861765a8bf6d Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Wed, 12 Dec 2018 14:58:55 +0000 Subject: [PATCH 18/25] Moved plugins up one level and added new view plugins --- .../plugins/default/__init__.py | 1 + openflexure_microscope/plugins/default/api.py | 20 +++++++++++++ .../plugins/default/plugin.py | 30 +++++++++++++++++++ .../plugins/default/test_plugin/__init__.py | 2 -- .../plugins/default/test_plugin/api.py | 28 ----------------- .../plugins/test_plugin/__init__.py | 1 + .../{default => }/test_plugin/plugin.py | 7 +---- 7 files changed, 53 insertions(+), 36 deletions(-) create mode 100644 openflexure_microscope/plugins/default/api.py create mode 100644 openflexure_microscope/plugins/default/plugin.py delete mode 100644 openflexure_microscope/plugins/default/test_plugin/__init__.py delete mode 100644 openflexure_microscope/plugins/default/test_plugin/api.py create mode 100644 openflexure_microscope/plugins/test_plugin/__init__.py rename openflexure_microscope/plugins/{default => }/test_plugin/plugin.py (64%) diff --git a/openflexure_microscope/plugins/default/__init__.py b/openflexure_microscope/plugins/default/__init__.py index e69de29b..f42b093e 100644 --- a/openflexure_microscope/plugins/default/__init__.py +++ b/openflexure_microscope/plugins/default/__init__.py @@ -0,0 +1 @@ +from .plugin import Plugin \ No newline at end of file diff --git a/openflexure_microscope/plugins/default/api.py b/openflexure_microscope/plugins/default/api.py new file mode 100644 index 00000000..fce53194 --- /dev/null +++ b/openflexure_microscope/plugins/default/api.py @@ -0,0 +1,20 @@ +from openflexure_microscope.api.utilities import parse_payload +from openflexure_microscope.api.v1.views import MicroscopeViewPlugin + +from flask import request, Response, escape + +import logging + + +class IdentifyAPI(MicroscopeViewPlugin): + + def get(self): + data = self.microscope.plugin.default.identify() # Call a method from our plugin, using the full route + return Response(escape(data)) + + +class HelloWorldAPI(MicroscopeViewPlugin): + + def get(self): + data = self.plugin.hello_world() # Call a method from our plugin, using the MicroscopeViewPlugin.plugin shortcut + return Response(data) diff --git a/openflexure_microscope/plugins/default/plugin.py b/openflexure_microscope/plugins/default/plugin.py new file mode 100644 index 00000000..39261c6e --- /dev/null +++ b/openflexure_microscope/plugins/default/plugin.py @@ -0,0 +1,30 @@ +from openflexure_microscope.plugins import MicroscopePlugin + +from .api import IdentifyAPI, HelloWorldAPI + + +class Plugin(MicroscopePlugin): + """ + A set of default plugins + """ + + api_views = { + '/identify': IdentifyAPI, + '/hello': HelloWorldAPI, + } + + def identify(self): + """ + Demonstrate access to Microscope.camera, and Microscope.stage + """ + + response = "My parent camera is {}, and my parent stage is {}.".format(self.microscope.camera, self.microscope.stage) + print(response) + return response + + def hello_world(self): + """ + Demonstrate passive method + """ + + return "Hello world!" \ No newline at end of file diff --git a/openflexure_microscope/plugins/default/test_plugin/__init__.py b/openflexure_microscope/plugins/default/test_plugin/__init__.py deleted file mode 100644 index d132c52b..00000000 --- a/openflexure_microscope/plugins/default/test_plugin/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from openflexure_microscope.plugins.default.test_plugin.api import ENDPOINTS -from openflexure_microscope.plugins.default.test_plugin.plugin import PLUGINS \ No newline at end of file diff --git a/openflexure_microscope/plugins/default/test_plugin/api.py b/openflexure_microscope/plugins/default/test_plugin/api.py deleted file mode 100644 index 500962ea..00000000 --- a/openflexure_microscope/plugins/default/test_plugin/api.py +++ /dev/null @@ -1,28 +0,0 @@ -from openflexure_microscope.api.utilities import parse_payload -from openflexure_microscope.api.v1.views import MicroscopeView - -from flask import request, Response - -import logging - - -class PluginTestAPI(MicroscopeView): - - def get(self): - data = self.microscope.plugin.test1.run() # Call a method from our plugin - return Response(data) - - def post(self): - # Get payload - state = parse_payload(request) - logging.debug(state) - - # Handle absolute positioning - if 'data' in state: # If value is given in payload JSON - data = str(state['data']) # Process and store that value - - return Response(data) - -ENDPOINTS = { - 'test1': PluginTestAPI -} diff --git a/openflexure_microscope/plugins/test_plugin/__init__.py b/openflexure_microscope/plugins/test_plugin/__init__.py new file mode 100644 index 00000000..44d6fa90 --- /dev/null +++ b/openflexure_microscope/plugins/test_plugin/__init__.py @@ -0,0 +1 @@ +from .plugin import FirstPlugin \ No newline at end of file diff --git a/openflexure_microscope/plugins/default/test_plugin/plugin.py b/openflexure_microscope/plugins/test_plugin/plugin.py similarity index 64% rename from openflexure_microscope/plugins/default/test_plugin/plugin.py rename to openflexure_microscope/plugins/test_plugin/plugin.py index 873a781b..e6bf90ab 100644 --- a/openflexure_microscope/plugins/default/test_plugin/plugin.py +++ b/openflexure_microscope/plugins/test_plugin/plugin.py @@ -11,9 +11,4 @@ class FirstPlugin(MicroscopePlugin): """ response = "My parent camera is {}, and my parent stage is {}.".format(self.microscope.camera, self.microscope.stage) - return response - - -PLUGINS = { - 'test1': FirstPlugin, -} #: dict: Dictionary describing the plugins. Keys are the names of the plugin's namescape, with a value corresponding to the class of that plugin. \ No newline at end of file + return response \ No newline at end of file From 86cc29ee84a3586968d971f546954170238cb8f9 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Wed, 12 Dec 2018 14:59:48 +0000 Subject: [PATCH 19/25] Added default and test plugins to default microscoperc --- openflexure_microscope/microscoperc.default.yaml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/openflexure_microscope/microscoperc.default.yaml b/openflexure_microscope/microscoperc.default.yaml index 7e24d14f..4e1f515e 100644 --- a/openflexure_microscope/microscoperc.default.yaml +++ b/openflexure_microscope/microscoperc.default.yaml @@ -10,6 +10,7 @@ numpy_resolution: [1312, 976] # Capture quality jpeg_quality: 75 +# Parameters specific to PiCamera objects picamera_params: exposure_mode: 'off' awb_mode: 'off' @@ -17,4 +18,9 @@ picamera_params: framerate: 24 shutter_speed: 30000 saturation: 0 - led: false \ No newline at end of file + led: false + +# Default plugins +plugins: + - openflexure_microscope.plugins.default:Plugin + - openflexure_microscope.plugins.test_plugin:FirstPlugin \ No newline at end of file From 6d464349b72c557577b8209513d199511445e02a Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Wed, 12 Dec 2018 15:00:30 +0000 Subject: [PATCH 20/25] Completely rewritten plugin loader to accept absolute modules, or single file paths --- openflexure_microscope/plugins/__init__.py | 4 +- openflexure_microscope/plugins/loader.py | 215 +++++++++------------ 2 files changed, 90 insertions(+), 129 deletions(-) diff --git a/openflexure_microscope/plugins/__init__.py b/openflexure_microscope/plugins/__init__.py index e552673e..b335100a 100644 --- a/openflexure_microscope/plugins/__init__.py +++ b/openflexure_microscope/plugins/__init__.py @@ -1,3 +1,3 @@ -__all__ = ['search_plugin_dirs', 'find_plugins', 'load_plugin', 'PluginMount', 'MicroscopePlugin'] +__all__ = ['module_from_file', 'load_plugin_class', 'load_plugin_module', 'class_from_map', 'PluginMount', 'MicroscopePlugin'] -from .loader import search_plugin_dirs, find_plugins, load_plugin, PluginMount, MicroscopePlugin \ No newline at end of file +from .loader import module_from_file, load_plugin_class, load_plugin_module, class_from_map, PluginMount, MicroscopePlugin \ No newline at end of file diff --git a/openflexure_microscope/plugins/loader.py b/openflexure_microscope/plugins/loader.py index 634a868d..566c563d 100644 --- a/openflexure_microscope/plugins/loader.py +++ b/openflexure_microscope/plugins/loader.py @@ -1,126 +1,83 @@ import importlib import os -import warnings +import inspect import logging -from openflexure_microscope.config import USER_CONFIG_DIR -MAIN_MODULE = '__init__' -HERE = os.path.abspath(os.path.dirname(__file__)) -DEFAULT_PLUGIN_PATH = os.path.join(HERE, 'default') -USER_PLUGIN_DIR = os.path.join(USER_CONFIG_DIR, "plugins") +class bcolors: + HEADER = '\033[95m' + OKBLUE = '\033[94m' + OKGREEN = '\033[92m' + WARNING = '\033[93m' + FAIL = '\033[91m' + ENDC = '\033[0m' + BOLD = '\033[1m' + UNDERLINE = '\033[4m' -def search_plugin_dirs(plugin_paths, include_default=True): - """ - Search through, and load from, a list of plugin directories. +def module_from_file(plugin_path): + # Expand environment variables in path string + plugin_path = os.path.expandvars(plugin_path) + # Expand user directory in path string + plugin_path = os.path.expanduser(plugin_path) - Args: - plugin_paths (list): List of strings of plugin directories. - include_default (bool): Also load plugins from the module default directory (DEFAULT_PLUGIN_PATH, USER_PLUGIN_DIR) - """ - global DEFAULT_PLUGIN_PATH, USER_PLUGIN_DIR + # Check if the path is to a file + if not os.path.isfile(plugin_path): + logging.warning(bcolors.FAIL + "No valid plugin found at {}.".format(plugin_path) + bcolors.ENDC) + return None, None, None - if not os.path.exists(USER_PLUGIN_DIR): # If user config file already exists - os.makedirs(USER_PLUGIN_DIR) + else: + # Get name of plugin from the file + plugin_name = os.path.splitext(os.path.basename(plugin_path))[0] - if include_default: # If including default plugins - plugin_paths.append(DEFAULT_PLUGIN_PATH) # Add default directory to the search paths - plugin_paths.append(USER_PLUGIN_DIR) # Add user directory to the search paths + plugin_spec = importlib.util.spec_from_file_location(plugin_name, plugin_path) + plugin_module = importlib.util.module_from_spec(plugin_spec) - logging.debug(plugin_paths) - - plugins = [] # List of loaded plugins - for plugin_dir in plugin_paths: # For each plugin directory - logging.debug("Searching {}".format(plugin_dir)) - plugins.extend(find_plugins(plugin_dir)) # Find plugin folders, and load into list - - return plugins + return plugin_spec, plugin_module, plugin_name -def find_plugins_legacy(plugin_dir): - """ - Find all plugins residing within a directory +def load_plugin_module(plugin_path): + # First, try importing from standard modules + try: + plugin_module = importlib.import_module(plugin_path) + except ModuleNotFoundError: + plugin_spec, plugin_module, plugin_name = module_from_file(plugin_path) - Args: - plugin_dir (str): String of directory to be searched - """ - plugins = [] - plugins_folders = os.listdir(plugin_dir) + # If a valid plugin was found + if plugin_spec and plugin_module: + # Execute the module, so we have access to it + plugin_spec.loader.exec_module(plugin_module) + else: + plugin_name = plugin_path.split('.')[-1] - loader_details = ( - importlib.machinery.SourceFileLoader, - importlib.machinery.SOURCE_SUFFIXES - ) - - for i in plugins_folders: - plugin_folder = os.path.join(plugin_dir, i) - logging.info(plugin_folder) - - if not os.path.isdir(plugin_folder): # If plugin folder doesn't exist - continue # Skip this iteration - if not MAIN_MODULE + '.py' in os.listdir(plugin_folder): # If no __init__ file in plugin folder - continue # Skip this iteration - - module_spec = importlib.machinery.FileFinder(plugin_folder, loader_details).find_spec(MAIN_MODULE) - - plugins.append(module_spec) - return plugins + return plugin_module, plugin_name -def find_plugins(plugin_dir): - """ - Find all plugins residing within a directory - - Args: - plugin_dir (str): String of directory to be searched - """ - plugins = [] - plugins_folders = os.listdir(plugin_dir) - - loader_details = ( - importlib.machinery.SourceFileLoader, - importlib.machinery.SOURCE_SUFFIXES - ) - - for i in plugins_folders: - plugin_folder = os.path.join(plugin_dir, i) - logging.info(plugin_folder) - - if not os.path.isdir(plugin_folder): # If plugin folder doesn't exist - continue # Skip this iteration - if not MAIN_MODULE + '.py' in os.listdir(plugin_folder): # If no __init__ file in plugin folder - continue # Skip this iteration - - module_spec = importlib.machinery.FileFinder(plugin_folder, loader_details).find_spec(MAIN_MODULE) - - plugins.append(module_spec) - return plugins +def load_plugin_class(plugin_path, plugin_class_name): + plugin_module, plugin_name = load_plugin_module(plugin_path) + if plugin_module: + # Now try to extract the class + try: + plugin_class = getattr(plugin_module, plugin_class_name) + except AttributeError: + logging.warning(bcolors.FAIL + "Class {} does not exist in plugin {}. Skipping.".format(plugin_class_name, plugin_path) + bcolors.ENDC) + return None, None + else: + return plugin_class, plugin_name + else: + return None, None -def load_plugin(module_spec): - """ - Load a source file from a given spec. +def class_from_map(plugin_map): + plugin_arr = plugin_map.split(':') - Args: - module_spec: Module spec of module to be returned - """ - module = importlib.util.module_from_spec(module_spec) - module_spec.loader.exec_module(module) - return module + if not len(plugin_arr) == 2: + logging.warning(bcolors.WARNING + "Malformed plugin map {}. Skipping.".format(plugin_map) + bcolors.ENDC) + return None, None + else: + return load_plugin_class(*plugin_arr) -def load_plugin_legacy(module_spec): - """ - Load a source file from a given spec. - - Args: - module_spec: Module spec of module to be returned - """ - module = importlib.util.module_from_spec(module_spec) - module_spec.loader.exec_module(module) - return module - class PluginMount(object): """ A mount-point for all loaded plugins. Attaches to a Microscope object. @@ -130,44 +87,45 @@ class PluginMount(object): """ def __init__(self, parent): self.parent = parent + self.plugins = [] print("Creating plugin mount") - def attach(self, plugin_module): + @property + def members(self): + plugin_array = [] + for obj_name in dir(self): + if not obj_name == "plugins" and not obj_name[:2] == '__': + obj = getattr(self, obj_name) + if isinstance(obj, MicroscopePlugin): + plugin_members = [member for member in inspect.getmembers(obj) if not member[0][:2] == '__'] + plugin_info = (obj_name, plugin_members) + plugin_array.append(plugin_info) + return plugin_array + + def attach(self, plugin_map): """ Attach a MicroscopePlugin instance to the plugin mount. Args: - plugin_module: A loaded module to be attached. Module can be loaded using :py:meth:`openflexure_microscope.plugins.load_plugin` + plugin_map (str): A plugin map describing the file or module to load a MicroscopePlugin child from. Maps should be in the format 'module.to.load:ClassName' or '/path/to/file:ClassName'. """ - print("LOADING MODULE {}".format(plugin_module.__name__)) + plugin_class, plugin_name = class_from_map(plugin_map) - if hasattr(plugin_module, 'PLUGINS') and isinstance(plugin_module.PLUGINS, dict): + if plugin_class and plugin_name: + plugin_object = plugin_class() - for plugin_name, plugin_class in plugin_module.PLUGINS.items(): + if hasattr(self, plugin_name): # If a plugin with the same name is already attached. + logging.warning(bcolors.WARNING + "A plugin named {} has already been loaded. Skipping {}.".format(plugin_name, plugin_map) + bcolors.ENDC) - plugin_object = plugin_class() - if hasattr(self, plugin_name): - warnings.warn("A plugin named {} has already been loaded. Skipping {}.".format(plugin_name, plugin_class)) - else: - setattr(self, plugin_name, plugin_object) + elif isinstance(plugin_object, MicroscopePlugin): # If plugin_object is an instance of MicroscopePlugin + # Attach plugin_object to the plugin mount + setattr(self, plugin_name, plugin_object) + self.plugins.append((plugin_name, plugin_object)) - # Grant plugin access to the hardware - assert(isinstance(plugin_object, MicroscopePlugin)) - plugin_object.microscope = self.parent + # Grant plugin access to the hardware + plugin_object.microscope = self.parent - print("Adding plugin: {}".format(plugin_name)) - else: - warnings.warn("No valid PLUGINS dictionary found in {}".format(plugin_module)) - - -class PluginGroup(): - """ - A class used to group plugin methods within a PluginMount. - - Currently useless aside from creating a namespace in which plugin methods will reside. - """ - def __init__(self): - pass + logging.info(bcolors.OKGREEN + "Plugin {} loaded as {}.".format(plugin_map, plugin_name) + bcolors.ENDC) class MicroscopePlugin(): @@ -177,5 +135,8 @@ class MicroscopePlugin(): Initially only defines an empty object for microscope. All plugins must be an instance of this class to successfully attach to PluginMount. """ + + api_views = {} # Initially empty dictionary of API views associated with the plugin + def __init__(self): self.microscope = None #: :py:class:`openflexure_microscope.microscope.Microscope`: Microscope object From e1b021583b725117829c06c85612027869fac727 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Wed, 12 Dec 2018 15:00:49 +0000 Subject: [PATCH 21/25] Rewritten plugin attacher for new system --- openflexure_microscope/microscope.py | 47 +++++++++++++++++++++------- 1 file changed, 35 insertions(+), 12 deletions(-) diff --git a/openflexure_microscope/microscope.py b/openflexure_microscope/microscope.py index bfb9737b..80fc4436 100644 --- a/openflexure_microscope/microscope.py +++ b/openflexure_microscope/microscope.py @@ -9,7 +9,7 @@ import numpy as np from openflexure_stage import OpenFlexureStage from .camera.pi import StreamingCamera -from .plugins import PluginMount, load_plugin, search_plugin_dirs +from .plugins import PluginMount from .config import load_config, save_config, convert_config @@ -22,16 +22,24 @@ class Microscope(object): Args: camera (:py:class:`openflexure_microscope.camera.pi.StreamingCamera`): camera object microscope (:py:class:`openflexure_stage.stage.OpenFlexureStage`): stage object - load_config (bool): Should a config file be automatically loaded on init? - config_path (str): Path to the config YAML file to be loaded. If None, defaults to USER_CONFIG_PATH. + config (dict): Dictionary of the runtime config to apply to the microscope. Does not automatically apply to camera and stage. + attach_plugins (bool): Should the microscope attach plugins listen in 'config' immediately. """ - def __init__(self, camera: StreamingCamera, stage: OpenFlexureStage): + def __init__(self, camera: StreamingCamera, stage: OpenFlexureStage, config: dict={}, attach_plugins: bool=True): + # Attach initial hardware (may be NoneTypes) self.attach(camera, stage) + # Store entire runtime-config + self.config = config + # Create plugin mountpoint self.plugin = PluginMount(self) #: :py:class:`openflexure_microscope.plugins.PluginMount`: Mounting point for all microscope plugins + # Attach plugins + if attach_plugins: + self.attach_plugins() + def __enter__(self): """Create microscope on context enter.""" return self @@ -75,22 +83,37 @@ class Microscope(object): elif not self.stage: logging.info("Attached dummy stage.") - def find_plugins(self, plugin_paths=[], include_default=True): + def attach_plugin_maps(self, plugin_maps): """ - Automatically search for plugins, and attach to self. + Attach plugins from a list of plugin map strings Args: - plugin_paths (list): List of strings of plugin directories. - include_default (bool): Also load plugins from the module default directory (DEFAULT_PLUGIN_PATH) + plugin_maps (list): List of strings of plugin maps. """ logging.debug("Attaching plugins...") - plugins_list = search_plugin_dirs(plugin_paths, include_default=include_default) - for i in plugins_list: - module = load_plugin(i) - self.plugin.attach(module) + for plugin_map in plugin_maps: + self.plugin.attach(plugin_map) logging.debug("Plugins attached.") + def attach_plugins(self): + """ + Automatically search for plugin maps in self.config, and attach. + """ + if 'plugins' in self.config: + self.attach_plugin_maps(self.config['plugins']) + else: + logging.warning("No plugins specified in microscoperc.yaml. Skipping.") + + def reload_plugins(self): + """ + Empty the plugin mount and re-attach from self.config. + """ + logging.info("Tearing down existing PluginMount...") + self.plugin = PluginMount(self) + logging.info("Repopulating PluginMount...") + self.attach_plugins() + # Create unified state @property def state(self): From 10d89e541b79224d27a769bd03e1fd96a10fa6bf Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Wed, 12 Dec 2018 15:01:10 +0000 Subject: [PATCH 22/25] Updated plugin tester --- tests/test_plugins.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 47d7815e..f65c37be 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -10,15 +10,13 @@ logging.basicConfig(stream=sys.stderr, level=logging.DEBUG) if __name__ == '__main__': openflexurerc = config.load_config() - microscope = Microscope(StreamingCamera(config=openflexurerc), OpenFlexureStage("/dev/ttyUSB0")) + print(openflexurerc) - microscope.find_plugins() # Automatically find microscope plugins + microscope = Microscope(StreamingCamera(config=openflexurerc), OpenFlexureStage("/dev/ttyUSB0"), config=openflexurerc) - print(dir(microscope.plugin)) + print(microscope.plugin.plugins) # Check that default tets plugins have loaded - print("RUNNING PLUGIN METHODS:") - microscope.plugin.test1.run() - microscope.plugin.test2.run() + print(microscope.plugin.default.identify()) microscope.close() From 5b7f6a0e44eb284c568b5655017a4aca35d27d24 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Wed, 12 Dec 2018 15:01:31 +0000 Subject: [PATCH 23/25] Get view plugins directly from attached microscope object plugins --- .../api/v1/blueprints/plugins.py | 60 +++++++++---------- 1 file changed, 27 insertions(+), 33 deletions(-) diff --git a/openflexure_microscope/api/v1/blueprints/plugins.py b/openflexure_microscope/api/v1/blueprints/plugins.py index 8a8c7b0b..485d3eee 100644 --- a/openflexure_microscope/api/v1/blueprints/plugins.py +++ b/openflexure_microscope/api/v1/blueprints/plugins.py @@ -1,50 +1,44 @@ from openflexure_microscope.api.utilities import parse_payload, get_from_payload, gen, get_bool -from openflexure_microscope.api.v1.views import MicroscopeView - -from openflexure_microscope.plugins import load_plugin, search_plugin_dirs +from openflexure_microscope.api.v1.views import MicroscopeViewPlugin from flask import Response, Blueprint, jsonify import logging, warnings -def add_endpoints(plugin_module, endpoint_dict): - """ - Fetch valid endpoints from a plugin_module - - Args: - plugin_module: A loaded module to be attached. Module can be loaded using :py:meth:`openflexure_microscope.plugins.load_plugin` - """ - if hasattr(plugin_module, 'ENDPOINTS') and isinstance(plugin_module.ENDPOINTS, dict): # If plugin contains valid endpoints - - for endpoint_name, endpoint_class in plugin_module.ENDPOINTS.items(): # For each defined endpoint - - if endpoint_name in endpoint_dict: # Check if endpoint name clashes - warnings.warn("An endpoint /{} has already been loaded. Skipping {}.".format(endpoint_name, endpoint_class)) - else: - endpoint_dict[endpoint_name] = endpoint_class # Add endpoint to main endpoint dictionary - else: - warnings.warn("No valid ENDPOINTS dictionary found in {}".format(plugin_module)) - - def construct_blueprint(microscope_obj, plugin_paths=[], include_default=True): blueprint = Blueprint('plugin_blueprint', __name__) - logging.debug("Attaching plugins...") - plugins_list = search_plugin_dirs(plugin_paths, include_default=include_default) + all_routes = [] - endpoint_dict = {} # Store all endpoints + # For each plugin attached to the microscope object + for plugin_name, plugin_obj in microscope_obj.plugin.plugins: - for plugin_file in plugins_list: - plugin_module = load_plugin(plugin_file) - add_endpoints(plugin_module, endpoint_dict) + if hasattr(plugin_obj, 'api_views') and isinstance(plugin_obj.api_views, dict): # If plugin contains valid endpoints - for endpoint_name, endpoint_class in endpoint_dict.items(): # For each valid endpoint + for view_route, view_class in plugin_obj.api_views.items(): # For each defined endpoint - blueprint.add_url_rule( - '/{}'.format(endpoint_name), - view_func=endpoint_class.as_view('plugin_{}'.format(endpoint_name), microscope=microscope_obj) - ) + # Remove all leading slashes from view route + while view_route[0] == '/': + view_route = view_route[1:] + + # Construct a full view route from the plugin name + full_view_route = "/{}/{}".format(plugin_name, view_route) + + if full_view_route not in all_routes and issubclass(view_class, MicroscopeViewPlugin): # Check if endpoint name clashes + all_routes.append(full_view_route) # Add route to main route dictionary + + # Add route to the plugins blueprint + blueprint.add_url_rule( + full_view_route, + view_func=view_class.as_view('plugin_{}'.format(view_route).replace('/', '_'), microscope=microscope_obj, plugin=plugin_obj) + ) + + else: + warnings.warn("An endpoint /{} has already been loaded. Skipping {}.".format(full_view_route, view_class)) + + else: + warnings.warn("No valid 'api_views' dictionary found in {}".format(plugin_obj)) return(blueprint) From 71b5c685ca6a308f1efae62c5190cae9597f0f86 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Wed, 12 Dec 2018 15:01:57 +0000 Subject: [PATCH 24/25] Created new MicroscopeViewPlugin class to give shortcut to an associated MicroscopePlugin --- openflexure_microscope/api/v1/views.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/openflexure_microscope/api/v1/views.py b/openflexure_microscope/api/v1/views.py index 43d12982..f223e592 100644 --- a/openflexure_microscope/api/v1/views.py +++ b/openflexure_microscope/api/v1/views.py @@ -10,4 +10,17 @@ class MicroscopeView(MethodView): """ self.microscope = microscope - MethodView.__init__(self, **kwargs) \ No newline at end of file + MethodView.__init__(self, **kwargs) + + +class MicroscopeViewPlugin(MicroscopeView): + + def __init__(self, microscope, plugin=None, **kwargs): + """ + Create a generic MethodView with a globally available + microscope object passed as an argument, and a plugin + reference stored in 'self'. Initially None + """ + self.plugin = plugin + + MicroscopeView.__init__(self, microscope=microscope, **kwargs) \ No newline at end of file From d0e738c6af970b597a25491ff2b7b6b50641a078 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Wed, 12 Dec 2018 15:02:19 +0000 Subject: [PATCH 25/25] Load config and plugins at startup --- openflexure_microscope/api/app.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/openflexure_microscope/api/app.py b/openflexure_microscope/api/app.py index 3a6c184e..413d98cb 100644 --- a/openflexure_microscope/api/app.py +++ b/openflexure_microscope/api/app.py @@ -30,7 +30,8 @@ import logging, sys logging.basicConfig(stream=sys.stderr, level=logging.DEBUG) # Create a dummy microscope object, with no hardware attachments -api_microscope = Microscope(None, None) +openflexurerc = config.load_config() # Load default user config +api_microscope = Microscope(None, None, config=openflexurerc) logging.debug("Created an empty microscope in global.") @@ -66,9 +67,8 @@ for code in default_exceptions: @app.before_first_request def attach_microscope(): # Create the microscope object globally (common to all spawned server threads) - global api_microscope + global api_microscope, openflexurerc logging.debug("First request made. Populating microscope with hardware...") - openflexurerc = config.load_config() # Load default user config logging.debug("Creating camera object...") api_camera = StreamingCamera(config=openflexurerc) @@ -81,9 +81,6 @@ def attach_microscope(): api_stage ) - logging.debug("Attaching plugins to microscope...") - api_microscope.find_plugins() # Automatically find microscope plugins - logging.debug("Microscope successfully attached!")