diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..5f08879f --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +openflexure_microscope/api/static/** filter=lfs diff=lfs merge=lfs -text diff --git a/docs/source/conf.py b/docs/source/conf.py index ca5cecdf..cf5293a7 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -226,7 +226,7 @@ intersphinx_mapping = { "openflexure_stage": ("https://openflexure-stage.readthedocs.io/en/latest/", None), "picamera": ("https://picamera.readthedocs.io/en/release-1.13/", None), "marshmallow": ("https://marshmallow.readthedocs.io/en/stable/", None), - "webargs": ("https://webargs.readthedocs.io/en/latest/", None) + "webargs": ("https://webargs.readthedocs.io/en/latest/", None), } # -- Options for todo extension ---------------------------------------------- diff --git a/docs/source/extensions/example_extension/01b_basic_structure_subclass.py b/docs/source/extensions/example_extension/01b_basic_structure_subclass.py new file mode 100644 index 00000000..6177a2a9 --- /dev/null +++ b/docs/source/extensions/example_extension/01b_basic_structure_subclass.py @@ -0,0 +1,41 @@ +from labthings.server.extensions import BaseExtension +from labthings.server.find import find_component + + +# Create the extension class +class MyExtension(BaseExtension): + def __init__(self): + + # Create some instance variable + self.state_variable = "An example of a persistant instance variable" + + # Superclass init function + super().__init__("com.myname.myextension", version="0.0.0") + + def identify(self): + """ + Demonstrate access to Microscope.camera, and Microscope.stage + """ + microscope = find_component("org.openflexure.microscope") + + response = ( + f"My name is {microscope.name}. " + f"My parent camera is {microscope.camera}, " + f"and my parent stage is {microscope.stage}." + ) + + return response + + def rename(self, new_name): + """ + Rename the microscope + """ + + microscope = find_component("org.openflexure.microscope") + + microscope.name = new_name + microscope.save_settings() + + +# Create your extension object +my_extension = MyExtension() diff --git a/docs/source/extensions/example_extension/03_marshaling_data.py b/docs/source/extensions/example_extension/03_marshaling_data.py index f9ad668d..d3179c10 100644 --- a/docs/source/extensions/example_extension/03_marshaling_data.py +++ b/docs/source/extensions/example_extension/03_marshaling_data.py @@ -14,7 +14,7 @@ from labthings.server import fields class MicroscopeIdentifySchema(Schema): name = fields.String() # Microscopes name id = fields.UUID() # Microscopes unique ID - status = fields.Dict() # Status dictionary + state = fields.Dict() # Status dictionary camera = fields.String() # Camera object (represented as a string) stage = fields.String() # Stage object (represented as a string) diff --git a/docs/source/extensions/example_extension/04_properties.py b/docs/source/extensions/example_extension/04_properties.py index 45ee6c18..922270a5 100644 --- a/docs/source/extensions/example_extension/04_properties.py +++ b/docs/source/extensions/example_extension/04_properties.py @@ -19,7 +19,7 @@ from labthings.server import fields class MicroscopeIdentifySchema(Schema): name = fields.String() # Microscopes name id = fields.UUID() # Microscopes unique ID - status = fields.Dict() # Status dictionary + state = fields.Dict() # Status dictionary camera = fields.String() # Camera object (represented as a string) stage = fields.String() # Stage object (represented as a string) diff --git a/docs/source/extensions/example_extension/05_actions.py b/docs/source/extensions/example_extension/05_actions.py index 027580e8..dc3826a0 100644 --- a/docs/source/extensions/example_extension/05_actions.py +++ b/docs/source/extensions/example_extension/05_actions.py @@ -24,7 +24,7 @@ import io # Used in our capture action class MicroscopeIdentifySchema(Schema): name = fields.String() # Microscopes name id = fields.UUID() # Microscopes unique ID - status = fields.Dict() # Status dictionary + state = fields.Dict() # Status dictionary camera = fields.String() # Camera object (represented as a string) stage = fields.String() # Stage object (represented as a string) diff --git a/docs/source/extensions/lifecycle_hooks.rst b/docs/source/extensions/lifecycle_hooks.rst new file mode 100644 index 00000000..451618a4 --- /dev/null +++ b/docs/source/extensions/lifecycle_hooks.rst @@ -0,0 +1,53 @@ +Lifecycle Hooks +=============== + +Introduction +------------ +In some cases it is useful to have functions triggered by events in an extensions lifecycle. Currently two such lifecycle events can be used, ``on_register``, and ``on_component``. + +``on_register`` +--------------- + +The ``on_register`` method can be used to have a function call as soon as the extension has been successfully registered to the microscope. For example: + +.. code-block:: python + + class MyExtension(BaseExtension): + def __init__(self): + + # Track if the extension has been registered + self.registered = False + + # Add lifecycle hooks + self.on_register(self.on_register_handler, args=(), kwargs={}) + + # Superclass init function + super().__init__("com.myname.myextension", version="0.0.0") + + def on_register_handler(self, *args, **kwargs): + self.registered = True + print("Extension has been registered!") + + +``on_component`` +---------------- + +The ``on_component`` method can be used to have a function call as soon as a particular LabThings component has been added. This can be used, for example, to get information about the microscope instance as soon as it is available. For example: + +.. code-block:: python + + class MyExtension(BaseExtension): + def __init__(self): + + # Hold a reference to the microscope object as soon as it is available + self.microscope = None + + # Add lifecycle hooks + self.on_component("com.myname.myextension", self.on_microscope_handler) + + # Superclass init function + super().__init__("org.openflexure.microscope", version="0.0.0") + + def on_microscope_handler(self, microscope_object): + print("Microscope object has been found!") + self.microscope = microscope_object \ No newline at end of file diff --git a/docs/source/extensions/marshaling.rst b/docs/source/extensions/marshaling.rst index 10ab188e..1f09162a 100644 --- a/docs/source/extensions/marshaling.rst +++ b/docs/source/extensions/marshaling.rst @@ -107,7 +107,7 @@ We start by creating a schema to describe how to serialise a :py:class:`openflex class MicroscopeIdentifySchema(Schema): name = fields.String() # Microscopes name id = fields.UUID() # Microscopes unique ID - status = fields.Dict() # Status dictionary + state = fields.Dict() # Status dictionary camera = fields.String() # Camera object (represented as a string) stage = fields.String() # Stage object (represented as a string) diff --git a/docs/source/extensions/structure.rst b/docs/source/extensions/structure.rst index a289fa8b..053d4a4a 100644 --- a/docs/source/extensions/structure.rst +++ b/docs/source/extensions/structure.rst @@ -24,3 +24,13 @@ Once this extension is loaded, any other extensions will have access to your met # Call a function from your extension if my_found_extension: my_found_extension.identify() + + +Subclassing ``BaseExtension`` +------------------------------- + +The syntax used above allows novice programmers to easily start building extensions, without having to deal with subclassing. However, for more complex extensions which require persistent state, subclassing :py:class:`labthings.server.extensions.BaseExtension` is recommended. + +The same simple extension as seen above can be written using subclassing: + +.. literalinclude:: ./example_extension/01b_basic_structure_subclass.py diff --git a/docs/source/plugins.rst b/docs/source/plugins.rst index ad28657e..bb865f26 100644 --- a/docs/source/plugins.rst +++ b/docs/source/plugins.rst @@ -11,4 +11,5 @@ Developing API Extensions ./extensions/properties.rst ./extensions/actions.rst ./extensions/tasks_locks.rst - ./extensions/ev_gui.rst \ No newline at end of file + ./extensions/ev_gui.rst + ./extensions/lifecycle_hooks.rst \ No newline at end of file diff --git a/openflexure_microscope/__init__.py b/openflexure_microscope/__init__.py index d8fd15ec..e69de29b 100644 --- a/openflexure_microscope/__init__.py +++ b/openflexure_microscope/__init__.py @@ -1,6 +0,0 @@ -__all__ = ["Microscope", "config", "utilities"] -__version__ = "0.1.0" - -from .microscope import Microscope -from . import config -from . import utilities diff --git a/openflexure_microscope/api/app.py b/openflexure_microscope/api/app.py index 6ca80e75..e1991132 100644 --- a/openflexure_microscope/api/app.py +++ b/openflexure_microscope/api/app.py @@ -2,7 +2,7 @@ import time import atexit -import logging +import logging, logging.handlers import os import pkg_resources @@ -16,10 +16,10 @@ from openflexure_microscope.api.utilities import list_routes, init_default_exten from openflexure_microscope.config import JSONEncoder from openflexure_microscope.paths import ( - OPENFLEXURE_ETC_PATH, OPENFLEXURE_VAR_PATH, OPENFLEXURE_EXTENSIONS_PATH, settings_file_path, + logs_file_path, ) from labthings.server.quick import create_app @@ -30,37 +30,29 @@ from openflexure_microscope.api.microscope import default_microscope as api_micr from openflexure_microscope.api.v2 import views # Handle logging -is_gunicorn = "gunicorn" in os.environ.get("SERVER_SOFTWARE", "") - -DEFAULT_LOGFILE = settings_file_path("openflexure_microscope.log") +DEFAULT_LOGFILE = logs_file_path("openflexure_microscope.log") logger = logging.getLogger() -if (__name__ == "__main__") or (not is_gunicorn): - # If imported, but not by gunicorn - print("Letting sys handle logs") - logger.setLevel(logging.DEBUG) -else: - # Direct standard Python logging to file and console - error_formatter = logging.Formatter( - "[%(asctime)s] [%(threadName)s] [%(levelname)s] %(message)s" - ) - rotating_logfile = logging.handlers.RotatingFileHandler( - DEFAULT_LOGFILE, maxBytes=1_000_000, backupCount=7 - ) +error_formatter = logging.Formatter( + "[%(asctime)s] [%(threadName)s] [%(levelname)s] %(message)s" +) - error_handlers = [rotating_logfile, logging.StreamHandler()] +rotating_logfile = logging.handlers.RotatingFileHandler( + DEFAULT_LOGFILE, maxBytes=1_000_000, backupCount=7 +) - for handler in error_handlers: - handler.setFormatter(error_formatter) - logger.addHandler(handler) +error_handlers = [rotating_logfile, logging.StreamHandler()] - logger.setLevel(logging.getLogger("gunicorn.error").level) +for handler in error_handlers: + handler.setFormatter(error_formatter) + logger.addHandler(handler) + +logger.setLevel(logging.INFO) # Log server paths being used logging.info(f"Running with data path {OPENFLEXURE_VAR_PATH}") -logging.info(f"Running with server path {OPENFLEXURE_ETC_PATH}") # Create flask app app, labthing = create_app( @@ -69,6 +61,7 @@ app, labthing = create_app( title=f"OpenFlexure Microscope {api_microscope.name}", description="Test LabThing-based API for OpenFlexure Microscope", version=pkg_resources.get_distribution("openflexure_microscope").version, + flask_kwargs={"static_url_path": ""}, ) # Enable CORS for some routes outside of LabThings @@ -93,15 +86,21 @@ labthing.add_root_link(views.CaptureList, "captures") labthing.add_view(views.CaptureView, f"/captures/") labthing.add_view(views.CaptureDownload, f"/captures//download/") labthing.add_view(views.CaptureTags, f"/captures//tags") -labthing.add_view(views.CaptureMetadata, f"/captures//metadata") +labthing.add_view(views.CaptureAnnotations, f"/captures//annotations") + +# Attach settings and state resources +labthing.add_view(views.SettingsProperty, f"/instrument/settings") +labthing.add_root_link(views.SettingsProperty, "instrumentSettings") +labthing.add_view(views.NestedSettingsProperty, "/instrument/settings/") +labthing.add_view(views.StateProperty, "/instrument/state") +labthing.add_view(views.NestedStateProperty, "/instrument/state/") +labthing.add_root_link(views.StateProperty, "instrumentState") +labthing.add_view(views.ConfigurationProperty, "/instrument/configuration") +labthing.add_view( + views.NestedConfigurationProperty, "/instrument/configuration/" +) +labthing.add_root_link(views.ConfigurationProperty, "instrumentConfiguration") -# Attach settings and status resources -labthing.add_view(views.SettingsProperty, f"/settings") -labthing.add_root_link(views.SettingsProperty, "settings") -labthing.add_view(views.NestedSettingsProperty, "/settings/") -labthing.add_view(views.StatusProperty, "/status") -labthing.add_view(views.NestedStatusProperty, "/status/") -labthing.add_root_link(views.StatusProperty, "status") # Attach streams resources labthing.add_view(views.MjpegStream, f"/streams/mjpeg") @@ -109,12 +108,18 @@ labthing.add_view(views.SnapshotStream, f"/streams/snapshot") # Attach microscope action resources labthing.add_view(views.actions.ActionsView, "/actions") +labthing.add_root_link(views.actions.ActionsView, "actions") for name, action in views.enabled_root_actions().items(): view_class = action["view_class"] rule = action["rule"] labthing.add_view(view_class, f"/actions{rule}") +@app.route("/") +def openflexure_ev(): + return app.send_static_file("index.html") + + @app.route("/routes") @cross_origin() def routes(): diff --git a/openflexure_microscope/api/default_extensions/__init__.py b/openflexure_microscope/api/default_extensions/__init__.py index 6d9a592d..577f10ba 100644 --- a/openflexure_microscope/api/default_extensions/__init__.py +++ b/openflexure_microscope/api/default_extensions/__init__.py @@ -4,6 +4,7 @@ import traceback from .autofocus import autofocus_extension_v2 from .scan import scan_extension_v2 from .zip_builder import zip_extension_v2 +from .autostorage import autostorage_extension_v2 # "Gracefully" handle cases where picamera cannot be imported (eg test server) try: @@ -11,4 +12,4 @@ try: except Exception as e: logging.error( f"Exception loading builtin extension picamera_autocalibrate: \n{traceback.format_exc()}" - ) \ No newline at end of file + ) diff --git a/openflexure_microscope/api/default_extensions/autofocus.py b/openflexure_microscope/api/default_extensions/autofocus.py index 510272b7..1da9960b 100644 --- a/openflexure_microscope/api/default_extensions/autofocus.py +++ b/openflexure_microscope/api/default_extensions/autofocus.py @@ -1,10 +1,7 @@ from labthings.server.find import find_component from labthings.server.extensions import BaseExtension from labthings.server.view import View -from labthings.server.decorators import ( - ThingAction, - ThingProperty, -) +from labthings.server.decorators import ThingAction, ThingProperty, marshal_task from openflexure_microscope.devel import JsonResponse, request, jsonify, taskify, abort from openflexure_microscope.utilities import set_properties @@ -198,7 +195,7 @@ def move_and_find_focus(microscope, dz): def fast_autofocus(microscope, dz=2000, backlash=None): """Perform a down-up-down-up autofocus""" - with monitor_sharpness(microscope) as m: + with monitor_sharpness(microscope) as m, microscope.camera.lock: i, z = m.focus_rel(-dz / 2) i, z = m.focus_rel(dz) fz = m.sharpest_z_on_move(i) @@ -211,7 +208,7 @@ def fast_autofocus(microscope, dz=2000, backlash=None): def fast_up_down_up_autofocus( - microscope, dz=2000, target_z=0, initial_move_up=True, mini_backlash=150 + microscope, dz=2000, target_z=0, initial_move_up=True, mini_backlash=25 ): """Autofocus by measuring on the way down, and moving back up with feedback. @@ -242,7 +239,7 @@ def fast_up_down_up_autofocus( from the starting position. Mostly useful if you're able to combine the initial move with something else, e.g. moving to the next scan point. - mini_backlash: (optional, default 50) is a small extra move made in step + mini_backlash: (optional, default 25) is a small extra move made in step 3 to help counteract backlash. It should be small enough that you would always expect there to be greater backlash than this. Too small might slightly hurt accuracy, but is unlikely to be a big issue. Too big @@ -308,6 +305,7 @@ class AutofocusAPI(View): Run a standard autofocus """ + @marshal_task def post(self): payload = JsonResponse(request) microscope = find_component("org.openflexure.microscope") @@ -323,7 +321,7 @@ class AutofocusAPI(View): task = taskify(autofocus)(microscope, dz) # return a handle on the autofocus task - return jsonify(task.state), 201 + return task else: abort(503, "No stage connected. Unable to autofocus.") @@ -335,6 +333,7 @@ class FastAutofocusAPI(View): Run a fast autofocus """ + @marshal_task def post(self): payload = JsonResponse(request) microscope = find_component("org.openflexure.microscope") @@ -344,24 +343,27 @@ class FastAutofocusAPI(View): # Figure out the parameters to use dz = payload.param("dz", default=2000, convert=int) - backlash = payload.param("backlash", default=0, convert=int) + backlash = payload.param("backlash", default=25, convert=int) if backlash < 0: backlash = 0 if microscope.has_real_stage(): logging.info("Running autofocus...") - task = taskify(fast_autofocus)(microscope, dz, backlash=backlash) + task = taskify(fast_up_down_up_autofocus)(microscope, dz=dz, mini_backlash=backlash) # return a handle on the autofocus task - return jsonify(task.state), 201 + return task else: abort(503, "No stage connected. Unable to autofocus.") -autofocus_extension_v2 = BaseExtension("org.openflexure.autofocus", version="2.0.0-beta.1") +autofocus_extension_v2 = BaseExtension( + "org.openflexure.autofocus", version="2.0.0" +) autofocus_extension_v2.add_method(fast_autofocus, "fast_autofocus") +autofocus_extension_v2.add_method(fast_up_down_up_autofocus, "fast_up_down_up_autofocus") autofocus_extension_v2.add_method(autofocus, "autofocus") autofocus_extension_v2.add_view(MeasureSharpnessAPI, "/measure_sharpness") diff --git a/openflexure_microscope/api/default_extensions/autostorage.py b/openflexure_microscope/api/default_extensions/autostorage.py new file mode 100644 index 00000000..f92d4d99 --- /dev/null +++ b/openflexure_microscope/api/default_extensions/autostorage.py @@ -0,0 +1,271 @@ +from labthings.server.extensions import BaseExtension +from labthings.server.view import View +from labthings.server.decorators import ThingProperty, PropertySchema, use_args +from labthings.server import fields +from labthings.server.find import find_component + +from openflexure_microscope.paths import settings_file_path, check_rw +from openflexure_microscope.config import OpenflexureSettingsFile +from openflexure_microscope.camera.base import BASE_CAPTURE_PATH +from openflexure_microscope.camera.capture import build_captures_from_exif + +from openflexure_microscope.api.utilities.gui import build_gui + +from flask import abort +import logging +import os +import psutil + +from sys import platform + + +AS_SETTINGS_PATH = settings_file_path("autostorage_settings.json") + + +def get_partitions(): + return [disk.mountpoint for disk in psutil.disk_partitions() if "rw" in disk.opts] + + +def get_permissive_partitions(): + return [partition for partition in get_partitions() if check_rw(partition)] + + +def get_permissive_locations(): + return [ + (partition, os.path.join(partition, "openflexure", "data", "micrographs")) + for partition in get_permissive_partitions() + ] + + +def get_current_location(camera): + return camera.paths.get("default") + + +def set_current_location(camera, location: str): + if not os.path.isdir(location): + os.makedirs(location) + logging.debug("Updating location...") + camera.paths.update({"default": location}) + logging.debug("Rebuilding captures...") + camera.rebuild_captures() + logging.debug("Capture location changed successfully.") + + +def get_default_location(): + return BASE_CAPTURE_PATH + + +def get_all_locations(): + locations = {} + # If default is not already listed (e.g. if it's currently set) + if get_default_location() not in locations.values(): + locations["Default"] = get_default_location() + + for ppartition, plocation in get_permissive_locations(): + pdrive = os.path.splitdrive(plocation)[0] + if not ( + pdrive # If path actually has a drive (basically just Windows?) + and any( # And shares a common drive with an existing location + [ + pdrive == os.path.splitdrive(location)[0] + for location in locations.values() + ] + ) + ): + locations[ppartition] = plocation + + # Strip out Nones + return {k: v for k, v in locations.items() if v} + + +class CaptureStorageLocation: + def __init__(self, mountpoint): + pass + + +class AutostorageExtension(BaseExtension): + def __init__(self): + BaseExtension.__init__( + self, + "org.openflexure.autostorage", + version="2.0.0", + description="Handle switching capture storage devices", + ) + + # We'll store a reference to a camera object, who's capture paths will be modified + self.camera = None + + self.initial_location = get_default_location() + + # Register the on_microscope function to run when the microscope is attached + self.on_component("org.openflexure.microscope", self.on_microscope) + + def on_microscope(self, microscope_obj): + """Function to automatically call when the parent LabThing has a microscope attached.""" + logging.debug(f"Autostorage extension found microscope {microscope_obj}") + if hasattr(microscope_obj, "camera"): + logging.debug(f"Autostorage extension bound to camera {self.camera}") + + # Store a reference to the camera + self.camera = microscope_obj.camera + # Store the initial storage location + self.initial_location = get_current_location(self.camera) + + # If preferred path does not exist, or cannot be written to + self.check_location(self.initial_location) + + logging.debug(self.get_locations()) + + def check_location(self, location=None): + if not location: + location = get_current_location(self.camera) + # If preferred path does not exist, or cannot be written to + if not (os.path.isdir(location) and check_rw(location)): + logging.error( + f"Preferred capture path {location} is missing or cannot be written to. Restoring defaults." + ) + # Reset the storage location to default + set_current_location(self.camera, get_default_location()) + + def get_locations(self): + if self.camera: + locations = get_all_locations() + + current_location = get_current_location(self.camera) + if current_location not in locations.values(): + locations.update({"Custom": current_location}) + # Add location from the cameras settings file + return locations + else: + return {} + + def get_preferred_key(self): + current = get_current_location(self.camera) + locations = self.get_locations() + + matches = [k for k, v in locations.items() if v == current] + + if len(matches) > 1: + logging.warning( + "Multiple path matches found. Weird, but carrying on using zeroth." + ) + + return matches[0] + + def set_preferred_key(self, new_path_key: str): + if not new_path_key in self.get_locations().keys(): + raise KeyError(f"No location named {new_path_key}") + + location = self.get_locations().get(new_path_key) + set_current_location(self.camera, location) + + def key_to_title(self, path_key: str): + if not path_key in self.get_locations().keys(): + raise KeyError(f"No location named {path_key}") + + return f"{path_key} ({self.get_locations().get(path_key)})" + + def title_to_key(self, path_title: str): + matches = [] + for loc_key in self.get_locations().keys(): + if path_title.startswith(loc_key): + matches.append(loc_key) + + if len(matches) > 1: + logging.warning( + "Multiple path matches found. Weird, but carrying on using zeroth." + ) + + return matches[0] + + def get_titles(self): + return [self.key_to_title(key) for key in self.get_locations().keys()] + + def get_preferred_title(self): + return self.key_to_title(self.get_preferred_key()) + + +autostorage_extension_v2 = AutostorageExtension() + + +@ThingProperty +class GetLocationsView(View): + def get(self): + global autostorage_extension_v2 + + autostorage_extension_v2.check_location() + return autostorage_extension_v2.get_locations() + + +@ThingProperty +@PropertySchema(fields.String(required=True, example="Default")) +class PreferredLocationView(View): + def get(self): + global autostorage_extension_v2 + + autostorage_extension_v2.check_location() + return autostorage_extension_v2.get_preferred_key() + + def post(self, new_path_key): + global autostorage_extension_v2 + microscope = find_component("org.openflexure.microscope") + + if not microscope: + abort(503, "No microscope connected. Unable to autofocus.") + + autostorage_extension_v2.check_location() + autostorage_extension_v2.set_preferred_key(new_path_key) + microscope.save_settings() + + +class PreferredLocationGUIView(View): + @use_args({"new_path_title": fields.String(required=True)}) + def post(self, args): + global autostorage_extension_v2 + + new_path_title = args.get("new_path_title") + logging.debug(new_path_title) + + new_path_key = autostorage_extension_v2.title_to_key(new_path_title) + logging.debug(f"{new_path_key}") + + autostorage_extension_v2.check_location() + autostorage_extension_v2.set_preferred_key(new_path_key) + + return new_path_title + + +def dynamic_form(): + global autostorage_extension_v2 + autostorage_extension_v2.check_location() + return { + "icon": "sd_storage", + "title": "Storage", + "forms": [ + { + "name": "Autostorage", + "isCollapsible": False, + "isTask": False, + "route": "/location-from-title", + "emitOnResponse": "globalUpdateCaptures", + "submitLabel": "Set path", + "schema": [ + { + "fieldType": "selectList", + "name": "new_path_title", + "label": "Capture storage path", + "options": autostorage_extension_v2.get_titles(), + "value": autostorage_extension_v2.get_preferred_title(), + } + ], + } + ], + } + + +autostorage_extension_v2.add_view(GetLocationsView, "list-locations") +autostorage_extension_v2.add_view(PreferredLocationView, "location") +autostorage_extension_v2.add_view(PreferredLocationGUIView, "location-from-title") +autostorage_extension_v2.add_meta( + "gui", build_gui(dynamic_form, autostorage_extension_v2) +) diff --git a/openflexure_microscope/api/default_extensions/picamera_autocalibrate/extension.py b/openflexure_microscope/api/default_extensions/picamera_autocalibrate/extension.py index 2ac16466..343a2c02 100644 --- a/openflexure_microscope/api/default_extensions/picamera_autocalibrate/extension.py +++ b/openflexure_microscope/api/default_extensions/picamera_autocalibrate/extension.py @@ -1,10 +1,7 @@ from labthings.server.view import View from labthings.server.find import find_component from labthings.server.extensions import BaseExtension -from labthings.server.decorators import ( - marshal_task, - ThingAction, -) +from labthings.server.decorators import marshal_task, ThingAction from labthings.core.tasks import taskify @@ -24,8 +21,8 @@ def recalibrate(microscope): """ scamera = microscope.camera with scamera.lock: - assert not scamera.status["record_active"], "Can't recalibrate while recording!" - streaming = scamera.status["stream_active"] + assert not scamera.record_active, "Can't recalibrate while recording!" + streaming = scamera.stream_active if streaming: logging.info("Stopping stream before recalibration") scamera.stop_stream_recording(resolution=(640, 480)) diff --git a/openflexure_microscope/api/default_extensions/scan.py b/openflexure_microscope/api/default_extensions/scan.py index de57511e..a5f82f52 100644 --- a/openflexure_microscope/api/default_extensions/scan.py +++ b/openflexure_microscope/api/default_extensions/scan.py @@ -1,20 +1,14 @@ import itertools import logging import uuid +import datetime from typing import Tuple from functools import reduce from openflexure_microscope.camera.base import generate_basename -from labthings.server.find import ( - find_component, - find_extension, -) +from labthings.server.find import find_component, find_extension from labthings.server.extensions import BaseExtension -from labthings.server.decorators import ( - marshal_task, - use_args, - ThingAction, -) +from labthings.server.decorators import marshal_task, use_args, ThingAction from labthings.server import fields from openflexure_microscope.devel import taskify, abort, update_task_progress @@ -78,12 +72,12 @@ def progress(): def capture( microscope, basename, - scan_id, temporary: bool = False, use_video_port: bool = False, resize: Tuple[int, int] = None, bayer: bool = False, metadata: dict = {}, + annotations: dict = {}, tags: list = [], ): @@ -101,16 +95,14 @@ def capture( output.file, use_video_port=use_video_port, resize=resize, bayer=bayer ) - # Affix metadata - if "scan" not in tags: - tags.append("scan") - # Inject system metadata - output.put_metadata(microscope.metadata, system=True) + output.put_metadata({"instrument": microscope.metadata}) # Insert custom metadata output.put_metadata(metadata) + # Insert custom metadata + output.put_annotations(annotations) # Insert custom tags output.put_tags(tags) @@ -122,7 +114,7 @@ def tile( microscope, basename: str = None, temporary: bool = False, - step_size: int = [2000, 1500, 100], + stride_size: int = [2000, 1500, 100], grid: list = [3, 3, 5], style="raster", autofocus_dz: int = 50, @@ -131,13 +123,13 @@ def tile( bayer: bool = False, fast_autofocus=False, metadata: dict = {}, + annotations: dict = {}, tags: list = [], ): global _images_to_be_captured global _images_captured_so_far # Keep task progress - # TODO: Make this line not nasty _images_to_be_captured = reduce((lambda x, y: x * y), grid) _images_captured_so_far = 0 @@ -145,28 +137,22 @@ def tile( if not basename: basename = generate_basename() - # Generate a stack ID - scan_id = uuid.uuid4() - # Store initial position initial_position = microscope.stage.position - # Add scan metadata - if "time" not in metadata: - metadata["time"] = generate_basename() - - metadata.update( - { - "scan_id": scan_id, - "basename": basename, - "scan_parameters": { - "step_size": step_size, - "grid": grid, - "style": style, - "autofocus_dz": autofocus_dz, - }, + # Add dataset metadata + dataset_d = { + "dataset": { + "id": uuid.uuid4(), + "type": "xyzScan", + "name": basename, + "acquisitionDate": datetime.datetime.now().isoformat(), + "strideSize": stride_size, + "grid": grid, + "style": style, + "autofocusDz": autofocus_dz, } - ) + } # Check if autofocus is enabled autofocus_extension = find_extension("org.openflexure.autofocus") @@ -180,25 +166,19 @@ def tile( else: autofocus_enabled = False - z_stack_dz = ( - grid[2] * step_size[2] if grid[2] > 1 else 0 - ) # shorthand for Z stack range - # Construct an x-y grid (worry about z later) - x_y_grid = construct_grid(initial_position, step_size[:2], grid[:2], style=style) + x_y_grid = construct_grid(initial_position, stride_size[:2], grid[:2], style=style) # Keep the initial Z position the same as our current position - next_z = initial_position[2] - if fast_autofocus: # If fast autofocus is enabled, make - next_z += autofocus_dz / 2 # sure we start from the top of the range - initial_z = next_z # Save this value for use in raster scans + initial_z = initial_position[2] + next_z = initial_z # Save this value for use in raster scans # Now step through each point in the x-y coordinate array for line in x_y_grid: # If rastering, rather than snake (or eventually spiral) # Return focus to initial position if style == "raster": - next_z = initial_z + next_z = initial_z # Reset z position at start of each new row logging.debug("Returning to initial z position") microscope.stage.move_abs( [line[0][0], line[0][1], next_z] @@ -211,31 +191,29 @@ def tile( # Refocus if autofocus_enabled: if fast_autofocus: - autofocus_extension.fast_autofocus( - dz=autofocus_dz, - target_z=-z_stack_dz / 2.0, # Finish below the focus - initial_move_up=False, # We're already at the top of the scan + # Run fast autofocus. Client should provide dz ~ 2000 + autofocus_extension.fast_up_down_up_autofocus( + microscope, dz=autofocus_dz ) - # TODO: save the focus data for future reference? Use it for diagnostics? else: - logging.debug("Running autofocus") + # Run slow autofocus. Client should provide dz ~ 50 autofocus_extension.autofocus( range(-3 * autofocus_dz, 4 * autofocus_dz, autofocus_dz) ) logging.debug("Finished autofocus") - time.sleep(1) # TODO: Remove + time.sleep(1) # If we're not doing a z-stack, just capture if grid[2] <= 1: capture( microscope, basename, - scan_id, temporary=temporary, use_video_port=use_video_port, resize=resize, bayer=bayer, - metadata=metadata, + metadata=dataset_d, + annotations=annotations, tags=tags, ) # Update task progress @@ -247,27 +225,17 @@ def tile( microscope=microscope, basename=basename, temporary=temporary, - scan_id=scan_id, - step_size=step_size[2], + step_size=stride_size[2], steps=grid[2], - center=not fast_autofocus, # fast_autofocus does this for us! - return_to_start=not fast_autofocus, use_video_port=use_video_port, resize=resize, bayer=bayer, - metadata=metadata, + metadata=dataset_d, + annotations=annotations, tags=tags, ) # Make sure we use our current best estimate of focus (i.e. the current position) next point next_z = microscope.stage.position[2] - if fast_autofocus: - next_z += ( - autofocus_dz / 2 - ) # Fast autofocus requires us to start at the top of the range - if grid[2] > 1: - next_z -= int( - grid[2] / 2.0 * step_size[2] - ) # Z stacking means we're higher up to start with logging.debug("Returning to {}".format(initial_position)) microscope.stage.move_abs(initial_position) @@ -277,52 +245,40 @@ def stack( microscope, basename: str = None, temporary: bool = False, - scan_id: str = None, step_size: int = 100, steps: int = 5, - center: bool = True, return_to_start: bool = True, use_video_port: bool = False, resize: Tuple[int, int] = None, bayer: bool = False, metadata: dict = {}, + annotations: dict = {}, tags: list = [], ): global _images_captured_so_far - # Generate a basename if none given - if not basename: - basename = generate_basename() - - # Generate a stack ID - if not scan_id: - scan_id = uuid.uuid4() - - # Add scan metadata - if not "time" in metadata: - metadata["time"] = generate_basename() - # Store initial position initial_position = microscope.stage.position + logging.debug(f"Starting z-stack from position {microscope.stage.position}") with microscope.lock: # Move to center scan - if center: - logging.debug("Moving to starting position") - microscope.stage.move_rel([0, 0, int((-step_size * steps) / 2)]) + logging.debug("Moving to z-stack starting position") + microscope.stage.move_rel([0, 0, int((-step_size * steps) / 2)]) + logging.debug(f"Starting scan from position {microscope.stage.position}") for i in range(steps): time.sleep(0.1) - logging.debug("Capturing...") + logging.debug(f"Capturing from position {microscope.stage.position}") capture( microscope, basename, - scan_id, temporary=temporary, use_video_port=use_video_port, resize=resize, bayer=bayer, metadata=metadata, + annotations=annotations, tags=tags, ) # Update task progress @@ -344,16 +300,18 @@ def stack( class TileScanAPI(View): @use_args( { - "filename": fields.String(), + "filename": fields.String(missing=None, example=None), "temporary": fields.Boolean(missing=False), - "step_size": fields.List(fields.Integer, missing=[2000, 1500, 100]), - "grid": fields.List(fields.Integer, missing=[3, 3, 5]), + "stride_size": fields.List( + fields.Integer, missing=[2000, 1500, 100], example=[2000, 1500, 100] + ), + "grid": fields.List(fields.Integer, missing=[3, 3, 3], example=[3, 3, 3]), "style": fields.String(missing="raster"), "autofocus_dz": fields.Integer(missing=50), "fast_autofocus": fields.Boolean(missing=False), "use_video_port": fields.Boolean(missing=False), "bayer": fields.Boolean(missing=False), - "metadata": fields.Dict(missing={}), + "annotations": fields.Dict(missing={}, example={"Foo": "Bar"}), "tags": fields.List(fields.String, missing=[]), "resize": fields.Dict(missing=None), # TODO: Validate keys } @@ -380,7 +338,7 @@ class TileScanAPI(View): microscope, basename=args.get("filename"), temporary=args.get("temporary"), - step_size=args.get("step_size"), + stride_size=args.get("stride_size"), grid=args.get("grid"), style=args.get("style"), autofocus_dz=args.get("autofocus_dz"), @@ -388,7 +346,7 @@ class TileScanAPI(View): resize=resize, bayer=args.get("bayer"), fast_autofocus=args.get("fast_autofocus"), - metadata=args.get("metadata"), + annotations=args.get("annotations"), tags=args.get("tags"), ) @@ -396,6 +354,6 @@ class TileScanAPI(View): return task -scan_extension_v2 = BaseExtension("org.openflexure.scan", version="2.0.0-beta.1") +scan_extension_v2 = BaseExtension("org.openflexure.scan", version="2.0.0") scan_extension_v2.add_view(TileScanAPI, "/tile") diff --git a/openflexure_microscope/api/default_extensions/zip_builder.py b/openflexure_microscope/api/default_extensions/zip_builder.py index 2b2514f6..b32eb995 100644 --- a/openflexure_microscope/api/default_extensions/zip_builder.py +++ b/openflexure_microscope/api/default_extensions/zip_builder.py @@ -6,7 +6,7 @@ from openflexure_microscope.devel import ( update_task_progress, ) -from flask import send_file, abort +from flask import send_file, abort, url_for import uuid import os @@ -16,13 +16,56 @@ import logging from labthings.server.find import find_component from labthings.server.view import View +from labthings.server.schema import Schema +from labthings.server import fields from labthings.server.extensions import BaseExtension +from labthings.server.utilities import description_from_view from labthings.server.decorators import ( ThingAction, ThingProperty, + marshal_task, + marshal_with, + pre_dump, ) +class ZipObjectSchema(Schema): + id = fields.String() + data_size = fields.Number() + zip_size = fields.Number() + links = fields.Dict() + + @pre_dump + def generate_links(self, data, **kwargs): + data.links = { + "download": { + "href": url_for( + ZipGetterAPIView.endpoint, session_id=data.id, _external=True + ), + **description_from_view(ZipGetterAPIView), + } + } + return data + + +class ZipObjectDescription: + def __init__(self, id, file_pointer, data_size=None): + self.id = id + self.fp = file_pointer + self.data_size = data_size + self.zip_size = os.path.getsize(self.fp.name) * 1e-6 + + def close(self): + logging.debug(self.fp.name) + self.fp.close() + os.unlink(self.fp.name) + + assert not os.path.exists(self.fp.name) + + def __del__(self): + self.close() + + class ZipManager: """ ZIP-builder manager @@ -38,8 +81,7 @@ class ZipManager: # Get array of captures from IDs capture_list = [ - microscope.camera.image_from_id(capture_id) - for capture_id in capture_id_list + microscope.camera.images.get(capture_id) for capture_id in capture_id_list ] # Remove Nones from list (missing/invalid captures) capture_list = [capture for capture in capture_list if capture] @@ -76,20 +118,23 @@ class ZipManager: # Update task progress update_task_progress(int((index / n_files) * 100)) - session_id = uuid.uuid4() - session_key = str(session_id) - # self.session_zips[session_id] = fp - self.session_zips[session_key] = { - "id": session_id, - "fp": fp, - "data_size": data_size_megabytes, - "zip_size": os.path.getsize(fp.name) * 1e-6, - } + session_id = str(uuid.uuid4()) + session_description = ZipObjectDescription( + session_id, fp, data_size=data_size_megabytes + ) + self.session_zips[session_id] = session_description - return self.session_zips[session_key] + return self.session_zips[session_id] - def zip_from_id(self, session_id): - return self.session_zips[session_id]["fp"] + def marshaled_build_zip_from_capture_ids(self, *args, **kwargs): + return ZipObjectSchema().dump(self.build_zip_from_capture_ids(*args, **kwargs)) + + def zip_fp_from_id(self, session_id): + return self.session_zips[session_id].fp + + def __del__(self): + for zd in self.session_zips.values(): + zd.close() # Create a global ZIP manager @@ -98,56 +143,68 @@ default_zip_manager = ZipManager() @ThingAction class ZipBuilderAPIView(View): + @marshal_task def post(self): ids = list(JsonResponse(request).json) microscope = find_component("org.openflexure.microscope") - task = taskify(default_zip_manager.build_zip_from_capture_ids)(microscope, ids) + task = taskify(default_zip_manager.marshaled_build_zip_from_capture_ids)( + microscope, ids + ) # Return a handle on the autofocus task - return jsonify(task.state), 201 + return task @ThingProperty class ZipListAPIView(View): + @marshal_with(ZipObjectSchema(many=True)) def get(self): - return jsonify(default_zip_manager.session_zips) + return default_zip_manager.session_zips.values() class ZipGetterAPIView(View): + """ + Download or delete a particular capture collection ZIP file + """ + def get(self, session_id): + """ + Download a particular capture collection ZIP file + """ if not session_id in default_zip_manager.session_zips: return abort(404) # 404 Not Found logging.info(f"Session ID: {session_id}") return send_file( - default_zip_manager.zip_from_id(session_id).name, + default_zip_manager.zip_fp_from_id(session_id).name, mimetype="application/zip", as_attachment=True, attachment_filename=f"{session_id}.zip", ) def delete(self, session_id): + """ + Close and delete a particular capture collection ZIP file + """ if not session_id in default_zip_manager.session_zips: return abort(404) # 404 Not Found - logging.info(f"Session ID: {session_id}") - - fp = default_zip_manager.zip_from_id(session_id) - logging.debug(fp.name) - fp.close() - os.unlink(fp.name) - - assert not os.path.exists(fp.name) - + # Close the file + default_zip_manager.session_zips[session_id].close() + # Delete the file reference del default_zip_manager.session_zips[session_id] - return jsonify({"return": session_id}) + return {"return": session_id} -zip_extension_v2 = BaseExtension("org.openflexure.zipbuilder", version="2.0.0-beta.1") +zip_extension_v2 = BaseExtension( + "org.openflexure.zipbuilder", + version="2.0.0", + description="Build and download capture collections as ZIP files", +) zip_extension_v2.add_view(ZipGetterAPIView, "/get/") zip_extension_v2.add_view(ZipListAPIView, "/get") diff --git a/openflexure_microscope/api/example_extensions/ev_gui.py b/openflexure_microscope/api/example_extensions/ev_gui.py index 1acb3f9f..70089ccf 100644 --- a/openflexure_microscope/api/example_extensions/ev_gui.py +++ b/openflexure_microscope/api/example_extensions/ev_gui.py @@ -1,9 +1,6 @@ from labthings.server.view import View from labthings.server.extensions import BaseExtension -from labthings.server.decorators import ( - ThingAction, - ThingProperty, -) +from labthings.server.decorators import ThingAction, ThingProperty from openflexure_microscope.api.utilities.gui import build_gui @@ -35,7 +32,7 @@ def dynamic_form(): "name": "val_int", "label": "Number value", "minValue": 0, - "value": val_int + "value": val_int, }, { "fieldType": "htmlBlock", @@ -66,7 +63,7 @@ static_form = { "name": "val_int", "label": "Number value", "minValue": 0, - "default": 1 + "default": 1, }, { "fieldType": "htmlBlock", diff --git a/openflexure_microscope/api/microscope.py b/openflexure_microscope/api/microscope.py index 04053892..d9496684 100644 --- a/openflexure_microscope/api/microscope.py +++ b/openflexure_microscope/api/microscope.py @@ -1,44 +1,12 @@ -from openflexure_microscope import Microscope -from openflexure_microscope.camera.capture import build_captures_from_exif +from openflexure_microscope.microscope import Microscope import logging -# Import device modules -# NB this will eventually be handled by the RC file, so you can choose what device -# class should be attached. -try: - from openflexure_microscope.camera.pi import PiCameraStreamer -except ImportError: - logging.warning("Unable to import PiCameraStreamer") -from openflexure_microscope.camera.mock import MockStreamer - -from openflexure_microscope.stage.sanga import SangaStage -from openflexure_microscope.stage.mock import MockStage default_microscope = Microscope() -# Initialise camera -logging.debug("Creating camera object...") -try: - api_camera = PiCameraStreamer() -except Exception as e: - logging.error(e) - logging.warning("No valid camera hardware found. Falling back to mock camera!") - api_camera = MockStreamer() - -# Initialise stage -logging.debug("Creating stage object...") - -api_stage = MockStage() - -# Attach devices to microscope -logging.debug("Attaching devices to microscope...") -default_microscope.attach(api_camera, api_stage) - # Restore loaded capture array to camera object logging.debug("Restoring captures...") -default_microscope.camera.images = build_captures_from_exif( - default_microscope.camera.paths["default"] -) +default_microscope.camera.rebuild_captures() logging.debug("Microscope successfully attached!") diff --git a/openflexure_microscope/api/static/css/chunk-vendors.1800c649.css b/openflexure_microscope/api/static/css/chunk-vendors.1800c649.css new file mode 100644 index 00000000..c967969b --- /dev/null +++ b/openflexure_microscope/api/static/css/chunk-vendors.1800c649.css @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8605f70737729f74f48d3183b30b4a2f50706f4f750cd208aeb8c136253f7d01 +size 772 diff --git a/openflexure_microscope/api/static/css/main.24f9722e.css b/openflexure_microscope/api/static/css/main.24f9722e.css new file mode 100644 index 00000000..b8cd1050 --- /dev/null +++ b/openflexure_microscope/api/static/css/main.24f9722e.css @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e68506e72389393d35cc47e5184459fff9a23bedea8c9ee96491f66c88e71954 +size 1375875 diff --git a/openflexure_microscope/api/static/favicon-16x16.png b/openflexure_microscope/api/static/favicon-16x16.png new file mode 100644 index 00000000..6f9b9751 --- /dev/null +++ b/openflexure_microscope/api/static/favicon-16x16.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ee944cc13be0a52ce48a81a09c179dac70729bd7580a516ccc8ac10b4cfa3f98 +size 1310 diff --git a/openflexure_microscope/api/static/favicon-32x32.png b/openflexure_microscope/api/static/favicon-32x32.png new file mode 100644 index 00000000..21fb950d --- /dev/null +++ b/openflexure_microscope/api/static/favicon-32x32.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:710327f8b3d83c83019ed2c4b27de8caf8e4e923c9148f93c1c0a2662f683f26 +size 1659 diff --git a/openflexure_microscope/api/static/favicon.ico b/openflexure_microscope/api/static/favicon.ico new file mode 100644 index 00000000..d55c92be --- /dev/null +++ b/openflexure_microscope/api/static/favicon.ico @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0405ef60ab42df1c2e8721bf1f500c8c7d9e1afae130307101b8f77925ffdac1 +size 1150 diff --git a/openflexure_microscope/api/static/fonts/MaterialIcons-Regular.012cf6a1.woff b/openflexure_microscope/api/static/fonts/MaterialIcons-Regular.012cf6a1.woff new file mode 100644 index 00000000..ebaebe27 --- /dev/null +++ b/openflexure_microscope/api/static/fonts/MaterialIcons-Regular.012cf6a1.woff @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c4a1baec300d09e03a8380b85918267ee80faae8e00c6c56b48e2e74b1d9b38d +size 57620 diff --git a/openflexure_microscope/api/static/fonts/MaterialIcons-Regular.570eb838.woff2 b/openflexure_microscope/api/static/fonts/MaterialIcons-Regular.570eb838.woff2 new file mode 100644 index 00000000..ed48a958 --- /dev/null +++ b/openflexure_microscope/api/static/fonts/MaterialIcons-Regular.570eb838.woff2 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a87d66c91b2e7dc5530aef76c03bd6a3d25ea5826110bf4803b561b811cc8726 +size 44300 diff --git a/openflexure_microscope/api/static/fonts/MaterialIcons-Regular.a37b0c01.ttf b/openflexure_microscope/api/static/fonts/MaterialIcons-Regular.a37b0c01.ttf new file mode 100644 index 00000000..acdfd2e6 --- /dev/null +++ b/openflexure_microscope/api/static/fonts/MaterialIcons-Regular.a37b0c01.ttf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b7f4a3ab562048f28dd1fa691601bc43363a61d0f876d16d8316c52e4f32d696 +size 128180 diff --git a/openflexure_microscope/api/static/fonts/MaterialIcons-Regular.e79bfd88.eot b/openflexure_microscope/api/static/fonts/MaterialIcons-Regular.e79bfd88.eot new file mode 100644 index 00000000..e3f70a00 --- /dev/null +++ b/openflexure_microscope/api/static/fonts/MaterialIcons-Regular.e79bfd88.eot @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8c998b4a9c0acbb9fe5dd572c206a5a33fdd5ca2b58db87fc3b893beac85068d +size 143258 diff --git a/openflexure_microscope/api/static/index.html b/openflexure_microscope/api/static/index.html new file mode 100644 index 00000000..f9009ab2 --- /dev/null +++ b/openflexure_microscope/api/static/index.html @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:463bfdbd635ddff1d00df38b1149ffd3947b492d71cfb546549dffebd311b090 +size 843 diff --git a/openflexure_microscope/api/static/js/chunk-vendors.3b7762c4.js b/openflexure_microscope/api/static/js/chunk-vendors.3b7762c4.js new file mode 100644 index 00000000..47dda2fd --- /dev/null +++ b/openflexure_microscope/api/static/js/chunk-vendors.3b7762c4.js @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a536bfa88fd1bf69284804285c5cf1da33cf05f164f175eb4085f7d057ff4f17 +size 378133 diff --git a/openflexure_microscope/api/static/js/chunk-vendors.3b7762c4.js.map b/openflexure_microscope/api/static/js/chunk-vendors.3b7762c4.js.map new file mode 100644 index 00000000..834b0baf --- /dev/null +++ b/openflexure_microscope/api/static/js/chunk-vendors.3b7762c4.js.map @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:996b19391845b7cf1810f1181d935aad524897763da4c36847c7715f762b63fa +size 1597950 diff --git a/openflexure_microscope/api/static/js/main.eaa8787b.js b/openflexure_microscope/api/static/js/main.eaa8787b.js new file mode 100644 index 00000000..4826f329 --- /dev/null +++ b/openflexure_microscope/api/static/js/main.eaa8787b.js @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0a77d7d2e5e4220883bc1491a627435cc80d0b2574d51972bcd6b12add7a4a94 +size 116850 diff --git a/openflexure_microscope/api/static/js/main.eaa8787b.js.map b/openflexure_microscope/api/static/js/main.eaa8787b.js.map new file mode 100644 index 00000000..72e98b54 --- /dev/null +++ b/openflexure_microscope/api/static/js/main.eaa8787b.js.map @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1404c938e8f60b1694dd48357d486d919f5b1e32c9f226ca26fc0ee399019542 +size 395811 diff --git a/openflexure_microscope/api/static/titleicon.svg b/openflexure_microscope/api/static/titleicon.svg new file mode 100644 index 00000000..5307728d --- /dev/null +++ b/openflexure_microscope/api/static/titleicon.svg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:187e61d8f6171eebf611502412556c707fab1d837f8763b2b60ce3915303f0ad +size 1734 diff --git a/openflexure_microscope/api/utilities/gui.py b/openflexure_microscope/api/utilities/gui.py index 19d83bd8..dcdfe79e 100644 --- a/openflexure_microscope/api/utilities/gui.py +++ b/openflexure_microscope/api/utilities/gui.py @@ -3,6 +3,12 @@ import logging from functools import wraps +def clean_rule(rule: str): + while rule[0] == "/": + rule = rule[1:] + return f"{rule}" + + def build_gui_from_dict(gui_description, extension_object): # Make a working copy of GUI description api_gui = copy.deepcopy(gui_description) @@ -10,6 +16,10 @@ def build_gui_from_dict(gui_description, extension_object): # Expand shorthand routes into full relative URLs if "forms" in gui_description and isinstance(api_gui["forms"], list): for form in api_gui["forms"]: + # Clean leading slashes from rule + if "route" in form: + form["route"] = clean_rule(form["route"]) + # Match rule in extension object if "route" in form and form["route"] in extension_object._rules.keys(): form["route"] = extension_object._rules[form["route"]]["rule"] else: diff --git a/openflexure_microscope/api/v2/views/__init__.py b/openflexure_microscope/api/v2/views/__init__.py index a998c703..3c1fae87 100644 --- a/openflexure_microscope/api/v2/views/__init__.py +++ b/openflexure_microscope/api/v2/views/__init__.py @@ -1,4 +1,4 @@ from .actions import enabled_root_actions from .captures import * -from .state import * +from .instrument import * from .streams import * diff --git a/openflexure_microscope/api/v2/views/actions/__init__.py b/openflexure_microscope/api/v2/views/actions/__init__.py index b256c60b..abab2118 100644 --- a/openflexure_microscope/api/v2/views/actions/__init__.py +++ b/openflexure_microscope/api/v2/views/actions/__init__.py @@ -1,7 +1,7 @@ """ Top-level representation of enabled actions """ - +from flask import url_for from . import camera, stage, system from labthings.server.view import View @@ -74,9 +74,9 @@ class ActionsView(View): d = { "links": { "self": { - "href": current_labthing().url_for(action["view_class"]), + "href": url_for(action["view_class"].endpoint, _external=True), "mimetype": "application/json", - **description_from_view(action["view_class"]) + **description_from_view(action["view_class"]), } }, "rule": action["rule"], diff --git a/openflexure_microscope/api/v2/views/actions/camera.py b/openflexure_microscope/api/v2/views/actions/camera.py index ec4445e2..62f777d7 100644 --- a/openflexure_microscope/api/v2/views/actions/camera.py +++ b/openflexure_microscope/api/v2/views/actions/camera.py @@ -36,7 +36,7 @@ class CaptureAPI(View): "bayer": fields.Boolean( missing=False, description="Store raw bayer data in file" ), - "metadata": fields.Dict(missing={}, example={"Client": "SwaggerUI"}), + "annotations": fields.Dict(missing={}, example={"Client": "SwaggerUI"}), "tags": fields.List(fields.String, missing=[], example=["docs"]), "resize": fields.Dict( missing=None, example={"width": 640, "height": 480} @@ -75,11 +75,10 @@ class CaptureAPI(View): ) # Inject system metadata - output.put_metadata(microscope.metadata, system=True) + output.put_metadata({"instrument": microscope.metadata}) # Insert custom metadata - output.put_metadata(args.get("metadata")) - + output.put_annotations(args.get("annotations")) # Insert custom tags output.put_tags(args.get("tags")) @@ -132,10 +131,7 @@ class RAMCaptureAPI(View): stream.seek(0) - return send_file( - io.BytesIO(stream.getbuffer()), - mimetype="image/jpeg" - ) + return send_file(io.BytesIO(stream.getbuffer()), mimetype="image/jpeg") @ThingAction @@ -167,8 +163,8 @@ class GPUPreviewStartAPI(View): microscope.camera.start_preview(fullscreen=fullscreen, window=window) - # TODO: Make schema for microscope status - return jsonify(microscope.status) + # TODO: Make schema for microscope state + return jsonify(microscope.state) @ThingAction @@ -179,5 +175,5 @@ class GPUPreviewStopAPI(View): """ microscope = find_component("org.openflexure.microscope") microscope.camera.stop_preview() - # TODO: Make schema for microscope status - return jsonify(microscope.status) + # TODO: Make schema for microscope state + return jsonify(microscope.state) diff --git a/openflexure_microscope/api/v2/views/actions/stage.py b/openflexure_microscope/api/v2/views/actions/stage.py index 446e719e..5cf4059e 100644 --- a/openflexure_microscope/api/v2/views/actions/stage.py +++ b/openflexure_microscope/api/v2/views/actions/stage.py @@ -1,12 +1,7 @@ from openflexure_microscope.api.utilities import JsonResponse from labthings.server.view import View from labthings.server.find import find_component -from labthings.server.decorators import ( - use_args, - marshal_with, - doc, - ThingAction, -) +from labthings.server.decorators import use_args, marshal_with, doc, ThingAction from labthings.server import fields from openflexure_microscope.utilities import axes_to_array, filter_dict @@ -57,8 +52,8 @@ class MoveStageAPI(View): else: logging.warning("Unable to move. No stage found.") - # TODO: Make schema for microscope status - return jsonify(microscope.status["stage"]["position"]) + # TODO: Make schema for microscope state + return jsonify(microscope.state["stage"]["position"]) @ThingAction @@ -71,5 +66,5 @@ class ZeroStageAPI(View): microscope = find_component("org.openflexure.microscope") microscope.stage.zero_position() - # TODO: Make schema for microscope status - return jsonify(microscope.status["stage"]) + # TODO: Make schema for microscope state + return jsonify(microscope.state["stage"]) diff --git a/openflexure_microscope/api/v2/views/actions/system.py b/openflexure_microscope/api/v2/views/actions/system.py index 43cf0ead..df002df1 100644 --- a/openflexure_microscope/api/v2/views/actions/system.py +++ b/openflexure_microscope/api/v2/views/actions/system.py @@ -3,10 +3,7 @@ import subprocess import os from sys import platform -from labthings.server.decorators import ( - ThingAction, - doc_response, -) +from labthings.server.decorators import ThingAction, doc_response def is_raspberrypi(raise_on_errors=False): @@ -28,9 +25,14 @@ class ShutdownAPI(View): """ Attempt to shutdown the device """ - subprocess.Popen(["sudo", "shutdown", "-h", "now"]) + p = subprocess.Popen( + ["sudo", "shutdown", "-h", "now"], + stderr=subprocess.PIPE, + stdout=subprocess.PIPE, + ) - return "{}", 201 + out, err = p.communicate() + return {"out": out, "err": err}, 201 @ThingAction @@ -44,6 +46,11 @@ class RebootAPI(View): """ Attempt to reboot the device """ - subprocess.Popen(["sudo", "shutdown", "-r", "now"]) + p = subprocess.Popen( + ["sudo", "shutdown", "-r", "now"], + stderr=subprocess.PIPE, + stdout=subprocess.PIPE, + ) - return "{}", 201 + out, err = p.communicate() + return {"out": out, "err": err}, 201 diff --git a/openflexure_microscope/api/v2/views/captures.py b/openflexure_microscope/api/v2/views/captures.py index e97e8765..2deb72b6 100644 --- a/openflexure_microscope/api/v2/views/captures.py +++ b/openflexure_microscope/api/v2/views/captures.py @@ -6,9 +6,7 @@ from openflexure_microscope.api.utilities import get_bool, JsonResponse from labthings.server.schema import Schema from labthings.server import fields from labthings.server.view import View -from labthings.server.utilities import ( - description_from_view, -) +from labthings.server.utilities import description_from_view from labthings.server.decorators import marshal_with, doc_response, Tag, ThingProperty from labthings.server.find import find_component @@ -16,14 +14,38 @@ from labthings.server.find import find_component from marshmallow import pre_dump +class InstrumentSchema(Schema): + id = fields.UUID() + configuration = fields.Dict() + settings = fields.Dict() + state = fields.Dict() + + +class CaptureMetadataImageSchema(Schema): + id = fields.UUID() + acquisitionDate = fields.String(format="date") + format = fields.String() + name = fields.String() + tags = fields.List(fields.String()) + annotations = fields.Dict() + + +class CaptureMetadataSchema(Schema): + experimenter = fields.Dict() # TODO: Make schema + experimenterGroup = fields.Dict() # TODO: Make schema + dataset = fields.Dict() # TODO: Make schema + image = fields.Nested(CaptureMetadataImageSchema()) + instrument = fields.Nested(InstrumentSchema()) + + class CaptureSchema(Schema): id = fields.String() file = fields.String( data_key="path", description="Path of file on microscope device" ) exists = fields.Bool(data_key="available") - filename = fields.String() - metadata = fields.Dict() + name = fields.String() + metadata = fields.Nested(CaptureMetadataSchema()) links = fields.Dict() @@ -41,16 +63,18 @@ class CaptureSchema(Schema): "mimetype": "application/json", **description_from_view(CaptureTags), }, - "metadata": { - "href": url_for(CaptureMetadata.endpoint, id=data.id, _external=True), + "annotations": { + "href": url_for( + CaptureAnnotations.endpoint, id=data.id, _external=True + ), "mimetype": "application/json", - **description_from_view(CaptureMetadata), + **description_from_view(CaptureAnnotations), }, "download": { "href": url_for( CaptureDownload.endpoint, id=data.id, - filename=data.filename, + filename=data.name, _external=True, ), "mimetype": "image/jpeg", @@ -64,6 +88,9 @@ capture_schema = CaptureSchema() capture_list_schema = CaptureSchema(many=True) +from pprint import pprint + + @ThingProperty @Tag("captures") class CaptureList(View): @@ -73,7 +100,7 @@ class CaptureList(View): List all image captures """ microscope = find_component("org.openflexure.microscope") - image_list = microscope.camera.images + image_list = microscope.camera.images.values() return image_list @@ -85,7 +112,7 @@ class CaptureView(View): Description of a single image capture """ microscope = find_component("org.openflexure.microscope") - capture_obj = microscope.camera.image_from_id(id) + capture_obj = microscope.camera.images.get(id) if not capture_obj: return abort(404) # 404 Not Found @@ -97,12 +124,15 @@ class CaptureView(View): Delete a single image capture """ microscope = find_component("org.openflexure.microscope") - capture_obj = microscope.camera.image_from_id(id) + capture_obj = microscope.camera.images.get(id) if not capture_obj: return abort(404) # 404 Not Found + # Delete the capture file capture_obj.delete() + # Delete from capture list + del microscope.camera.images[id] return "", 204 @@ -115,7 +145,7 @@ class CaptureDownload(View): Image data for a single image capture """ microscope = find_component("org.openflexure.microscope") - capture_obj = microscope.camera.image_from_id(id) + capture_obj = microscope.camera.images.get(id) if not capture_obj: return abort(404) # 404 Not Found @@ -150,7 +180,7 @@ class CaptureTags(View): Get tags associated with a single image capture """ microscope = find_component("org.openflexure.microscope") - capture_obj = microscope.camera.image_from_id(id) + capture_obj = microscope.camera.images.get(id) if not capture_obj: return abort(404) # 404 Not Found @@ -162,7 +192,7 @@ class CaptureTags(View): Add tags to a single image capture """ microscope = find_component("org.openflexure.microscope") - capture_obj = microscope.camera.image_from_id(id) + capture_obj = microscope.camera.images.get(id) if not capture_obj: return abort(404) # 404 Not Found @@ -177,12 +207,12 @@ class CaptureTags(View): return jsonify(capture_obj.tags) - def delete(self, capture_id): + def delete(self, id): """ Delete tags from a single image capture """ microscope = find_component("org.openflexure.microscope") - capture_obj = microscope.camera.image_from_id(id) + capture_obj = microscope.camera.images.get(id) if not capture_obj: return abort(404) # 404 Not Found @@ -199,25 +229,25 @@ class CaptureTags(View): @Tag("captures") -class CaptureMetadata(View): +class CaptureAnnotations(View): def get(self, id): """ - Get metadata associated with a single image capture + Get annotations associated with a single image capture """ microscope = find_component("org.openflexure.microscope") - capture_obj = microscope.camera.image_from_id(id) + capture_obj = microscope.camera.images.get(id) if not capture_obj: return abort(404) # 404 Not Found - return jsonify(capture_obj.metadata) + return jsonify(capture_obj.annotations) def put(self, id): """ Update metadata for a single image capture """ microscope = find_component("org.openflexure.microscope") - capture_obj = microscope.camera.image_from_id(id) + capture_obj = microscope.camera.images.get(id) if not capture_obj: return abort(404) # 404 Not Found @@ -228,7 +258,6 @@ class CaptureMetadata(View): if type(data_dict) != dict: return abort(400) - # TODO: Allow putting system metadata maybe? - capture_obj.put_metadata(data_dict) + capture_obj.put_annotations(data_dict) - return jsonify(capture_obj.metadata) + return jsonify(capture_obj.annotations) diff --git a/openflexure_microscope/api/v2/views/state.py b/openflexure_microscope/api/v2/views/instrument.py similarity index 69% rename from openflexure_microscope/api/v2/views/state.py rename to openflexure_microscope/api/v2/views/instrument.py index 6aa7f958..633d73ff 100644 --- a/openflexure_microscope/api/v2/views/state.py +++ b/openflexure_microscope/api/v2/views/instrument.py @@ -1,10 +1,6 @@ from openflexure_microscope.api.utilities import JsonResponse -from labthings.core.utilities import ( - get_by_path, - set_by_path, - create_from_path, -) +from labthings.core.utilities import get_by_path, set_by_path, create_from_path from labthings.server.find import find_component from labthings.server.view import View @@ -34,7 +30,7 @@ class SettingsProperty(View): logging.debug("Updating settings from PUT request:") logging.debug(payload.json) - microscope.apply_settings(payload.json) + microscope.update_settings(payload.json) microscope.save_settings() return self.get() @@ -69,24 +65,24 @@ class NestedSettingsProperty(View): dictionary = create_from_path(keys) set_by_path(dictionary, keys, payload.json) - microscope.apply_settings(dictionary) + microscope.update_settings(dictionary) microscope.save_settings() return self.get(route) @ThingProperty -class StatusProperty(View): +class StateProperty(View): def get(self): """ Show current read-only state of the microscope """ microscope = find_component("org.openflexure.microscope") - return jsonify(microscope.status) + return jsonify(microscope.state) @Tag("properties") -class NestedStatusProperty(View): +class NestedStateProperty(View): @doc_response(404, description="Status key cannot be found") def get(self, route): """ @@ -96,7 +92,35 @@ class NestedStatusProperty(View): keys = route.split("/") try: - value = get_by_path(microscope.status, keys) + value = get_by_path(microscope.state, keys) + except KeyError: + return abort(404) + + return jsonify(value) + + +@ThingProperty +class ConfigurationProperty(View): + def get(self): + """ + Show current read-only state of the microscope + """ + microscope = find_component("org.openflexure.microscope") + return jsonify(microscope.configuration) + + +@Tag("properties") +class NestedConfigurationProperty(View): + @doc_response(404, description="Configuration key cannot be found") + def get(self, route): + """ + Show a nested section of the current microscope state + """ + microscope = find_component("org.openflexure.microscope") + keys = route.split("/") + + try: + value = get_by_path(microscope.configuration, keys) except KeyError: return abort(404) diff --git a/openflexure_microscope/api/v2/views/streams.py b/openflexure_microscope/api/v2/views/streams.py index 3061af62..4382e2ab 100644 --- a/openflexure_microscope/api/v2/views/streams.py +++ b/openflexure_microscope/api/v2/views/streams.py @@ -1,10 +1,6 @@ from openflexure_microscope.api.utilities import gen, JsonResponse -from labthings.core.utilities import ( - get_by_path, - set_by_path, - create_from_path, -) +from labthings.core.utilities import get_by_path, set_by_path, create_from_path from labthings.server.find import find_component from labthings.server.view import View diff --git a/openflexure_microscope/camera/base.py b/openflexure_microscope/camera/base.py index e6395e1f..e3da4131 100644 --- a/openflexure_microscope/camera/base.py +++ b/openflexure_microscope/camera/base.py @@ -7,8 +7,9 @@ import datetime import logging from abc import ABCMeta, abstractmethod +from collections import OrderedDict -from .capture import CaptureObject +from .capture import CaptureObject, build_captures_from_exif from openflexure_microscope.utilities import entry_by_uuid from labthings.core.lock import StrictLock @@ -91,20 +92,6 @@ class CameraEvent(object): class BaseCamera(metaclass=ABCMeta): """ Base implementation of StreamingCamera. - - Attributes: - thread: Background thread reading frames from camera - camera: Camera object - lock (:py:class:`labthings.lock.StrictLock`): Strict lock controlling thread - access to stage hardware - frame (bytes): Current frame is stored here by background thread - last_access (time): Time of last client access to the camera - stream_timeout (int): Number of inactive seconds before timing out the stream - stream_timeout_enabled (bool): Enable or disable timing out the stream - status (dict): Dictionary for capture state - paths (dict): Dictionary of capture paths - images (list): List of image capture objects - videos (list): List of video capture objects """ def __init__(self): @@ -121,16 +108,33 @@ class BaseCamera(metaclass=ABCMeta): self.stream_timeout = 20 self.stream_timeout_enabled = False - self.status = {"board": None} + self.stream_active = False + self.record_active = False self.paths = {"default": BASE_CAPTURE_PATH, "temp": TEMP_CAPTURE_PATH} # Capture data - self.images = [] - self.videos = [] + self.images = OrderedDict() + self.videos = OrderedDict() + + @property + @abstractmethod + def configuration(self): + """The current camera configuration.""" + pass + + @property + @abstractmethod + def state(self): + """The current read-only camera state.""" + pass + + @property + def settings(self): + return self.read_settings() @abstractmethod - def apply_settings(self, config: dict): + def update_settings(self, config: dict): """Update settings from a config dictionary""" with self.lock: # Apply valid config params to camera object @@ -143,10 +147,6 @@ class BaseCamera(metaclass=ABCMeta): """Return the current settings as a dictionary""" return {"paths": self.paths} - def save_settings(self): - """(Optional) Save any settings to disk that need to be stored""" - return - def __enter__(self): """Create camera on context enter.""" return self @@ -159,7 +159,7 @@ class BaseCamera(metaclass=ABCMeta): """Close the BaseCamera and all attached StreamObjects.""" logging.info("Closing {}".format(self)) # Close all StreamObjects - for capture_list in [self.images, self.videos]: + for capture_list in [self.images.values(), self.videos.values()]: for stream_object in capture_list: stream_object.close() # Empty temp directory @@ -178,6 +178,9 @@ class BaseCamera(metaclass=ABCMeta): shutil.rmtree(self.paths["temp"]) logging.debug("Cleared {}.".format(self.paths["temp"])) + def rebuild_captures(self): + self.images = build_captures_from_exif(self.paths["default"]) + # START AND STOP WORKER THREAD def start_worker(self, timeout: int = 5) -> bool: @@ -187,7 +190,7 @@ class BaseCamera(metaclass=ABCMeta): self.last_access = time.time() self.stop = False - if not self.status["stream_active"]: + if not self.stream_active: # start background frame thread self.thread = threading.Thread(target=self._thread) self.thread.daemon = True @@ -207,12 +210,12 @@ class BaseCamera(metaclass=ABCMeta): logging.debug("Stopping worker thread") timeout_time = time.time() + timeout - if self.status["stream_active"]: + if self.stream_active: self.stop = True self.thread.join() # Wait for stream thread to exit logging.debug("Waiting for stream thread to exit.") - while self.status["stream_active"]: + while self.stream_active: if time.time() > timeout_time: logging.debug("Timeout waiting for worker thread close.") raise TimeoutError("Timeout waiting for worker thread close.") @@ -242,20 +245,22 @@ class BaseCamera(metaclass=ABCMeta): @property def image(self): """Return the latest captured image.""" - return last_entry(self.images) + return last_entry(self.images.values()) @property def video(self): """Return the latest recorded video.""" - return last_entry(self.videos) + return last_entry(self.videos.values()) def image_from_id(self, image_id): """Return an image StreamObject with a matching ID.""" - return entry_by_uuid(image_id, self.images) + logging.warning("image_from_id is deprecated. Access captures as a dictionary.") + return entry_by_uuid(image_id, self.images.values()) def video_from_id(self, video_id): """Return a video StreamObject with a matching ID.""" - return entry_by_uuid(video_id, self.videos) + logging.warning("video_from_id is deprecated. Access captures as a dictionary.") + return entry_by_uuid(video_id, self.videos.values()) # CREATING NEW CAPTURES @@ -280,7 +285,7 @@ class BaseCamera(metaclass=ABCMeta): # Generate file name if not filename: - filename = generate_numbered_basename(self.images) + filename = generate_numbered_basename(self.images.values()) logging.debug(filename) filename = "{}.{}".format(filename, fmt) @@ -298,7 +303,9 @@ class BaseCamera(metaclass=ABCMeta): output.put_tags(["temporary"]) # Update capture list - self.images.append(output) + capture_key = str(output.id) + logging.debug(f"Adding image {output} with key {capture_key}") + self.images[capture_key] = output return output @@ -320,10 +327,11 @@ class BaseCamera(metaclass=ABCMeta): folder (str): Name of the folder in which to store the capture. fmt (str): Format of the capture. """ + # TODO: Remove the redundancy here # Generate file name if not filename: - filename = generate_numbered_basename(self.videos) + filename = generate_numbered_basename(self.videos.values()) logging.debug(filename) filename = "{}.{}".format(filename, fmt) @@ -341,7 +349,9 @@ class BaseCamera(metaclass=ABCMeta): output.put_tags(["temporary"]) # Update capture list - self.videos.append(output) + capture_key = str(output.id) + logging.debug(f"Adding video {output} with key {capture_key}") + self.videos[capture_key] = output return output @@ -352,7 +362,7 @@ class BaseCamera(metaclass=ABCMeta): self.frames_iterator = self.frames() logging.debug("Entering worker thread.") - self.status["stream_active"] = True + self.stream_active = True for frame in self.frames_iterator: self.frame = frame @@ -365,9 +375,7 @@ class BaseCamera(metaclass=ABCMeta): and ( # If using timeout time.time() - self.last_access > self.stream_timeout ) - and not self.status[ # And timeout time - "preview_active" - ] # And GPU preview is not active + and not self.preview_active # And GPU preview is not active ): self.frames_iterator.close() break @@ -383,4 +391,4 @@ class BaseCamera(metaclass=ABCMeta): logging.debug("BaseCamera worker thread exiting...") # Set stream_activate state - self.status["stream_active"] = False + self.stream_active = False diff --git a/openflexure_microscope/camera/capture.py b/openflexure_microscope/camera/capture.py index 20ec71da..70bee3ec 100644 --- a/openflexure_microscope/camera/capture.py +++ b/openflexure_microscope/camera/capture.py @@ -4,12 +4,14 @@ import os import shutil import glob import datetime -import yaml import json import logging from PIL import Image +import dateutil.parser import atexit +from collections import OrderedDict + from openflexure_microscope.camera import piexif from openflexure_microscope.camera.piexif._exceptions import InvalidImageDataError from openflexure_microscope.config import JSONEncoder @@ -34,11 +36,9 @@ def pull_usercomment_dict(filepath): try: return json.loads(exif_dict["Exif"][37510].decode()) except json.decoder.JSONDecodeError: - # TODO: Remove YAML support in a later version - logging.warning( - f"Capture {filepath} has metadata stored in YAML format. This is now deprecated in favour of JSON." + logging.error( + f"Capture {filepath} has old, corrupt, or missing OpenFlexure metadata. Unable to reload to server." ) - return yaml.load(exif_dict["Exif"][37510].decode()) else: return None @@ -60,14 +60,15 @@ def build_captures_from_exif(capture_path): logging.debug("Reloading captures from {}...".format(capture_path)) files = make_file_list(capture_path, EXIF_FORMATS) - captures = [] + captures = OrderedDict() for f in files: logging.debug("Reloading capture {}...".format(f)) exif = pull_usercomment_dict(f) if exif: capture = capture_from_exif(f, exif) - captures.append(capture) + if capture: + captures[capture.id] = capture else: logging.error("Invalid data at {}. Skipping.".format(f)) @@ -93,18 +94,24 @@ def capture_from_exif(path, exif_dict): # Build file path information capture.split_file_path(capture.file) - # Populate capture parameters - capture.id = exif_dict["id"] - capture.timestring = exif_dict["time"] - capture.format = exif_dict["format"] + # Image metadata + try: + image_metadata = exif_dict.pop("image") + except KeyError as e: + logging.error( + f"Unable to obtain valid 2.0 OpenFlexure metadata from file {path}" + ) + return None - capture.custom_metadata = ( - exif_dict["custom"] if "custom" in exif_dict.keys() else {} - ) - capture.system_metadata = ( - exif_dict["system"] if "system" in exif_dict.keys() else {} - ) - capture.tags = exif_dict["tags"] + # Populate capture parameters + capture.id = image_metadata.get("id") + capture.datetime = dateutil.parser.isoparse(image_metadata.get("acquisitionDate")) + capture.format = image_metadata.get("format") + capture.tags = image_metadata.get("tags") + capture.annotations = image_metadata.get("annotations") + + # Since we popped the "image" key, we dump whatever is left in _metadata + capture._metadata = exif_dict return capture @@ -113,16 +120,6 @@ class CaptureObject(object): """ StreamObject used to store and process on-disk capture data, and metadata. Serves to simplify modifying properties of on-disk capture data. - - Attributes: - timestring (str): Timestring of capture creation time - custom_metadata (dict): Dictionary of custom metadata to be included in metadata file - tags (list): List of tags. Essentially just as extra custom metadata field, but useful for quick organisation - filefolder (str): Folder in which the capture file will be stored - filename (str): Full name of the capture file - basename (str): Filename of the capture, without a file extension - format (str): Format of the capture data - """ def __init__(self, filepath) -> None: @@ -131,17 +128,20 @@ class CaptureObject(object): # Store a nice ID self.id = uuid.uuid4() #: str: Unique capture ID logging.debug("Created StreamObject {}".format(self.id)) - self.timestring = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + self.datetime = datetime.datetime.now() # Create file name. Default to UUID self.file = filepath self.split_file_path(self.file) - # Dictionary for storing custom metadata - self.custom_metadata = {} - # Dictionary for adding top-level metadata (cannmot be accessed through web API) - self.system_metadata = {} + if not os.path.exists(self.filefolder): + os.makedirs(self.filefolder) + # Dictionary for adding top-level metadata (cannmot be accessed through web API) + self._metadata = {} + + # Dictionary for storing custom annotations + self.annotations = {} # List for storing tags self.tags = [] @@ -158,15 +158,11 @@ class CaptureObject(object): Args: filepath (str): String of the full file path, including file format extension """ - # Split the full file path into a folder and a filename - self.filefolder, self.filename = os.path.split(filepath) - # Split the filename out from it's file extension - self.basename = os.path.splitext(self.filename)[0] - self.format = self.filename.split(".")[-1] - - # Create folder and file - if not os.path.exists(self.filefolder): - os.makedirs(self.filefolder) + # Split the full file path into a folder and a name + self.filefolder, self.name = os.path.split(filepath) + # Split the name out from it's file extension + self.basename = os.path.splitext(self.name)[0] + self.format = self.name.split(".")[-1] @property def exists(self) -> bool: @@ -204,17 +200,24 @@ class CaptureObject(object): # HANDLE METADATA - def put_metadata(self, data: dict, system: bool = False) -> None: + def put_annotations(self, data: dict) -> None: """ - Merge metadata from a passed dictionary into the capture metadata, and saves. + Merge annotations from a passed dictionary into the capture metadata, and saves. Args: data (dict): Dictionary of metadata to be added """ - if system: - self.system_metadata.update(data) - else: - self.custom_metadata.update(data) + self.annotations.update(data) + self.save_metadata() + + def put_metadata(self, data: dict) -> None: + """ + Merge root metadata from a passed dictionary into the capture metadata, and saves. + + Args: + data (dict): Dictionary of metadata to be added + """ + self._metadata.update(data) self.save_metadata() def save_metadata(self) -> None: @@ -244,12 +247,15 @@ class CaptureObject(object): and any added custom metadata and tags. """ d = { - "id": self.id, - "time": self.timestring, - "format": self.format, - "tags": self.tags, - "custom": self.custom_metadata, - "system": self.system_metadata, + "image": { + "id": self.id, + "name": self.name, + "acquisitionDate": self.datetime.isoformat(), + "format": self.format, + "tags": self.tags, + "annotations": self.annotations, + }, + **self._metadata, } # Add custom metadata to dictionary @@ -262,7 +268,7 @@ class CaptureObject(object): """ # Create basic state dictionary - d = {"path": self.file, "filename": self.filename, "metadata": self.metadata} + d = {"path": self.file, "name": self.name, "metadata": self.metadata} # Combined availability of data if self.exists: diff --git a/openflexure_microscope/camera/mock.py b/openflexure_microscope/camera/mock.py index 4f614b1a..322bae58 100644 --- a/openflexure_microscope/camera/mock.py +++ b/openflexure_microscope/camera/mock.py @@ -26,17 +26,13 @@ We override the logging settings in api.app by setting a level for PIL here. pil_logger = logging.getLogger("PIL") pil_logger.setLevel(logging.INFO) + # MAIN CLASS -class MockStreamer(BaseCamera): +class MissingCamera(BaseCamera): def __init__(self): # Run BaseCamera init BaseCamera.__init__(self) - # Store state of PiCameraStreamer - self.status.update( - {"stream_active": False, "record_active": False, "board": None} - ) - # Update config properties self.image_resolution = (1312, 976) self.stream_resolution = (640, 480) @@ -71,6 +67,16 @@ class MockStreamer(BaseCamera): image.save(self.stream, format="JPEG") + @property + def configuration(self): + """The current camera configuration.""" + return {} + + @property + def state(self): + """The current read-only camera state.""" + return {} + def initialisation(self): """Run any initialisation code when the frame iterator starts.""" pass @@ -101,7 +107,7 @@ class MockStreamer(BaseCamera): return conf_dict - def apply_settings(self, config: dict): + def update_settings(self, config: dict): """ Write a config dictionary to the PiCameraStreamer config. @@ -117,7 +123,7 @@ class MockStreamer(BaseCamera): with self.lock: # Apply valid config params to camera object - if not self.status["record_active"]: # If not recording a video + if not self.record_active: # If not recording a video for key, value in config.items(): # For each provided setting if hasattr(self, key): @@ -192,7 +198,7 @@ class MockStreamer(BaseCamera): if isinstance(output, CaptureObject): target = output.file else: - target = target + target = output with self.lock: if isinstance(target, str): diff --git a/openflexure_microscope/camera/pi.py b/openflexure_microscope/camera/pi.py index 5cafb045..990767d0 100644 --- a/openflexure_microscope/camera/pi.py +++ b/openflexure_microscope/camera/pi.py @@ -47,6 +47,11 @@ from .base import BaseCamera, CaptureObject from .set_picamera_gain import set_analog_gain, set_digital_gain from openflexure_microscope.paths import settings_file_path +from openflexure_microscope.utilities import ( + serialise_array_b64, + ndarray_to_json, + json_to_ndarray, +) # MAIN CLASS @@ -84,15 +89,8 @@ class PiCameraStreamer(BaseCamera): picamera.PiCamera() ) #: :py:class:`picamera.PiCamera`: Picamera object - # Store status of PiCameraStreamer - self.status.update( - { - "stream_active": False, - "record_active": False, - "preview_active": False, - "board": f"picamera_{self.camera.revision}", - } - ) + # Store state of PiCameraStreamer + self.preview_active = False # Reset variable states self.set_zoom(1.0) @@ -116,15 +114,22 @@ class PiCameraStreamer(BaseCamera): "picamera_lst.npy" ) #: str: Path of .npy lens shading table file - # Update board identifier - self.status.update({}) - # Create an empty stream self.stream = io.BytesIO() # Start streaming self.start_worker() + @property + def configuration(self): + """The current camera configuration.""" + return {"board": self.camera.revision} + + @property + def state(self): + """The current read-only camera state.""" + return {} + def initialisation(self): """Run any initialisation code when the frame iterator starts.""" pass @@ -153,10 +158,7 @@ class PiCameraStreamer(BaseCamera): "image_resolution": self.image_resolution, "numpy_resolution": self.numpy_resolution, "jpeg_quality": self.jpeg_quality, - "picamera_lst_path": self.picamera_lst_path - if os.path.isfile(self.picamera_lst_path) - else None, - "picamera_settings": {}, + "picamera": {}, } ) @@ -165,18 +167,22 @@ class PiCameraStreamer(BaseCamera): try: value = getattr(self.camera, key) logging.debug("Reading PiCamera().{}: {}".format(key, value)) - conf_dict["picamera_settings"][key] = value + conf_dict["picamera"][key] = value except AttributeError: logging.debug("Unable to read PiCamera attribute {}".format(key)) + # Include a serialised lens shading table + if ( + hasattr(self.camera, "lens_shading_table") + and getattr(self.camera, "lens_shading_table") is not None + ): + conf_dict["picamera"]["lens_shading_table"] = ndarray_to_json( + getattr(self.camera, "lens_shading_table") + ) + return conf_dict - def save_settings(self): - """Save lens-shading table to disk""" - logging.info("Saving picamera_lst to {}".format(self.picamera_lst_path)) - self.save_lens_shading_table() - - def apply_settings(self, config: dict): + def update_settings(self, config: dict): """ Write a config dictionary to the PiCameraStreamer config. @@ -194,36 +200,37 @@ class PiCameraStreamer(BaseCamera): with self.lock: # Apply valid config params to Picamera object - if not self.status["record_active"]: # If not recording a video + if not self.record_active: # If not recording a video # Pause stream while changing settings - if self.status["stream_active"]: # If stream is active + if self.stream_active: # If stream is active logging.info("Pausing stream to update config.") self.stop_stream_recording() # Pause stream paused_stream = True # Remember to unpause stream when done # PiCamera parameters - if "picamera_settings" in config: # If new settings are given + if "picamera" in config: # If new settings are given self.apply_picamera_settings( - config["picamera_settings"], pause_for_effect=True + config["picamera"], pause_for_effect=True ) + # Handle lens shading if camera supports it + if ( + hasattr(self.camera, "lens_shading_table") + and "lens_shading_table" in config["picamera"] + ): + try: + self.camera.lens_shading_table = json_to_ndarray( + config["picamera"].get("lens_shading_table") + ) + except KeyError as e: + logging.error(e) + # PiCameraStreamer parameters for key, value in config.items(): # For each provided setting - if (key != "picamera_settings") and hasattr(self, key): + if (key != "picamera") and hasattr(self, key): setattr(self, key, value) - # Handle lens shading if camera supports it - if ("picamera_lst_path" in config) and hasattr( - self.camera, "lens_shading_table" - ): - logging.debug( - "Applying lens_shading_table from file: {}".format( - config["picamera_lst_path"] - ) - ) - self.apply_lens_shading_table(config["picamera_lst_path"]) - # If stream was paused to update config, unpause if paused_stream: logging.info("Resuming stream.") @@ -292,53 +299,18 @@ class PiCameraStreamer(BaseCamera): if pause_for_effect: time.sleep(0.2) - def read_lens_shading_table(self): - """ - Read the current lens shading table as a numpy array, if it exists. Return None otherwise. - """ - if hasattr(self.camera, "lens_shading_table"): - return self.camera.lens_shading_table - else: - return None - - def save_lens_shading_table(self): - """ - Save the current lens shading table to an .npy file, if it exists. - """ - logging.debug(self.read_lens_shading_table()) - if self.read_lens_shading_table() is not None: - np.save(self.picamera_lst_path, self.read_lens_shading_table()) - else: - logging.warning("Unable to save a nonexistant lens shading table") - - def apply_lens_shading_table(self, lst_array_or_path): - """ - Apply a lens shading table from an .npy file, or numpy array. - - Args: - lst_array_or_path: Numpy array, or path to .npy file, describing the lens-shading table - """ - if isinstance(lst_array_or_path, np.ndarray): - self.camera.lens_shading_table = lst_array_or_path - elif (type(lst_array_or_path) == str) and os.path.isfile(lst_array_or_path): - self.camera.lens_shading_table = np.load(lst_array_or_path) - else: - logging.error( - "Unsupported or missing data for camera lens_shading_table. Must be numpy ndarray, or .npy file path string. Skipping." - ) - def set_zoom(self, zoom_value: float = 1.0) -> None: """ Change the camera zoom, handling re-centering and scaling. """ with self.lock: - self.status["zoom_value"] = float(zoom_value) - if self.status["zoom_value"] < 1: - self.status["zoom_value"] = 1 + self.zoom_value = float(zoom_value) + if self.zoom_value < 1: + self.zoom_value = 1 # Richard's code for zooming ! fov = self.camera.zoom centre = np.array([fov[0] + fov[2] / 2.0, fov[1] + fov[3] / 2.0]) - size = 1.0 / self.status["zoom_value"] + size = 1.0 / self.zoom_value # If the new zoom value would be invalid, move the centre to # keep it within the camera's sensor (this is only relevant # when zooming out, if the FoV is not centred on (0.5, 0.5) @@ -365,7 +337,7 @@ class PiCameraStreamer(BaseCamera): self.camera.preview.window = window if fullscreen: self.camera.preview.fullscreen = fullscreen - self.status["preview_active"] = True + self.preview_active = True except picamera.exc.PiCameraMMALError as e: logging.error( "Suppressed a MMALError in start_preview. Exception: {}".format(e) @@ -380,7 +352,7 @@ class PiCameraStreamer(BaseCamera): def stop_preview(self): """Stop the on board GPU camera preview.""" self.camera.stop_preview() - self.status["preview_active"] = False + self.preview_active = False def start_recording(self, output, fmt: str = "h264", quality: int = 15): """Start recording. @@ -398,7 +370,7 @@ class PiCameraStreamer(BaseCamera): """ with self.lock: # Start recording method only if a current recording is not running - if not self.status["record_active"]: + if not self.record_active: # Start the camera video recording on port 2 logging.info("Recording to {}".format(output)) @@ -411,8 +383,8 @@ class PiCameraStreamer(BaseCamera): quality=quality, ) - # Update status dictionary - self.status["record_active"] = True + # Update state + self.record_active = True return output @@ -431,8 +403,8 @@ class PiCameraStreamer(BaseCamera): self.camera.stop_recording(splitter_port=2) logging.info("Recording stopped") - # Update status dictionary - self.status["record_active"] = False + # Update state + self.record_active = False def stop_stream_recording( self, splitter_port: int = 1, resolution: Tuple[int, int] = None @@ -500,7 +472,7 @@ class PiCameraStreamer(BaseCamera): self.camera.resolution = resolution # If the stream should be active - if self.status["stream_active"]: + if self.stream_active: try: # Start recording on stream port self.camera.start_recording( @@ -551,7 +523,7 @@ class PiCameraStreamer(BaseCamera): if isinstance(output, CaptureObject): target = output.file else: - target = target + target = output with self.lock: logging.info("Capturing to {}".format(output)) @@ -680,7 +652,6 @@ class PiCameraStreamer(BaseCamera): # Start stream recording (and set resolution) self.start_stream_recording() - # Update status logging.debug("STREAM ACTIVE") # While the iterator is not closed diff --git a/openflexure_microscope/config.py b/openflexure_microscope/config.py index d4b3aa3f..3cd36f72 100644 --- a/openflexure_microscope/config.py +++ b/openflexure_microscope/config.py @@ -1,4 +1,5 @@ import json +import flask import os import errno import logging @@ -7,7 +8,12 @@ from uuid import UUID import numpy as np from fractions import Fraction -from .paths import OPENFLEXURE_ETC_PATH, CONFIG_FILE_PATH, DEFAULT_CONFIG_FILE_PATH +from .paths import ( + SETTINGS_FILE_PATH, + DEFAULT_SETTINGS_FILE_PATH, + CONFIGURATION_FILE_PATH, + DEFAULT_CONFIGURATION_FILE_PATH, +) class OpenflexureSettingsFile: @@ -19,21 +25,21 @@ class OpenflexureSettingsFile: expand (bool): Expand paths to valid auxillary config files. """ - def __init__(self, config_path: str = None): - global DEFAULT_CONFIG, CONFIG_FILE_PATH + def __init__(self, path: str, defaults: dict = {}): + global DEFAULT_SETTINGS # Set arguments - self.config_path = config_path or CONFIG_FILE_PATH + self.path = path # Initialise basic config file with defaults if it doesn't exist - initialise_file(self.config_path, populate=DEFAULT_CONFIG) + initialise_file(self.path, populate=defaults) def load(self) -> dict: """ Loads settings from a file on-disk. """ # Unexpanded config dictionary (used at load/save time) - loaded_config = load_json_file(self.config_path) + loaded_config = load_json_file(self.path) logging.debug("Reading settings from disk") return loaded_config @@ -50,11 +56,11 @@ class OpenflexureSettingsFile: save_settings = config if backup: - if os.path.isfile(self.config_path): - shutil.copyfile(self.config_path, self.config_path + ".bk") + if os.path.isfile(self.path): + shutil.copyfile(self.path, self.path + ".bk") logging.debug("Saving settings dictionary to disk") - save_json_file(self.config_path, save_settings) + save_json_file(self.path, save_settings) def merge(self, config: dict) -> dict: """ @@ -71,7 +77,7 @@ class OpenflexureSettingsFile: return settings -class JSONEncoder(json.JSONEncoder): +class JSONEncoder(flask.json.JSONEncoder): """ A custom JSON encoder, with type conversions for PiCamera fractions, Numpy integers, and Numpy arrays """ @@ -88,14 +94,13 @@ class JSONEncoder(json.JSONEncoder): # Numpy arrays elif isinstance(o, np.ndarray): return o.tolist() + # UUIDs + elif isinstance(o, UUID): + return str(o) else: # call base class implementation which takes care of # raising exceptions for unsupported types - try: - return json.JSONEncoder.default(self, o) - # if it's some mystery object, just return a string representation - except TypeError: - return str(o) + return flask.json.JSONEncoder.default(self, o) # HANDLE BASIC LOADING AND SAVING OF SETTINGS FILES @@ -178,10 +183,22 @@ def initialise_file(config_path, populate: str = "{}\n"): outfile.write(populate) -# Load the default config -with open(DEFAULT_CONFIG_FILE_PATH, "r") as default_rc: - DEFAULT_CONFIG = default_rc.read() - +# Load the default settings +with open(DEFAULT_SETTINGS_FILE_PATH, "r") as default_settings: + DEFAULT_SETTINGS = default_settings.read() #: Default user settings object -user_settings = OpenflexureSettingsFile(config_path=CONFIG_FILE_PATH) +user_settings = OpenflexureSettingsFile( + path=SETTINGS_FILE_PATH, defaults=DEFAULT_SETTINGS +) + + +# Load the default configuration +with open(DEFAULT_CONFIGURATION_FILE_PATH, "r") as default_configuration: + DEFAULT_CONFIGURATION = default_configuration.read() + +#: Default user settings object +user_configuration = OpenflexureSettingsFile( + path=CONFIGURATION_FILE_PATH, defaults=DEFAULT_CONFIGURATION +) + diff --git a/openflexure_microscope/microscope.py b/openflexure_microscope/microscope.py index dabf7033..3ed73568 100644 --- a/openflexure_microscope/microscope.py +++ b/openflexure_microscope/microscope.py @@ -6,15 +6,21 @@ import logging import pkg_resources import uuid -from openflexure_microscope.stage.base import BaseStage -from openflexure_microscope.stage.mock import MockStage -from openflexure_microscope.camera.base import BaseCamera -from openflexure_microscope.camera.mock import MockStreamer +from openflexure_microscope.stage.mock import MissingStage +from openflexure_microscope.camera.mock import MissingCamera +from openflexure_microscope.stage.sanga import SangaStage + +try: + from openflexure_microscope.camera.pi import PiCameraStreamer +except ImportError: + logging.warning("Unable to import PiCameraStreamer") +from openflexure_microscope.camera.mock import MissingCamera from openflexure_microscope.utilities import serialise_array_b64 -from openflexure_microscope.config import user_settings +from openflexure_microscope.config import user_settings, user_configuration from labthings.core.lock import CompositeLock +from labthings.core.utilities import rupdate class Microscope: @@ -24,20 +30,26 @@ class Microscope: The camera and stage objects may already be initialised, and can be passed as arguments. """ - def __init__(self): - # Initial attributes - self.id = uuid.uuid4() #: Microscope UUID - self.name = self.id #: Microscope name (modifiable) - self.fov = [0, 0] #: Microscope field-of-view in stage motor steps - self.camera = None #: Currently connected camera object - self.stage = None #: Currently connected stage object + def __init__(self, settings=user_settings, configuration=user_configuration): + self.id = uuid.uuid4() + self.name = self.id + + self.fov = [0, 0] #: Microscope field-of-view in stage motor steps + + # Store settings and configuration files + self.settings_file = settings + self.configuration_file = configuration # Initialise with an empty composite lock #: :py:class:`labthings.lock.CompositeLock`: Composite lock for locking both camera and stage self.lock = CompositeLock([]) + self.camera = None #: Currently connected camera object + self.stage = None #: Currently connected stage object + self.setup(self.configuration_file.load()) # Attach components + # Apply settings loaded from file - self.apply_settings(user_settings.load()) + self.update_settings(self.settings_file.load()) def __enter__(self): """Create microscope on context enter.""" @@ -56,58 +68,49 @@ class Microscope: self.stage.close() logging.info("Closed {}".format(self)) - def attach(self, camera: BaseCamera, stage: BaseStage): + def setup(self, configuration): """ - Retroactively attaches a camera and stage to the microscope object. - - Allows the microscope to be created as a "dummy", with hardware communications - opened at a later time. - - Args: - camera (:py:class:`openflexure_microscope.camera.base.BaseCamera`): camera object - stage (:py:class:`openflexure_microscope.stage.base.BaseStage`): stage object + Attach microscope components based on initially passed configuration file """ - settings_full = self.read_settings() + ### Detector + if configuration.get("camera"): + camera_type = configuration["camera"].get("type") + if camera_type in ("PiCamera", "PiCameraStreamer"): + try: + self.camera = PiCameraStreamer() + except Exception as e: + logging.error(e) + logging.warning("No compatible camera hardware found.") - logging.debug("Attaching camera...") - self.camera = ( - camera - ) #: :py:class:`openflexure_microscope.camera.base.BaseCamera`: Picamera object + ### Stage + if configuration.get("stage"): + stage_type = configuration["stage"].get("type") + stage_port = configuration["stage"].get("port") + if stage_type in ("SangaBoard", "SangaStage"): + try: + self.stage = SangaStage(port=stage_port) + except Exception as e: + logging.error(e) + logging.warning("No compatible Sangaboard hardware found.") + + ### Fallbacks if not self.camera: - logging.info("No camera attached.") - else: - logging.info("Attached camera {}".format(camera)) - - if hasattr(self.camera, "lock"): # If camera has a lock - logging.info("Attaching {} to composite lock.".format(self.camera.lock)) - # Add the lock to the microscope composite lock - self.lock.locks.append(self.camera.lock) - - logging.debug("Attaching stage...") - self.stage = ( - stage - ) #: :py:class:`openflexure_microscope.stage.base.BaseStage`: OpenFlexure stage object + self.camera = MissingCamera() if not self.stage: - logging.info("No stage attached.") - else: - logging.info("Attached stage {}".format(stage)) + self.stage = MissingStage() - if hasattr(self.stage, "lock"): # If stage object has a lock - logging.info( - "Attaching lock {} to composite lock.".format(self.stage.lock) - ) - # Add the lock to the microscope composite lock - self.lock.locks.append(self.stage.lock) - - logging.info("Reapplying settings to newly attached devices") - self.apply_settings(settings_full) + ### Locks + if hasattr(self.camera, "lock"): + self.lock.locks.append(self.camera.lock) + if hasattr(self.stage, "lock"): + self.lock.locks.append(self.stage.lock) def has_real_stage(self) -> bool: """ Check if a real (non-mock) stage is currently attached. """ - if hasattr(self, "stage") and not isinstance(self.stage, MockStage): + if hasattr(self, "stage") and not isinstance(self.stage, MissingStage): return True else: return False @@ -116,49 +119,45 @@ class Microscope: """ Check if a real (non-mock) camera is currently attached. """ - if hasattr(self, "camera") and not isinstance(self.camera, MockStreamer): + if hasattr(self, "camera") and not isinstance(self.camera, MissingCamera): return True else: return False - # Create unified status + # Create unified state @property - def status(self): - """Dictionary of the basic microscope status. + def state(self): + """Dictionary of the basic microscope state. Return: - dict: Dictionary containing complete microscope status + dict: Dictionary containing complete microscope state """ - state = { - "camera": self.camera.status, - "stage": self.stage.status, - "version": pkg_resources.get_distribution("openflexure_microscope").version, - } + state = {"camera": self.camera.state, "stage": self.stage.state} return state - def apply_settings(self, config: dict): + def update_settings(self, settings: dict): """ Applies a settings dictionary to the microscope. Missing parameters will be left untouched. """ - logging.debug("Microscope: Applying config: {}".format(config)) + logging.debug("Microscope: Applying settings: {}".format(settings)) # If attached to a camera - if ("camera_settings" in config) and self.camera: - self.camera.apply_settings(config["camera_settings"]) + if ("camera" in settings) and self.camera: + self.camera.update_settings(settings["camera"]) # If attached to a stage - if ("stage_settings" in config) and self.stage: - self.stage.apply_settings(config["stage_settings"]) + if ("stage" in settings) and self.stage: + self.stage.update_settings(settings["stage"]) - # Todo: tidy up with some loopy goodness - if "id" in config: - self.id = config["id"] - if "name" in config: - self.name = config["name"] - if "fov" in config: - self.fov = config["fov"] + # Microscope settings + if "id" in settings: + self.id = settings["id"] + if "name" in settings: + self.name = settings["name"] + if "fov" in settings: + self.fov = settings["fov"] - def read_settings(self, json_safe=False): + def read_settings(self, full: bool = True): """ Get an updated settings dictionary. @@ -174,16 +173,34 @@ class Microscope: # If attached to a camera if self.camera: settings_current_camera = self.camera.read_settings() - settings_current["camera_settings"] = settings_current_camera + settings_current["camera"] = settings_current_camera + + # Store an encoded copy of the PiCamera lens shading table, if it exists + if hasattr(self.camera, "read_lens_shading_table"): + # Read LST. Returns None if no LST is active + lst_arr = self.camera.read_lens_shading_table() + + if lst_arr is not None: + b64_string, dtype, shape = serialise_array_b64(lst_arr) + + settings_current["camera"]["lens_shading_table"] = { + "@type": "ndarray", + "b64_string": b64_string, + "dtype": dtype, + "shape": shape, + } # If attached to a stage if self.stage: settings_current_stage = self.stage.read_settings() - settings_current["stage_settings"] = settings_current_stage + settings_current["stage"] = settings_current_stage - settings_full = user_settings.merge(settings_current) + settings_full = self.settings_file.merge(settings_current) - return settings_full + if full: + return settings_full + else: + return settings_current def save_settings(self): """ @@ -192,11 +209,31 @@ class Microscope: # Read curent config current_config = self.read_settings() # Save config to file - if self.camera: - self.camera.save_settings() - if self.stage: - self.stage.save_settings() - user_settings.save(current_config, backup=True) + self.settings_file.save(current_config, backup=True) + + @property + def configuration(self): + initial_configuration = self.configuration_file.load() + + current_configuration = { + "application": { + "name": "openflexure_microscope", + "version": pkg_resources.get_distribution( + "openflexure_microscope" + ).version, + }, + "stage": { + "type": self.stage.__class__.__name__, + **self.stage.configuration, + }, + "camera": { + "type": self.camera.__class__.__name__, + **self.camera.configuration, + }, + } + + initial_configuration.update(current_configuration) + return initial_configuration @property def metadata(self): @@ -204,23 +241,10 @@ class Microscope: Microscope system metadata, to be applied to basically all captures """ system_metadata = { - "microscope_settings": self.read_settings(), - "microscope_state": self.status, - "microscope_id": self.id, - "microscope_name": self.name, + "id": self.id, + "settings": self.read_settings(full=False), + "state": self.state, + "configuration": self.configuration, } - # Store an encoded copy of the PiCamera lens shading table, if it exists - if self.camera and hasattr(self.camera, "read_lens_shading_table"): - # Read LST. Returns None if no LST is active - lst_arr = self.camera.read_lens_shading_table() - - b64_string, dtype, shape = serialise_array_b64(lst_arr) - - system_metadata["lens_shading_table"] = { - "b64_string": b64_string, - "dtype": dtype, - "shape": shape, - } - return system_metadata diff --git a/openflexure_microscope/microscope_configuration.default.json b/openflexure_microscope/microscope_configuration.default.json new file mode 100644 index 00000000..b9cbfdce --- /dev/null +++ b/openflexure_microscope/microscope_configuration.default.json @@ -0,0 +1,9 @@ +{ + "camera": { + "type": "PiCamera" + }, + "stage": { + "type": "SangaStage", + "port": null + } +} \ No newline at end of file diff --git a/openflexure_microscope/microscope_settings.default.json b/openflexure_microscope/microscope_settings.default.json index bde62f47..5c408d2e 100644 --- a/openflexure_microscope/microscope_settings.default.json +++ b/openflexure_microscope/microscope_settings.default.json @@ -3,5 +3,5 @@ 4100, 3146 ], - "jpeg_quality": 75 + "jpeg_quality": 100 } \ No newline at end of file diff --git a/openflexure_microscope/paths.py b/openflexure_microscope/paths.py index 6650f5a5..b9d26bda 100644 --- a/openflexure_microscope/paths.py +++ b/openflexure_microscope/paths.py @@ -10,12 +10,34 @@ def check_rw(path): def settings_file_path(filename: str): """Generate a full file path for a filename to be stored in server settings folder""" - return os.path.join(OPENFLEXURE_ETC_PATH, filename) + settings_dir = os.path.join(OPENFLEXURE_VAR_PATH, "settings") + if not os.path.exists(settings_dir): + os.makedirs(settings_dir) + return os.path.join(settings_dir, filename) def data_file_path(filename: str): """Generate a full file path for a filename to be stored in server data folder""" - return os.path.join(OPENFLEXURE_VAR_PATH, filename) + data_dir = os.path.join(OPENFLEXURE_VAR_PATH, "data") + if not os.path.exists(data_dir): + os.makedirs(data_dir) + return os.path.join(data_dir, filename) + + +def extensions_file_path(filename: str): + """Generate a full file path for a folder to be stored in server extensions""" + ext_dir = os.path.join(OPENFLEXURE_VAR_PATH, "extensions") + if not os.path.exists(ext_dir): + os.makedirs(ext_dir) + return os.path.join(ext_dir, filename) + + +def logs_file_path(filename: str): + """Generate a full file path for a filename to be stored in server logs""" + logs_dir = os.path.join(OPENFLEXURE_VAR_PATH, "logs") + if not os.path.exists(logs_dir): + os.makedirs(logs_dir) + return os.path.join(logs_dir, filename) # HANDLE DEFAULTS FILES STORED IN THIS APPLICATION @@ -23,10 +45,13 @@ def data_file_path(filename: str): HERE = os.path.abspath(os.path.dirname(__file__)) #: Path of default (first-run) microscope settings -DEFAULT_CONFIG_FILE_PATH = os.path.join(HERE, "microscope_settings.default.json") +DEFAULT_SETTINGS_FILE_PATH = os.path.join(HERE, "microscope_settings.default.json") +#: Path of default (first-run) microscope configuration +DEFAULT_CONFIGURATION_FILE_PATH = os.path.join( + HERE, "microscope_configuration.default.json" +) - -# DATA BASE PATHS +# BASE PATHS if os.name == "nt": PREFERRED_VAR_PATH = os.getenv("PROGRAMDATA") or "C:\\ProgramData" @@ -49,37 +74,11 @@ else: OPENFLEXURE_VAR_PATH = FALLBACK_OPENFLEXURE_VAR_PATH -# SERVER BASE PATHS - -if os.name == "nt": - PREFERRED_ETC_PATH = os.getenv("PROGRAMDATA") or "C:\\ProgramData" - FALLBACK_ETC_PATH = os.path.expanduser("~") -else: - PREFERRED_ETC_PATH = "/etc" - FALLBACK_ETC_PATH = os.path.join(os.path.expanduser("~"), ".config") - -PREFERRED_OPENFLEXURE_ETC_PATH = os.path.join(PREFERRED_ETC_PATH, "openflexure") -FALLBACK_OPENFLEXURE_ETC_PATH = os.path.join(FALLBACK_ETC_PATH, "openflexure") - -if not os.path.exists(PREFERRED_OPENFLEXURE_ETC_PATH) and check_rw(PREFERRED_ETC_PATH): - os.makedirs(PREFERRED_OPENFLEXURE_ETC_PATH) - -if check_rw(PREFERRED_OPENFLEXURE_ETC_PATH): - OPENFLEXURE_ETC_PATH = PREFERRED_OPENFLEXURE_ETC_PATH -else: - if not os.path.exists(FALLBACK_OPENFLEXURE_ETC_PATH): - os.makedirs(FALLBACK_OPENFLEXURE_ETC_PATH) - OPENFLEXURE_ETC_PATH = FALLBACK_OPENFLEXURE_ETC_PATH - - # SERVER PATHS -#: Path of microscope settings directory -CONFIG_FILE_PATH = os.path.join(OPENFLEXURE_ETC_PATH, "microscope_settings.json") +#: Path of microscope settings file +SETTINGS_FILE_PATH = settings_file_path("microscope_settings.json") +#: Path of microscope configuration file +CONFIGURATION_FILE_PATH = settings_file_path("microscope_configuration.json") #: Path of microscope extensions directory -OPENFLEXURE_EXTENSIONS_PATH = os.path.join( - OPENFLEXURE_ETC_PATH, "microscope_extensions" -) - - -# DATA PATHS +OPENFLEXURE_EXTENSIONS_PATH = extensions_file_path("microscope_extensions") diff --git a/openflexure_microscope/rescue/__init__.py b/openflexure_microscope/rescue/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/openflexure_microscope/rescue/auto.py b/openflexure_microscope/rescue/auto.py new file mode 100644 index 00000000..9983c1a9 --- /dev/null +++ b/openflexure_microscope/rescue/auto.py @@ -0,0 +1,77 @@ +from openflexure_microscope.paths import ( + FALLBACK_OPENFLEXURE_VAR_PATH, + PREFERRED_OPENFLEXURE_VAR_PATH, +) +import logging +import os + +from . import check_settings, check_capture_reload + +# Paths for suggestions +LOGS_PATHS = [ + os.path.join(PREFERRED_OPENFLEXURE_VAR_PATH, "logs"), + os.path.join(FALLBACK_OPENFLEXURE_VAR_PATH, "logs"), +] +SETTINGS_PATHS = [ + os.path.join(var_path, "settings", "microscope_settings.json") + for var_path in (PREFERRED_OPENFLEXURE_VAR_PATH, FALLBACK_OPENFLEXURE_VAR_PATH) +] +CONFIG_PATHS = [ + os.path.join(var_path, "settings", "microscope_configuration.json") + for var_path in (PREFERRED_OPENFLEXURE_VAR_PATH, FALLBACK_OPENFLEXURE_VAR_PATH) +] +DATA_PATHS = [ + os.path.join(PREFERRED_OPENFLEXURE_VAR_PATH, "data"), + os.path.join(FALLBACK_OPENFLEXURE_VAR_PATH, "data"), +] + +logger = logging.getLogger() +logger.setLevel(logging.DEBUG) +logging.debug("Testing debug logger. One two one two.") + + +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" + + +error_keys = { + "config_settings_import_error": "The configuration submodule could not be imported properly. This is usually because the default config or settings files are badly broken somehow. See further errors for details", + "default_config_empty": "The default configuration file is empty, and could not be automatically populated.", + "default_settings_empty": "The default settings file is empty, and could not be automatically populated.", + "default_config_error": f"The default configuration file could not be parsed. To fix, consider backing up and deleting your configuration files from {CONFIG_PATHS} to reset the configuration.", + "default_settings_error": f"The default settings file could not be parsed. To fix, consider backing up and deleting your configuration files from {SETTINGS_PATHS} to reset the configuration.", + "capture_rebuild_timeout": f"Capture database rebuilding took a long time. This may not cause catastrophic errors, but rather will cause the server to hang for a while. To fix, consider moving your captures from {DATA_PATHS} to another location.", +} + +if __name__ == "__main__": + spoof = False + + error_sources = [] + + if spoof: + error_sources = list(error_keys.keys()) + + error_sources.extend(check_settings.main()) + error_sources.extend(check_capture_reload.main()) + + if not error_sources: + + print() + print(bcolors.OKGREEN + "No errors found!" + bcolors.ENDC) + print( + "That's not to say everything is fine, only that our automatic diagnostics couldn't find much." + ) + print(f"You can check through the server logs at {LOGS_PATHS}") + + else: + for err_code in error_sources: + logging.error(err_code) + if error_keys.get(err_code): + print(bcolors.FAIL + error_keys.get(err_code) + bcolors.ENDC) diff --git a/openflexure_microscope/rescue/check_capture_reload.py b/openflexure_microscope/rescue/check_capture_reload.py new file mode 100644 index 00000000..3a61c692 --- /dev/null +++ b/openflexure_microscope/rescue/check_capture_reload.py @@ -0,0 +1,34 @@ +from openflexure_microscope.rescue.monitor_timeout import launch_timeout_test_process +from openflexure_microscope.camera.capture import build_captures_from_exif +from openflexure_microscope.camera.base import BASE_CAPTURE_PATH +from openflexure_microscope.config import user_settings + +import logging + + +def check_capture_rebuild(timeout=10): + logging.info("Loading user settings...") + settings = user_settings.load() + + cap_path = str(settings.get("camera", {}).get("paths", {}).get("default")) + logging.info(f"Capture path found: {cap_path}") + if not cap_path: + logging.error( + "No capture path defined in settings. This is unusual for anything other than a first-run. \nFalling back to default path." + ) + cap_path = BASE_CAPTURE_PATH + + logging.info("Starting capture reload with a 10 second timeout...") + passed_timeout_test = launch_timeout_test_process( + build_captures_from_exif, args=(cap_path,) + ) + + return passed_timeout_test + + +def main(): + error_sources = [] + passed_timeout = check_capture_rebuild() + if not passed_timeout: + error_sources.append("capture_rebuild_timeout") + return error_sources diff --git a/openflexure_microscope/rescue/check_settings.py b/openflexure_microscope/rescue/check_settings.py new file mode 100644 index 00000000..9ba33ba7 --- /dev/null +++ b/openflexure_microscope/rescue/check_settings.py @@ -0,0 +1,47 @@ +import logging +import json + +ERROR_SOURCES = [] + + +def trace_config_exceptions(): + error_sources = [] + + from openflexure_microscope.paths import ( + DEFAULT_CONFIGURATION_FILE_PATH, + SETTINGS_FILE_PATH, + ) + + try: + default_config = json.load(DEFAULT_CONFIGURATION_FILE_PATH) + if not default_config: + error_sources.append("default_config_empty") + except Exception as e: + logging.error("Error parsing config:") + logging.error(e) + error_sources.append("default_config_error") + + try: + default_settings = json.load(SETTINGS_FILE_PATH) + if not default_settings: + error_sources.append("default_settings_empty") + except Exception as e: + logging.error("Error parsing settings:") + logging.error(e) + error_sources.append("default_settings_error") + + return error_sources + + +def main(): + error_sources = [] + logging.info("Attempting default settings and config import...") + try: + from openflexure_microscope import config + except Exception as e: + error_sources.append("config_settings_import_error") + logging.error("Error importing config:") + logging.error(e) + error_sources.extend(trace_config_exceptions()) + + return error_sources diff --git a/openflexure_microscope/rescue/monitor_service.py b/openflexure_microscope/rescue/monitor_service.py new file mode 100644 index 00000000..f12ab1b9 --- /dev/null +++ b/openflexure_microscope/rescue/monitor_service.py @@ -0,0 +1,59 @@ +#!/bin/python +# +# Copyright 2016 Flavio Garcia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Usage: monitor_process.py +# +# Example(crontab, every 5 minutes): +# */5 * * * * /root/bin/monitor_service.py prosody > /dev/null 2>&1 +# + +import sys +import subprocess + + +class ServiceMonitor(object): + def __init__(self, service): + self.service = service + + def is_active(self): + """Return True if service is running""" + for line in self.status(): + if "Active:" in line: + if "(running)" in line: + return True + return False + + def status(self): + cmd = f"/bin/systemctl status {self.service}.service" + proc = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE) + stdout_list = proc.communicate()[0].decode().split("\n") + return stdout_list + + def start(self): + cmd = f"/bin/systemctl start {self.service}.service" + proc = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE) + proc.communicate() + + def stop(self): + cmd = f"/bin/systemctl stop {self.service}.service" + proc = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE) + proc.communicate() + + def log(self, n_lines: int = 100): + cmd = f"/bin/journalctl -u {self.service}.service -n {n_lines} --no-pager" + proc = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE) + stdout_list = proc.communicate()[0].decode().split("\n") + return stdout_list diff --git a/openflexure_microscope/rescue/monitor_timeout.py b/openflexure_microscope/rescue/monitor_timeout.py new file mode 100644 index 00000000..b02b29b6 --- /dev/null +++ b/openflexure_microscope/rescue/monitor_timeout.py @@ -0,0 +1,39 @@ +import multiprocessing +import logging +import time + +# bar +def test_long_fn(): + for _ in range(100): + print("Tick") + time.sleep(1) + + +def launch_timeout_test_process(target, args=(), kwargs=None, timeout=10): + if not kwargs: + kwargs = {} + + # Start target as a process + p = multiprocessing.Process(target=target, args=args, kwargs=kwargs) + p.start() + + # Wait for 10 seconds or until process finishes + p.join(timeout) + + # If thread is still active + if p.is_alive(): + logging.error( + f"Function {target} reached timeout after {timeout} seconds. Terminating." + ) + + # Terminate + p.terminate() + p.join() + + return False + else: + return True + + +if __name__ == "__main__": + launch_timeout_test_process(test_long_fn, timeout=5) diff --git a/openflexure_microscope/stage/base.py b/openflexure_microscope/stage/base.py index 5821cff2..b04909b2 100644 --- a/openflexure_microscope/stage/base.py +++ b/openflexure_microscope/stage/base.py @@ -14,7 +14,7 @@ class BaseStage(metaclass=ABCMeta): self.lock = StrictLock(timeout=5) @abstractmethod - def apply_settings(self, config: dict): + def update_settings(self, config: dict): """Update settings from a config dictionary""" pass @@ -23,18 +23,16 @@ class BaseStage(metaclass=ABCMeta): """Return the current settings as a dictionary""" pass - def save_settings(self): - """(Optional) Save any settings to disk that need to be stored""" - return + @property + @abstractmethod + def state(self): + """The general state dictionary of the board.""" + pass @property @abstractmethod - def status(self): - """The general state dictionary of the board. - Should at least contain 'position', and 'board' keys. - Note: A None/Null value for 'board' will disable stage - movement in the OpenFlexure eV client software, - """ + def configuration(self): + """The general stage configuration.""" pass @property @@ -51,11 +49,7 @@ class BaseStage(metaclass=ABCMeta): @property def position_map(self): - return { - "x": self.position[0], - "y": self.position[1], - "z": self.position[2], - } + return {"x": self.position[0], "y": self.position[1], "z": self.position[2]} @property @abstractmethod diff --git a/openflexure_microscope/stage/mock.py b/openflexure_microscope/stage/mock.py index fe2d2ac6..fe523c64 100644 --- a/openflexure_microscope/stage/mock.py +++ b/openflexure_microscope/stage/mock.py @@ -7,7 +7,7 @@ import time import logging -class MockStage(BaseStage): +class MissingStage(BaseStage): def __init__(self, port=None, **kwargs): BaseStage.__init__(self) self._position = [0, 0, 0] @@ -17,19 +17,17 @@ class MockStage(BaseStage): self.axis_names = ["x", "y", "z"] # Assume all sangaboards are 3 axis @property - def status(self): - """The general status dictionary of the board.""" - status = { - "position": self.position_map, - "board": None, - "firmware": None, - "version": None - } - return status + def state(self): + """The general state dictionary of the board.""" + state = {"position": self.position_map} + return state - def apply_settings(self, config: dict): + @property + def configuration(self): + return {} + + def update_settings(self, config: dict): """Update settings from a config dictionary""" - # Set backlash. Expects a dictionary with axis labels if "backlash" in config: # Construct backlash array diff --git a/openflexure_microscope/stage/sanga.py b/openflexure_microscope/stage/sanga.py index 2c4fa6d9..d5ffcf15 100644 --- a/openflexure_microscope/stage/sanga.py +++ b/openflexure_microscope/stage/sanga.py @@ -23,6 +23,7 @@ class SangaStage(BaseStage): """Class managing serial communications with the motors for an Openflexure stage""" BaseStage.__init__(self) + self.port = port self.board = Sangaboard(port, **kwargs) self._backlash = ( @@ -31,14 +32,17 @@ class SangaStage(BaseStage): self.axis_names = ["x", "y", "z"] # Assume all sangaboards are 3 axis @property - def status(self): - """The general status dictionary of the board.""" - status = { - "position": self.position_map, + def state(self): + """The general state dictionary of the board.""" + return {"position": self.position_map} + + @property + def configuration(self): + return { + "port": self.port, "board": self.board.board, "firmware": self.board.firmware, } - return status @property def n_axes(self): @@ -81,7 +85,7 @@ class SangaStage(BaseStage): else: self._backlash = np.array([int(blsh)] * self.n_axes, dtype=np.int) - def apply_settings(self, config: dict): + def update_settings(self, config: dict): """Update settings from a config dictionary""" # Set backlash. Expects a dictionary with axis labels diff --git a/openflexure_microscope/utilities.py b/openflexure_microscope/utilities.py index bd88664e..63db1651 100644 --- a/openflexure_microscope/utilities.py +++ b/openflexure_microscope/utilities.py @@ -4,6 +4,7 @@ import operator import base64 from uuid import UUID import numpy as np +import logging from collections import abc from functools import reduce from contextlib import contextmanager @@ -21,6 +22,31 @@ def serialise_array_b64(npy_arr): return b64_string, dtype, shape +def ndarray_to_json(arr: np.ndarray): + if isinstance(arr, memoryview): + # We can transparently convert memoryview objects to arrays + # This comes in very handy for the lens shading table. + arr = np.array(arr) + b64_string, dtype, shape = serialise_array_b64(arr) + return { + "@type": "ndarray", + "dtype": dtype, + "shape": shape, + "base64": b64_string + } + + +def json_to_ndarray(json_dict: dict): + if not json_dict.get("@type") != "ndarray": + logging.warning("No valid @type attribute found. Conversion may fail.") + for required_param in ("dtype", "shape", "base64"): + if not json_dict.get(required_param): + raise KeyError(f"Missing required key {required_param}") + + return deserialise_array_b64(json_dict.get("base64"), json_dict.get("dtype"), json_dict.get("shape")) + + + @contextmanager def set_properties(obj, **kwargs): """A context manager to set, then reset, certain properties of an object. diff --git a/poetry.lock b/poetry.lock index 894a2805..d177e8f5 100644 --- a/poetry.lock +++ b/poetry.lock @@ -12,12 +12,12 @@ description = "A pluggable API specification generator. Currently supports the O name = "apispec" optional = false python-versions = ">=3.5" -version = "3.2.0" +version = "3.3.0" [package.extras] -dev = ["PyYAML (>=3.10)", "prance (>=0.11)", "marshmallow (>=2.19.2)", "pytest", "mock", "flake8 (3.7.9)", "flake8-bugbear (19.8.0)", "pre-commit (>=1.20,<2.0)", "tox"] -docs = ["marshmallow (>=2.19.2)", "pyyaml (5.2)", "sphinx (2.3.0)", "sphinx-issues (1.2.0)", "sphinx-rtd-theme (0.4.3)"] -lint = ["flake8 (3.7.9)", "flake8-bugbear (19.8.0)", "pre-commit (>=1.20,<2.0)"] +dev = ["PyYAML (>=3.10)", "prance (>=0.11)", "marshmallow (>=2.19.2)", "pytest", "mock", "flake8 (3.7.9)", "flake8-bugbear (20.1.4)", "pre-commit (>=1.20,<3.0)", "tox"] +docs = ["marshmallow (>=2.19.2)", "pyyaml (5.3)", "sphinx (2.4.1)", "sphinx-issues (1.2.0)", "sphinx-rtd-theme (0.4.3)"] +lint = ["flake8 (3.7.9)", "flake8-bugbear (20.1.4)", "pre-commit (>=1.20,<3.0)"] tests = ["PyYAML (>=3.10)", "prance (>=0.11)", "marshmallow (>=2.19.2)", "pytest", "mock"] validation = ["prance (>=0.11)"] yaml = ["PyYAML (>=3.10)"] @@ -97,6 +97,18 @@ optional = false python-versions = "*" version = "2019.11.28" +[[package]] +category = "main" +description = "Foreign Function Interface for Python calling C code." +marker = "sys_platform == \"win32\" and platform_python_implementation == \"CPython\"" +name = "cffi" +optional = false +python-versions = "*" +version = "1.14.0" + +[package.dependencies] +pycparser = "*" + [[package]] category = "dev" description = "Universal encoding detector for Python 2 and 3" @@ -110,8 +122,8 @@ category = "main" description = "Composable command line interface toolkit" name = "click" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "7.0" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +version = "7.1.1" [[package]] category = "dev" @@ -161,13 +173,51 @@ version = "3.0.8" Flask = ">=0.9" Six = "*" +[[package]] +category = "main" +description = "Coroutine-based network library" +name = "gevent" +optional = false +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" +version = "1.4.0" + +[package.dependencies] +cffi = ">=1.11.5" +greenlet = ">=0.4.14" + +[package.extras] +dnspython = ["dnspython", "idna"] +doc = ["repoze.sphinx.autointerface"] +events = ["zope.event", "zope.interface"] +test = ["zope.interface", "zope.event", "requests", "objgraph", "psutil", "futures", "mock", "coverage (>=5.0a3)", "coveralls (>=1.0)"] + +[[package]] +category = "main" +description = "Websocket handler for the gevent pywsgi server, a Python network library" +name = "gevent-websocket" +optional = false +python-versions = "*" +version = "0.10.1" + +[package.dependencies] +gevent = "*" + +[[package]] +category = "main" +description = "Lightweight in-process concurrent programming" +marker = "platform_python_implementation == \"CPython\"" +name = "greenlet" +optional = false +python-versions = "*" +version = "0.4.15" + [[package]] category = "dev" description = "Internationalized Domain Names in Applications (IDNA)" name = "idna" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "2.8" +version = "2.9" [[package]] category = "dev" @@ -204,8 +254,8 @@ category = "main" description = "A very fast and expressive template engine." name = "jinja2" optional = false -python-versions = "*" -version = "2.10.3" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +version = "2.11.1" [package.dependencies] MarkupSafe = ">=0.23" @@ -215,23 +265,21 @@ i18n = ["Babel (>=0.8)"] [[package]] category = "main" -description = "" +description = "Python implementation of LabThings, based on the Flask microframework" name = "labthings" optional = false -python-versions = "^3.6" -version = "0.1.0" +python-versions = ">=3.6,<4.0" +version = "0.2.0" [package.dependencies] -Flask = "^1.1.1" -apispec = "^3.2.0" -flask-cors = "^3.0.8" -marshmallow = "^3.3.0" -webargs = "^5.5.2" +Flask = ">=1.1.1,<2.0.0" +apispec = ">=3.2.0,<4.0.0" +flask-cors = ">=3.0.8,<4.0.0" +gevent = ">=1.4.0,<2.0.0" +gevent-websocket = ">=0.10.1,<0.11.0" +marshmallow = ">=3.4.0,<4.0.0" +webargs = ">=5.5.3,<6.0.0" -[package.source] -reference = "ea5142a9642e7b852a6e1175f3c4aa1a4bfac044" -type = "git" -url = "https://github.com/labthings/python-labthings.git" [[package]] category = "dev" description = "A fast and thorough lazy object proxy." @@ -254,12 +302,12 @@ description = "A lightweight library for converting complex datatypes to and fro name = "marshmallow" optional = false python-versions = ">=3.5" -version = "3.3.0" +version = "3.5.1" [package.extras] -dev = ["pytest", "pytz", "simplejson", "mypy (0.750)", "flake8 (3.7.9)", "flake8-bugbear (19.8.0)", "pre-commit (>=1.20,<2.0)", "tox"] -docs = ["sphinx (2.2.2)", "sphinx-issues (1.2.0)", "alabaster (0.7.12)", "sphinx-version-warning (1.1.2)"] -lint = ["mypy (0.750)", "flake8 (3.7.9)", "flake8-bugbear (19.8.0)", "pre-commit (>=1.20,<2.0)"] +dev = ["pytest", "pytz", "simplejson", "mypy (0.761)", "flake8 (3.7.9)", "flake8-bugbear (20.1.4)", "pre-commit (>=1.20,<3.0)", "tox"] +docs = ["sphinx (2.4.3)", "sphinx-issues (1.2.0)", "alabaster (0.7.12)", "sphinx-version-warning (1.1.2)"] +lint = ["mypy (0.761)", "flake8 (3.7.9)", "flake8-bugbear (20.1.4)", "pre-commit (>=1.20,<3.0)"] tests = ["pytest", "pytz", "simplejson"] [[package]] @@ -276,7 +324,7 @@ description = "NumPy is the fundamental package for array computing with Python. name = "numpy" optional = false python-versions = ">=3.5" -version = "1.18.1" +version = "1.18.2" [[package]] category = "dev" @@ -284,7 +332,7 @@ description = "Core utilities for Python packages" name = "packaging" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "20.0" +version = "20.3" [package.dependencies] pyparsing = ">=2.0.2" @@ -296,7 +344,7 @@ description = "A pure Python interface for the Raspberry Pi camera module." name = "picamera" optional = true python-versions = "*" -version = "1.13.1b0" +version = "1.13.2b0" [package.extras] array = ["numpy"] @@ -304,7 +352,7 @@ doc = ["sphinx"] test = ["coverage", "pytest", "mock", "pillow", "numpy"] [package.source] -reference = "b39c2b6e42f5f7f57bb46eafcb5c9e2bbdb5d0cb" +reference = "79177fa7f28d6d5c6eab5ba814b420b1785b6b24" type = "git" url = "https://github.com/rwb27/picamera.git" [[package]] @@ -315,13 +363,33 @@ optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" version = "5.4.1" +[[package]] +category = "main" +description = "Cross-platform lib for process and system monitoring in Python." +name = "psutil" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "5.7.0" + +[package.extras] +enum = ["enum34"] + +[[package]] +category = "main" +description = "C parser in Python" +marker = "sys_platform == \"win32\" and platform_python_implementation == \"CPython\"" +name = "pycparser" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "2.20" + [[package]] category = "dev" description = "Pygments is a syntax highlighting package written in Python." name = "pygments" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "2.5.2" +python-versions = ">=3.5" +version = "2.6.1" [[package]] category = "dev" @@ -353,6 +421,17 @@ optional = false python-versions = "*" version = "3.4" +[[package]] +category = "main" +description = "Extensions to the standard Python datetime module" +name = "python-dateutil" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +version = "2.8.1" + +[package.dependencies] +six = ">=1.5" + [[package]] category = "dev" description = "World timezone definitions, modern and historical" @@ -361,30 +440,22 @@ optional = false python-versions = "*" version = "2019.3" -[[package]] -category = "main" -description = "YAML parser and emitter for Python" -name = "pyyaml" -optional = false -python-versions = "*" -version = "3.13" - [[package]] category = "dev" description = "Python HTTP for Humans." name = "requests" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "2.22.0" +version = "2.23.0" [package.dependencies] certifi = ">=2017.4.17" -chardet = ">=3.0.2,<3.1.0" -idna = ">=2.5,<2.9" +chardet = ">=3.0.2,<4" +idna = ">=2.5,<3" urllib3 = ">=1.21.1,<1.25.0 || >1.25.0,<1.25.1 || >1.25.1,<1.26" [package.extras] -security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)"] +security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"] socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7)", "win-inet-pton"] [[package]] @@ -414,6 +485,14 @@ version = "1.3.0" [package.dependencies] numpy = ">=1.13.3" +[[package]] +category = "main" +description = "Simple, fast, extensible JSON encoder/decoder for Python" +name = "simplejson" +optional = false +python-versions = ">=2.5, !=3.0.*, !=3.1.*, !=3.2.*" +version = "3.17.0" + [[package]] category = "main" description = "Python 2 and 3 compatibility utilities" @@ -436,7 +515,7 @@ description = "Python documentation generator" name = "sphinx" optional = false python-versions = ">=3.5" -version = "2.3.1" +version = "2.4.4" [package.dependencies] Jinja2 = ">=2.3" @@ -459,40 +538,43 @@ sphinxcontrib-serializinghtml = "*" [package.extras] docs = ["sphinxcontrib-websupport"] -test = ["pytest", "pytest-cov", "html5lib", "flake8 (>=3.5.0)", "flake8-import-order", "mypy (>=0.761)", "docutils-stubs"] +test = ["pytest (<5.3.3)", "pytest-cov", "html5lib", "flake8 (>=3.5.0)", "flake8-import-order", "mypy (>=0.761)", "docutils-stubs"] [[package]] category = "dev" -description = "" +description = "sphinxcontrib-applehelp is a sphinx extension which outputs Apple help books" name = "sphinxcontrib-applehelp" optional = false -python-versions = "*" -version = "1.0.1" - -[package.extras] -test = ["pytest", "flake8", "mypy"] - -[[package]] -category = "dev" -description = "" -name = "sphinxcontrib-devhelp" -optional = false -python-versions = "*" -version = "1.0.1" - -[package.extras] -test = ["pytest", "flake8", "mypy"] - -[[package]] -category = "dev" -description = "" -name = "sphinxcontrib-htmlhelp" -optional = false -python-versions = "*" +python-versions = ">=3.5" version = "1.0.2" [package.extras] -test = ["pytest", "flake8", "mypy", "html5lib"] +lint = ["flake8", "mypy", "docutils-stubs"] +test = ["pytest"] + +[[package]] +category = "dev" +description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp document." +name = "sphinxcontrib-devhelp" +optional = false +python-versions = ">=3.5" +version = "1.0.2" + +[package.extras] +lint = ["flake8", "mypy", "docutils-stubs"] +test = ["pytest"] + +[[package]] +category = "dev" +description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" +name = "sphinxcontrib-htmlhelp" +optional = false +python-versions = ">=3.5" +version = "1.0.3" + +[package.extras] +lint = ["flake8", "mypy", "docutils-stubs"] +test = ["pytest", "html5lib"] [[package]] category = "dev" @@ -519,25 +601,27 @@ test = ["pytest", "flake8", "mypy"] [[package]] category = "dev" -description = "" +description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp document." name = "sphinxcontrib-qthelp" optional = false -python-versions = "*" -version = "1.0.2" +python-versions = ">=3.5" +version = "1.0.3" [package.extras] -test = ["pytest", "flake8", "mypy"] +lint = ["flake8", "mypy", "docutils-stubs"] +test = ["pytest"] [[package]] category = "dev" -description = "" +description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)." name = "sphinxcontrib-serializinghtml" optional = false -python-versions = "*" -version = "1.1.3" +python-versions = ">=3.5" +version = "1.1.4" [package.extras] -test = ["pytest", "flake8", "mypy"] +lint = ["flake8", "mypy", "docutils-stubs"] +test = ["pytest"] [[package]] category = "dev" @@ -561,8 +645,8 @@ category = "dev" description = "HTTP library with thread-safe connection pooling, file post, and more." name = "urllib3" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, <4" -version = "1.25.7" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" +version = "1.25.8" [package.extras] brotli = ["brotlipy (>=0.6.0)"] @@ -575,10 +659,11 @@ description = "Declarative parsing and validation of HTTP request objects, with name = "webargs" optional = false python-versions = "*" -version = "5.5.2" +version = "5.5.3" [package.dependencies] marshmallow = ">=2.15.2" +simplejson = ">=2.1.0" [package.extras] dev = ["pytest", "mock", "webtest (2.0.33)", "Flask (>=0.12.2)", "Django (>=1.11.16)", "bottle (>=0.12.13)", "tornado (>=4.5.2)", "pyramid (>=1.9.1)", "webapp2 (>=3.0.0b1)", "falcon (>=1.4.0,<2.0)", "flake8 (3.7.8)", "pre-commit (>=1.17,<2.0)", "tox", "webtest-aiohttp (2.0.0)", "pytest-aiohttp (>=0.3.0)", "aiohttp (>=3.0.0)", "mypy (0.730)", "flake8-bugbear (19.8.0)"] @@ -592,12 +677,11 @@ category = "main" description = "The comprehensive WSGI web application library." name = "werkzeug" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "0.16.0" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +version = "1.0.0" [package.extras] dev = ["pytest", "coverage", "tox", "sphinx", "pallets-sphinx-themes", "sphinx-issues"] -termcolor = ["termcolor"] watchdog = ["watchdog"] [[package]] @@ -612,7 +696,7 @@ version = "1.11.2" rpi = ["picamera", "RPi.GPIO"] [metadata] -content-hash = "53c82daa835ef29f7aac8ac7d36048487a9d989662a58a2d05d9a70465fbc8c6" +content-hash = "3a3a3bd4ffd7e0fe4c4c418a1b191c33afd375e6f06bb298189fd84ff75ee4cc" python-versions = "^3.6" [metadata.files] @@ -621,8 +705,8 @@ alabaster = [ {file = "alabaster-0.7.12.tar.gz", hash = "sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02"}, ] apispec = [ - {file = "apispec-3.2.0-py2.py3-none-any.whl", hash = "sha256:f2ecb3a6534cfb42cf69b5c1222d2c0e695aab5ec3d7edde31b7354670948f05"}, - {file = "apispec-3.2.0.tar.gz", hash = "sha256:c1625ae910f699c9adb21928693a3245b9faab6843f1b1c94ab2d505549cd78a"}, + {file = "apispec-3.3.0-py2.py3-none-any.whl", hash = "sha256:9bf4e51d56c9067c60668b78210ae213894f060f85593dc2ad8805eb7d875a2a"}, + {file = "apispec-3.3.0.tar.gz", hash = "sha256:419d0564b899e182c2af50483ea074db8cb05fee60838be58bb4542095d5c08d"}, ] appdirs = [ {file = "appdirs-1.4.3-py2.py3-none-any.whl", hash = "sha256:d8b24664561d0d34ddfaec54636d502d7cea6e29c3eaf68f3df6180863e2166e"}, @@ -648,13 +732,43 @@ certifi = [ {file = "certifi-2019.11.28-py2.py3-none-any.whl", hash = "sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3"}, {file = "certifi-2019.11.28.tar.gz", hash = "sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f"}, ] +cffi = [ + {file = "cffi-1.14.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:1cae98a7054b5c9391eb3249b86e0e99ab1e02bb0cc0575da191aedadbdf4384"}, + {file = "cffi-1.14.0-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:cf16e3cf6c0a5fdd9bc10c21687e19d29ad1fe863372b5543deaec1039581a30"}, + {file = "cffi-1.14.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:f2b0fa0c01d8a0c7483afd9f31d7ecf2d71760ca24499c8697aeb5ca37dc090c"}, + {file = "cffi-1.14.0-cp27-cp27m-win32.whl", hash = "sha256:99f748a7e71ff382613b4e1acc0ac83bf7ad167fb3802e35e90d9763daba4d78"}, + {file = "cffi-1.14.0-cp27-cp27m-win_amd64.whl", hash = "sha256:c420917b188a5582a56d8b93bdd8e0f6eca08c84ff623a4c16e809152cd35793"}, + {file = "cffi-1.14.0-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:399aed636c7d3749bbed55bc907c3288cb43c65c4389964ad5ff849b6370603e"}, + {file = "cffi-1.14.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:cab50b8c2250b46fe738c77dbd25ce017d5e6fb35d3407606e7a4180656a5a6a"}, + {file = "cffi-1.14.0-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:001bf3242a1bb04d985d63e138230802c6c8d4db3668fb545fb5005ddf5bb5ff"}, + {file = "cffi-1.14.0-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:e56c744aa6ff427a607763346e4170629caf7e48ead6921745986db3692f987f"}, + {file = "cffi-1.14.0-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:b8c78301cefcf5fd914aad35d3c04c2b21ce8629b5e4f4e45ae6812e461910fa"}, + {file = "cffi-1.14.0-cp35-cp35m-win32.whl", hash = "sha256:8c0ffc886aea5df6a1762d0019e9cb05f825d0eec1f520c51be9d198701daee5"}, + {file = "cffi-1.14.0-cp35-cp35m-win_amd64.whl", hash = "sha256:8a6c688fefb4e1cd56feb6c511984a6c4f7ec7d2a1ff31a10254f3c817054ae4"}, + {file = "cffi-1.14.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:95cd16d3dee553f882540c1ffe331d085c9e629499ceadfbda4d4fde635f4b7d"}, + {file = "cffi-1.14.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:66e41db66b47d0d8672d8ed2708ba91b2f2524ece3dee48b5dfb36be8c2f21dc"}, + {file = "cffi-1.14.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:028a579fc9aed3af38f4892bdcc7390508adabc30c6af4a6e4f611b0c680e6ac"}, + {file = "cffi-1.14.0-cp36-cp36m-win32.whl", hash = "sha256:cef128cb4d5e0b3493f058f10ce32365972c554572ff821e175dbc6f8ff6924f"}, + {file = "cffi-1.14.0-cp36-cp36m-win_amd64.whl", hash = "sha256:337d448e5a725bba2d8293c48d9353fc68d0e9e4088d62a9571def317797522b"}, + {file = "cffi-1.14.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e577934fc5f8779c554639376beeaa5657d54349096ef24abe8c74c5d9c117c3"}, + {file = "cffi-1.14.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:62ae9af2d069ea2698bf536dcfe1e4eed9090211dbaafeeedf5cb6c41b352f66"}, + {file = "cffi-1.14.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:14491a910663bf9f13ddf2bc8f60562d6bc5315c1f09c704937ef17293fb85b0"}, + {file = "cffi-1.14.0-cp37-cp37m-win32.whl", hash = "sha256:c43866529f2f06fe0edc6246eb4faa34f03fe88b64a0a9a942561c8e22f4b71f"}, + {file = "cffi-1.14.0-cp37-cp37m-win_amd64.whl", hash = "sha256:2089ed025da3919d2e75a4d963d008330c96751127dd6f73c8dc0c65041b4c26"}, + {file = "cffi-1.14.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:3b911c2dbd4f423b4c4fcca138cadde747abdb20d196c4a48708b8a2d32b16dd"}, + {file = "cffi-1.14.0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:7e63cbcf2429a8dbfe48dcc2322d5f2220b77b2e17b7ba023d6166d84655da55"}, + {file = "cffi-1.14.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:3d311bcc4a41408cf5854f06ef2c5cab88f9fded37a3b95936c9879c1640d4c2"}, + {file = "cffi-1.14.0-cp38-cp38-win32.whl", hash = "sha256:675686925a9fb403edba0114db74e741d8181683dcf216be697d208857e04ca8"}, + {file = "cffi-1.14.0-cp38-cp38-win_amd64.whl", hash = "sha256:00789914be39dffba161cfc5be31b55775de5ba2235fe49aa28c148236c4e06b"}, + {file = "cffi-1.14.0.tar.gz", hash = "sha256:2d384f4a127a15ba701207f7639d94106693b6cd64173d6c8988e2c25f3ac2b6"}, +] chardet = [ {file = "chardet-3.0.4-py2.py3-none-any.whl", hash = "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"}, {file = "chardet-3.0.4.tar.gz", hash = "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae"}, ] click = [ - {file = "Click-7.0-py2.py3-none-any.whl", hash = "sha256:2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13"}, - {file = "Click-7.0.tar.gz", hash = "sha256:5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7"}, + {file = "click-7.1.1-py2.py3-none-any.whl", hash = "sha256:e345d143d80bf5ee7534056164e5e112ea5e22716bbb1ce727941f4c8b471b9a"}, + {file = "click-7.1.1.tar.gz", hash = "sha256:8a18b4ea89d8820c5d0c7da8a64b2c324b4dabb695804dbfea19b9be9d88c0cc"}, ] colorama = [ {file = "colorama-0.4.3-py2.py3-none-any.whl", hash = "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff"}, @@ -672,9 +786,62 @@ flask-cors = [ {file = "Flask-Cors-3.0.8.tar.gz", hash = "sha256:72170423eb4612f0847318afff8c247b38bd516b7737adfc10d1c2cdbb382d16"}, {file = "Flask_Cors-3.0.8-py2.py3-none-any.whl", hash = "sha256:f4d97201660e6bbcff2d89d082b5b6d31abee04b1b3003ee073a6fd25ad1d69a"}, ] +gevent = [ + {file = "gevent-1.4.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:1b7d3a285978b27b469c0ff5fb5a72bcd69f4306dbbf22d7997d83209a8ba917"}, + {file = "gevent-1.4.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:44089ed06a962a3a70e96353c981d628b2d4a2f2a75ea5d90f916a62d22af2e8"}, + {file = "gevent-1.4.0-cp27-cp27m-win32.whl", hash = "sha256:0e1e5b73a445fe82d40907322e1e0eec6a6745ca3cea19291c6f9f50117bb7ea"}, + {file = "gevent-1.4.0-cp27-cp27m-win_amd64.whl", hash = "sha256:74b7528f901f39c39cdbb50cdf08f1a2351725d9aebaef212a29abfbb06895ee"}, + {file = "gevent-1.4.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:0ff2b70e8e338cf13bedf146b8c29d475e2a544b5d1fe14045aee827c073842c"}, + {file = "gevent-1.4.0-cp34-cp34m-macosx_10_14_x86_64.whl", hash = "sha256:0774babec518a24d9a7231d4e689931f31b332c4517a771e532002614e270a64"}, + {file = "gevent-1.4.0-cp34-cp34m-manylinux1_x86_64.whl", hash = "sha256:d752bcf1b98174780e2317ada12013d612f05116456133a6acf3e17d43b71f05"}, + {file = "gevent-1.4.0-cp34-cp34m-win32.whl", hash = "sha256:3249011d13d0c63bea72d91cec23a9cf18c25f91d1f115121e5c9113d753fa12"}, + {file = "gevent-1.4.0-cp34-cp34m-win_amd64.whl", hash = "sha256:d1e6d1f156e999edab069d79d890859806b555ce4e4da5b6418616322f0a3df1"}, + {file = "gevent-1.4.0-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:7d0809e2991c9784eceeadef01c27ee6a33ca09ebba6154317a257353e3af922"}, + {file = "gevent-1.4.0-cp35-cp35m-win32.whl", hash = "sha256:14b4d06d19d39a440e72253f77067d27209c67e7611e352f79fe69e0f618f76e"}, + {file = "gevent-1.4.0-cp35-cp35m-win_amd64.whl", hash = "sha256:53b72385857e04e7faca13c613c07cab411480822ac658d97fd8a4ddbaf715c8"}, + {file = "gevent-1.4.0-cp36-cp36m-macosx_10_6_intel.whl", hash = "sha256:8d9ec51cc06580f8c21b41fd3f2b3465197ba5b23c00eb7d422b7ae0380510b0"}, + {file = "gevent-1.4.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:2711e69788ddb34c059a30186e05c55a6b611cb9e34ac343e69cf3264d42fe1c"}, + {file = "gevent-1.4.0-cp36-cp36m-win32.whl", hash = "sha256:e5bcc4270671936349249d26140c267397b7b4b1381f5ec8b13c53c5b53ab6e1"}, + {file = "gevent-1.4.0-cp36-cp36m-win_amd64.whl", hash = "sha256:9f7a1e96fec45f70ad364e46de32ccacab4d80de238bd3c2edd036867ccd48ad"}, + {file = "gevent-1.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:50024a1ee2cf04645535c5ebaeaa0a60c5ef32e262da981f4be0546b26791950"}, + {file = "gevent-1.4.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:4bfa291e3c931ff3c99a349d8857605dca029de61d74c6bb82bd46373959c942"}, + {file = "gevent-1.4.0-cp37-cp37m-win32.whl", hash = "sha256:ab4dc33ef0e26dc627559786a4fba0c2227f125db85d970abbf85b77506b3f51"}, + {file = "gevent-1.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:896b2b80931d6b13b5d9feba3d4eebc67d5e6ec54f0cf3339d08487d55d93b0e"}, + {file = "gevent-1.4.0-pp260-pypy_41-macosx_10_14_x86_64.whl", hash = "sha256:107f4232db2172f7e8429ed7779c10f2ed16616d75ffbe77e0e0c3fcdeb51a51"}, + {file = "gevent-1.4.0-pp260-pypy_41-win32.whl", hash = "sha256:28a0c5417b464562ab9842dd1fb0cc1524e60494641d973206ec24d6ec5f6909"}, + {file = "gevent-1.4.0.tar.gz", hash = "sha256:1eb7fa3b9bd9174dfe9c3b59b7a09b768ecd496debfc4976a9530a3e15c990d1"}, +] +gevent-websocket = [ + {file = "gevent-websocket-0.10.1.tar.gz", hash = "sha256:7eaef32968290c9121f7c35b973e2cc302ffb076d018c9068d2f5ca8b2d85fb0"}, + {file = "gevent_websocket-0.10.1-py3-none-any.whl", hash = "sha256:17b67d91282f8f4c973eba0551183fc84f56f1c90c8f6b6b30256f31f66f5242"}, +] +greenlet = [ + {file = "greenlet-0.4.15-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:99a26afdb82ea83a265137a398f570402aa1f2b5dfb4ac3300c026931817b163"}, + {file = "greenlet-0.4.15-cp27-cp27m-win32.whl", hash = "sha256:beeabe25c3b704f7d56b573f7d2ff88fc99f0138e43480cecdfcaa3b87fe4f87"}, + {file = "greenlet-0.4.15-cp27-cp27m-win_amd64.whl", hash = "sha256:9854f612e1b59ec66804931df5add3b2d5ef0067748ea29dc60f0efdcda9a638"}, + {file = "greenlet-0.4.15-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:ac57fcdcfb0b73bb3203b58a14501abb7e5ff9ea5e2edfa06bb03035f0cff248"}, + {file = "greenlet-0.4.15-cp33-cp33m-win32.whl", hash = "sha256:d634a7ea1fc3380ff96f9e44d8d22f38418c1c381d5fac680b272d7d90883720"}, + {file = "greenlet-0.4.15-cp33-cp33m-win_amd64.whl", hash = "sha256:0d48200bc50cbf498716712129eef819b1729339e34c3ae71656964dac907c28"}, + {file = "greenlet-0.4.15-cp34-cp34m-manylinux1_x86_64.whl", hash = "sha256:bcb530089ff24f6458a81ac3fa699e8c00194208a724b644ecc68422e1111939"}, + {file = "greenlet-0.4.15-cp34-cp34m-win32.whl", hash = "sha256:8b4572c334593d449113f9dc8d19b93b7b271bdbe90ba7509eb178923327b625"}, + {file = "greenlet-0.4.15-cp34-cp34m-win_amd64.whl", hash = "sha256:a9f145660588187ff835c55a7d2ddf6abfc570c2651c276d3d4be8a2766db490"}, + {file = "greenlet-0.4.15-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:51503524dd6f152ab4ad1fbd168fc6c30b5795e8c70be4410a64940b3abb55c0"}, + {file = "greenlet-0.4.15-cp35-cp35m-win32.whl", hash = "sha256:a19bf883b3384957e4a4a13e6bd1ae3d85ae87f4beb5957e35b0be287f12f4e4"}, + {file = "greenlet-0.4.15-cp35-cp35m-win_amd64.whl", hash = "sha256:853da4f9563d982e4121fed8c92eea1a4594a2299037b3034c3c898cb8e933d6"}, + {file = "greenlet-0.4.15-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:23d12eacffa9d0f290c0fe0c4e81ba6d5f3a5b7ac3c30a5eaf0126bf4deda5c8"}, + {file = "greenlet-0.4.15-cp36-cp36m-win32.whl", hash = "sha256:000546ad01e6389e98626c1367be58efa613fa82a1be98b0c6fc24b563acc6d0"}, + {file = "greenlet-0.4.15-cp36-cp36m-win_amd64.whl", hash = "sha256:d97b0661e1aead761f0ded3b769044bb00ed5d33e1ec865e891a8b128bf7c656"}, + {file = "greenlet-0.4.15-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:8041e2de00e745c0e05a502d6e6db310db7faa7c979b3a5877123548a4c0b214"}, + {file = "greenlet-0.4.15-cp37-cp37m-win32.whl", hash = "sha256:81fcd96a275209ef117e9ec91f75c731fa18dcfd9ffaa1c0adbdaa3616a86043"}, + {file = "greenlet-0.4.15-cp37-cp37m-win_amd64.whl", hash = "sha256:37c9ba82bd82eb6a23c2e5acc03055c0e45697253b2393c9a50cef76a3985304"}, + {file = "greenlet-0.4.15-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:e538b8dae561080b542b0f5af64d47ef859f22517f7eca617bb314e0e03fd7ef"}, + {file = "greenlet-0.4.15-cp38-cp38-win32.whl", hash = "sha256:51155342eb4d6058a0ffcd98a798fe6ba21195517da97e15fca3db12ab201e6e"}, + {file = "greenlet-0.4.15-cp38-cp38-win_amd64.whl", hash = "sha256:7457d685158522df483196b16ec648b28f8e847861adb01a55d41134e7734122"}, + {file = "greenlet-0.4.15.tar.gz", hash = "sha256:9416443e219356e3c31f1f918a91badf2e37acf297e2fa13d24d1cc2380f8fbc"}, +] idna = [ - {file = "idna-2.8-py2.py3-none-any.whl", hash = "sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c"}, - {file = "idna-2.8.tar.gz", hash = "sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407"}, + {file = "idna-2.9-py2.py3-none-any.whl", hash = "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa"}, + {file = "idna-2.9.tar.gz", hash = "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb"}, ] imagesize = [ {file = "imagesize-1.2.0-py2.py3-none-any.whl", hash = "sha256:6965f19a6a2039c7d48bca7dba2473069ff854c36ae6f19d2cde309d998228a1"}, @@ -689,10 +856,13 @@ itsdangerous = [ {file = "itsdangerous-1.1.0.tar.gz", hash = "sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19"}, ] jinja2 = [ - {file = "Jinja2-2.10.3-py2.py3-none-any.whl", hash = "sha256:74320bb91f31270f9551d46522e33af46a80c3d619f4a4bf42b3164d30b5911f"}, - {file = "Jinja2-2.10.3.tar.gz", hash = "sha256:9fe95f19286cfefaa917656583d020be14e7859c6b0252588391e47db34527de"}, + {file = "Jinja2-2.11.1-py2.py3-none-any.whl", hash = "sha256:b0eaf100007721b5c16c1fc1eecb87409464edc10469ddc9a22a27a99123be49"}, + {file = "Jinja2-2.11.1.tar.gz", hash = "sha256:93187ffbc7808079673ef52771baa950426fd664d3aad1d0fa3e95644360e250"}, +] +labthings = [ + {file = "labthings-0.2.0-py3-none-any.whl", hash = "sha256:e2235c0db45c7134403bd78b2aa55ae5aa1bfee677735c9674f52bfcb605e998"}, + {file = "labthings-0.2.0.tar.gz", hash = "sha256:f77391bbb02676ac7c28de59c425c4dc8d6de63031f2a2db8bffd26bc691cb5e"}, ] -labthings = [] lazy-object-proxy = [ {file = "lazy-object-proxy-1.4.3.tar.gz", hash = "sha256:f3900e8a5de27447acbf900b4750b0ddfd7ec1ea7fbaf11dfa911141bc522af0"}, {file = "lazy_object_proxy-1.4.3-cp27-cp27m-macosx_10_13_x86_64.whl", hash = "sha256:a2238e9d1bb71a56cd710611a1614d1194dc10a175c1e08d75e1a7bcc250d442"}, @@ -747,39 +917,39 @@ markupsafe = [ {file = "MarkupSafe-1.1.1.tar.gz", hash = "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b"}, ] marshmallow = [ - {file = "marshmallow-3.3.0-py2.py3-none-any.whl", hash = "sha256:3e53dd9e9358977a3929e45cdbe4a671f9eff53a7d6a23f33ed3eab8c1890d8f"}, - {file = "marshmallow-3.3.0.tar.gz", hash = "sha256:0ba81b6da4ae69eb229b74b3c741ff13fe04fb899824377b1aff5aaa1a9fd46e"}, + {file = "marshmallow-3.5.1-py2.py3-none-any.whl", hash = "sha256:ac2e13b30165501b7d41fc0371b8df35944f5849769d136f20e2c5f6cdc6e665"}, + {file = "marshmallow-3.5.1.tar.gz", hash = "sha256:90854221bbb1498d003a0c3cc9d8390259137551917961c8b5258c64026b2f85"}, ] mccabe = [ {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, ] numpy = [ - {file = "numpy-1.18.1-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:20b26aaa5b3da029942cdcce719b363dbe58696ad182aff0e5dcb1687ec946dc"}, - {file = "numpy-1.18.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:70a840a26f4e61defa7bdf811d7498a284ced303dfbc35acb7be12a39b2aa121"}, - {file = "numpy-1.18.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:17aa7a81fe7599a10f2b7d95856dc5cf84a4eefa45bc96123cbbc3ebc568994e"}, - {file = "numpy-1.18.1-cp35-cp35m-win32.whl", hash = "sha256:f3d0a94ad151870978fb93538e95411c83899c9dc63e6fb65542f769568ecfa5"}, - {file = "numpy-1.18.1-cp35-cp35m-win_amd64.whl", hash = "sha256:1786a08236f2c92ae0e70423c45e1e62788ed33028f94ca99c4df03f5be6b3c6"}, - {file = "numpy-1.18.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:ae0975f42ab1f28364dcda3dde3cf6c1ddab3e1d4b2909da0cb0191fa9ca0480"}, - {file = "numpy-1.18.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:cf7eb6b1025d3e169989416b1adcd676624c2dbed9e3bcb7137f51bfc8cc2572"}, - {file = "numpy-1.18.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:b765ed3930b92812aa698a455847141869ef755a87e099fddd4ccf9d81fffb57"}, - {file = "numpy-1.18.1-cp36-cp36m-win32.whl", hash = "sha256:2d75908ab3ced4223ccba595b48e538afa5ecc37405923d1fea6906d7c3a50bc"}, - {file = "numpy-1.18.1-cp36-cp36m-win_amd64.whl", hash = "sha256:9acdf933c1fd263c513a2df3dceecea6f3ff4419d80bf238510976bf9bcb26cd"}, - {file = "numpy-1.18.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:56bc8ded6fcd9adea90f65377438f9fea8c05fcf7c5ba766bef258d0da1554aa"}, - {file = "numpy-1.18.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:e422c3152921cece8b6a2fb6b0b4d73b6579bd20ae075e7d15143e711f3ca2ca"}, - {file = "numpy-1.18.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:b3af02ecc999c8003e538e60c89a2b37646b39b688d4e44d7373e11c2debabec"}, - {file = "numpy-1.18.1-cp37-cp37m-win32.whl", hash = "sha256:d92350c22b150c1cae7ebb0ee8b5670cc84848f6359cf6b5d8f86617098a9b73"}, - {file = "numpy-1.18.1-cp37-cp37m-win_amd64.whl", hash = "sha256:77c3bfe65d8560487052ad55c6998a04b654c2fbc36d546aef2b2e511e760971"}, - {file = "numpy-1.18.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c98c5ffd7d41611407a1103ae11c8b634ad6a43606eca3e2a5a269e5d6e8eb07"}, - {file = "numpy-1.18.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:9537eecf179f566fd1c160a2e912ca0b8e02d773af0a7a1120ad4f7507cd0d26"}, - {file = "numpy-1.18.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:e840f552a509e3380b0f0ec977e8124d0dc34dc0e68289ca28f4d7c1d0d79474"}, - {file = "numpy-1.18.1-cp38-cp38-win32.whl", hash = "sha256:590355aeade1a2eaba17617c19edccb7db8d78760175256e3cf94590a1a964f3"}, - {file = "numpy-1.18.1-cp38-cp38-win_amd64.whl", hash = "sha256:39d2c685af15d3ce682c99ce5925cc66efc824652e10990d2462dfe9b8918c6a"}, - {file = "numpy-1.18.1.zip", hash = "sha256:b6ff59cee96b454516e47e7721098e6ceebef435e3e21ac2d6c3b8b02628eb77"}, + {file = "numpy-1.18.2-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:a1baa1dc8ecd88fb2d2a651671a84b9938461e8a8eed13e2f0a812a94084d1fa"}, + {file = "numpy-1.18.2-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:a244f7af80dacf21054386539699ce29bcc64796ed9850c99a34b41305630286"}, + {file = "numpy-1.18.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:6fcc5a3990e269f86d388f165a089259893851437b904f422d301cdce4ff25c8"}, + {file = "numpy-1.18.2-cp35-cp35m-win32.whl", hash = "sha256:b5ad0adb51b2dee7d0ee75a69e9871e2ddfb061c73ea8bc439376298141f77f5"}, + {file = "numpy-1.18.2-cp35-cp35m-win_amd64.whl", hash = "sha256:87902e5c03355335fc5992a74ba0247a70d937f326d852fc613b7f53516c0963"}, + {file = "numpy-1.18.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:9ab21d1cb156a620d3999dd92f7d1c86824c622873841d6b080ca5495fa10fef"}, + {file = "numpy-1.18.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:cdb3a70285e8220875e4d2bc394e49b4988bdb1298ffa4e0bd81b2f613be397c"}, + {file = "numpy-1.18.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:6d205249a0293e62bbb3898c4c2e1ff8a22f98375a34775a259a0523111a8f6c"}, + {file = "numpy-1.18.2-cp36-cp36m-win32.whl", hash = "sha256:a35af656a7ba1d3decdd4fae5322b87277de8ac98b7d9da657d9e212ece76a61"}, + {file = "numpy-1.18.2-cp36-cp36m-win_amd64.whl", hash = "sha256:1598a6de323508cfeed6b7cd6c4efb43324f4692e20d1f76e1feec7f59013448"}, + {file = "numpy-1.18.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:deb529c40c3f1e38d53d5ae6cd077c21f1d49e13afc7936f7f868455e16b64a0"}, + {file = "numpy-1.18.2-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:cd77d58fb2acf57c1d1ee2835567cd70e6f1835e32090538f17f8a3a99e5e34b"}, + {file = "numpy-1.18.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:b1fe1a6f3a6f355f6c29789b5927f8bd4f134a4bd9a781099a7c4f66af8850f5"}, + {file = "numpy-1.18.2-cp37-cp37m-win32.whl", hash = "sha256:2e40be731ad618cb4974d5ba60d373cdf4f1b8dcbf1dcf4d9dff5e212baf69c5"}, + {file = "numpy-1.18.2-cp37-cp37m-win_amd64.whl", hash = "sha256:4ba59db1fcc27ea31368af524dcf874d9277f21fd2e1f7f1e2e0c75ee61419ed"}, + {file = "numpy-1.18.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:59ca9c6592da581a03d42cc4e270732552243dc45e87248aa8d636d53812f6a5"}, + {file = "numpy-1.18.2-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1b0ece94018ae21163d1f651b527156e1f03943b986188dd81bc7e066eae9d1c"}, + {file = "numpy-1.18.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:82847f2765835c8e5308f136bc34018d09b49037ec23ecc42b246424c767056b"}, + {file = "numpy-1.18.2-cp38-cp38-win32.whl", hash = "sha256:5e0feb76849ca3e83dd396254e47c7dba65b3fa9ed3df67c2556293ae3e16de3"}, + {file = "numpy-1.18.2-cp38-cp38-win_amd64.whl", hash = "sha256:ba3c7a2814ec8a176bb71f91478293d633c08582119e713a0c5351c0f77698da"}, + {file = "numpy-1.18.2.zip", hash = "sha256:e7894793e6e8540dbeac77c87b489e331947813511108ae097f1715c018b8f3d"}, ] packaging = [ - {file = "packaging-20.0-py2.py3-none-any.whl", hash = "sha256:aec3fdbb8bc9e4bb65f0634b9f551ced63983a529d6a8931817d52fdd0816ddb"}, - {file = "packaging-20.0.tar.gz", hash = "sha256:fe1d8331dfa7cc0a883b49d75fc76380b2ab2734b220fbb87d774e4fd4b851f8"}, + {file = "packaging-20.3-py2.py3-none-any.whl", hash = "sha256:82f77b9bee21c1bafbf35a84905d604d5d1223801d639cf3ed140bd651c08752"}, + {file = "packaging-20.3.tar.gz", hash = "sha256:3c292b474fda1671ec57d46d739d072bfd495a4f51ad01a055121d81e952b7a3"}, ] picamera = [] pillow = [ @@ -835,9 +1005,26 @@ pillow = [ {file = "Pillow-5.4.1.win32-py3.6.exe", hash = "sha256:4132c78200372045bb348fcad8d52518c8f5cfc077b1089949381ee4a61f1c6d"}, {file = "Pillow-5.4.1.win32-py3.7.exe", hash = "sha256:75d1f20bd8072eff92c5f457c266a61619a02d03ece56544195c56d41a1a0522"}, ] +psutil = [ + {file = "psutil-5.7.0-cp27-none-win32.whl", hash = "sha256:298af2f14b635c3c7118fd9183843f4e73e681bb6f01e12284d4d70d48a60953"}, + {file = "psutil-5.7.0-cp27-none-win_amd64.whl", hash = "sha256:75e22717d4dbc7ca529ec5063000b2b294fc9a367f9c9ede1f65846c7955fd38"}, + {file = "psutil-5.7.0-cp35-cp35m-win32.whl", hash = "sha256:f344ca230dd8e8d5eee16827596f1c22ec0876127c28e800d7ae20ed44c4b310"}, + {file = "psutil-5.7.0-cp35-cp35m-win_amd64.whl", hash = "sha256:e2d0c5b07c6fe5a87fa27b7855017edb0d52ee73b71e6ee368fae268605cc3f5"}, + {file = "psutil-5.7.0-cp36-cp36m-win32.whl", hash = "sha256:a02f4ac50d4a23253b68233b07e7cdb567bd025b982d5cf0ee78296990c22d9e"}, + {file = "psutil-5.7.0-cp36-cp36m-win_amd64.whl", hash = "sha256:1413f4158eb50e110777c4f15d7c759521703bd6beb58926f1d562da40180058"}, + {file = "psutil-5.7.0-cp37-cp37m-win32.whl", hash = "sha256:d008ddc00c6906ec80040d26dc2d3e3962109e40ad07fd8a12d0284ce5e0e4f8"}, + {file = "psutil-5.7.0-cp37-cp37m-win_amd64.whl", hash = "sha256:73f35ab66c6c7a9ce82ba44b1e9b1050be2a80cd4dcc3352cc108656b115c74f"}, + {file = "psutil-5.7.0-cp38-cp38-win32.whl", hash = "sha256:60b86f327c198561f101a92be1995f9ae0399736b6eced8f24af41ec64fb88d4"}, + {file = "psutil-5.7.0-cp38-cp38-win_amd64.whl", hash = "sha256:d84029b190c8a66a946e28b4d3934d2ca1528ec94764b180f7d6ea57b0e75e26"}, + {file = "psutil-5.7.0.tar.gz", hash = "sha256:685ec16ca14d079455892f25bd124df26ff9137664af445563c1bd36629b5e0e"}, +] +pycparser = [ + {file = "pycparser-2.20-py2.py3-none-any.whl", hash = "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705"}, + {file = "pycparser-2.20.tar.gz", hash = "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0"}, +] pygments = [ - {file = "Pygments-2.5.2-py2.py3-none-any.whl", hash = "sha256:2a3fe295e54a20164a9df49c75fa58526d3be48e14aceba6d6b1e8ac0bfd6f1b"}, - {file = "Pygments-2.5.2.tar.gz", hash = "sha256:98c8aa5a9f778fcd1026a17361ddaf7330d1b7c62ae97c3bb0ae73e0b9b6b0fe"}, + {file = "Pygments-2.6.1-py3-none-any.whl", hash = "sha256:ff7a40b4860b727ab48fad6360eb351cc1b33cbf9b15a0f689ca5353e9463324"}, + {file = "Pygments-2.6.1.tar.gz", hash = "sha256:647344a061c249a3b74e230c739f434d7ea4d8b1d5f3721bc0f3558049b38f44"}, ] pylint = [ {file = "pylint-2.4.4-py3-none-any.whl", hash = "sha256:886e6afc935ea2590b462664b161ca9a5e40168ea99e5300935f6591ad467df4"}, @@ -851,26 +1038,17 @@ pyserial = [ {file = "pyserial-3.4-py2.py3-none-any.whl", hash = "sha256:e0770fadba80c31013896c7e6ef703f72e7834965954a78e71a3049488d4d7d8"}, {file = "pyserial-3.4.tar.gz", hash = "sha256:6e2d401fdee0eab996cf734e67773a0143b932772ca8b42451440cfed942c627"}, ] +python-dateutil = [ + {file = "python-dateutil-2.8.1.tar.gz", hash = "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c"}, + {file = "python_dateutil-2.8.1-py2.py3-none-any.whl", hash = "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"}, +] pytz = [ {file = "pytz-2019.3-py2.py3-none-any.whl", hash = "sha256:1c557d7d0e871de1f5ccd5833f60fb2550652da6be2693c1e02300743d21500d"}, {file = "pytz-2019.3.tar.gz", hash = "sha256:b02c06db6cf09c12dd25137e563b31700d3b80fcc4ad23abb7a315f2789819be"}, ] -pyyaml = [ - {file = "PyYAML-3.13-cp27-cp27m-win32.whl", hash = "sha256:d5eef459e30b09f5a098b9cea68bebfeb268697f78d647bd255a085371ac7f3f"}, - {file = "PyYAML-3.13-cp27-cp27m-win_amd64.whl", hash = "sha256:e01d3203230e1786cd91ccfdc8f8454c8069c91bee3962ad93b87a4b2860f537"}, - {file = "PyYAML-3.13-cp34-cp34m-win32.whl", hash = "sha256:558dd60b890ba8fd982e05941927a3911dc409a63dcb8b634feaa0cda69330d3"}, - {file = "PyYAML-3.13-cp34-cp34m-win_amd64.whl", hash = "sha256:d46d7982b62e0729ad0175a9bc7e10a566fc07b224d2c79fafb5e032727eaa04"}, - {file = "PyYAML-3.13-cp35-cp35m-win32.whl", hash = "sha256:a7c28b45d9f99102fa092bb213aa12e0aaf9a6a1f5e395d36166639c1f96c3a1"}, - {file = "PyYAML-3.13-cp35-cp35m-win_amd64.whl", hash = "sha256:bc558586e6045763782014934bfaf39d48b8ae85a2713117d16c39864085c613"}, - {file = "PyYAML-3.13-cp36-cp36m-win32.whl", hash = "sha256:40c71b8e076d0550b2e6380bada1f1cd1017b882f7e16f09a65be98e017f211a"}, - {file = "PyYAML-3.13-cp36-cp36m-win_amd64.whl", hash = "sha256:3d7da3009c0f3e783b2c873687652d83b1bbfd5c88e9813fb7e5b03c0dd3108b"}, - {file = "PyYAML-3.13-cp37-cp37m-win32.whl", hash = "sha256:e170a9e6fcfd19021dd29845af83bb79236068bf5fd4df3327c1be18182b2531"}, - {file = "PyYAML-3.13-cp37-cp37m-win_amd64.whl", hash = "sha256:aa7dd4a6a427aed7df6fb7f08a580d68d9b118d90310374716ae90b710280af1"}, - {file = "PyYAML-3.13.tar.gz", hash = "sha256:3ef3092145e9b70e3ddd2c7ad59bdd0252a94dfe3949721633e41344de00a6bf"}, -] requests = [ - {file = "requests-2.22.0-py2.py3-none-any.whl", hash = "sha256:9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31"}, - {file = "requests-2.22.0.tar.gz", hash = "sha256:11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4"}, + {file = "requests-2.23.0-py2.py3-none-any.whl", hash = "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee"}, + {file = "requests-2.23.0.tar.gz", hash = "sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6"}, ] rope = [ {file = "rope-0.14.0-py2-none-any.whl", hash = "sha256:6b728fdc3e98a83446c27a91fc5d56808a004f8beab7a31ab1d7224cecc7d969"}, @@ -898,6 +1076,36 @@ scipy = [ {file = "scipy-1.3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:10325f0ffac2400b1ec09537b7e403419dcd25d9fee602a44e8a32119af9079e"}, {file = "scipy-1.3.0.tar.gz", hash = "sha256:c3bb4bd2aca82fb498247deeac12265921fe231502a6bc6edea3ee7fe6c40a7a"}, ] +simplejson = [ + {file = "simplejson-3.17.0-cp27-cp27m-macosx_10_13_x86_64.whl", hash = "sha256:87d349517b572964350cc1adc5a31b493bbcee284505e81637d0174b2758ba17"}, + {file = "simplejson-3.17.0-cp27-cp27m-win32.whl", hash = "sha256:1d1e929cdd15151f3c0b2efe953b3281b2fd5ad5f234f77aca725f28486466f6"}, + {file = "simplejson-3.17.0-cp27-cp27m-win_amd64.whl", hash = "sha256:1ea59f570b9d4916ae5540a9181f9c978e16863383738b69a70363bc5e63c4cb"}, + {file = "simplejson-3.17.0-cp33-cp33m-win32.whl", hash = "sha256:8027bd5f1e633eb61b8239994e6fc3aba0346e76294beac22a892eb8faa92ba1"}, + {file = "simplejson-3.17.0-cp33-cp33m-win_amd64.whl", hash = "sha256:22a7acb81968a7c64eba7526af2cf566e7e2ded1cb5c83f0906b17ff1540f866"}, + {file = "simplejson-3.17.0-cp34-cp34m-win32.whl", hash = "sha256:17163e643dbf125bb552de17c826b0161c68c970335d270e174363d19e7ea882"}, + {file = "simplejson-3.17.0-cp34-cp34m-win_amd64.whl", hash = "sha256:0fe3994207485efb63d8f10a833ff31236ed27e3b23dadd0bf51c9900313f8f2"}, + {file = "simplejson-3.17.0-cp35-cp35m-win32.whl", hash = "sha256:4cf91aab51b02b3327c9d51897960c554f00891f9b31abd8a2f50fd4a0071ce8"}, + {file = "simplejson-3.17.0-cp35-cp35m-win_amd64.whl", hash = "sha256:fc9051d249dd5512e541f20330a74592f7a65b2d62e18122ca89bf71f94db748"}, + {file = "simplejson-3.17.0-cp36-cp36m-macosx_10_13_x86_64.whl", hash = "sha256:86afc5b5cbd42d706efd33f280fec7bd7e2772ef54e3f34cf6b30777cd19a614"}, + {file = "simplejson-3.17.0-cp36-cp36m-win32.whl", hash = "sha256:926bcbef9eb60e798eabda9cd0bbcb0fca70d2779aa0aa56845749d973eb7ad5"}, + {file = "simplejson-3.17.0-cp36-cp36m-win_amd64.whl", hash = "sha256:daaf4d11db982791be74b23ff4729af2c7da79316de0bebf880fa2d60bcc8c5a"}, + {file = "simplejson-3.17.0-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:9a126c3a91df5b1403e965ba63b304a50b53d8efc908a8c71545ed72535374a3"}, + {file = "simplejson-3.17.0-cp37-cp37m-win32.whl", hash = "sha256:fc046afda0ed8f5295212068266c92991ab1f4a50c6a7144b69364bdee4a0159"}, + {file = "simplejson-3.17.0-cp37-cp37m-win_amd64.whl", hash = "sha256:7cce4bac7e0d66f3a080b80212c2238e063211fe327f98d764c6acbc214497fc"}, + {file = "simplejson-3.17.0.tar.gz", hash = "sha256:2b4b2b738b3b99819a17feaf118265d0753d5536049ea570b3c43b51c4701e81"}, + {file = "simplejson-3.17.0.win-amd64-py2.7.exe", hash = "sha256:1d346c2c1d7dd79c118f0cc7ec5a1c4127e0c8ffc83e7b13fc5709ff78c9bb84"}, + {file = "simplejson-3.17.0.win-amd64-py3.3.exe", hash = "sha256:5cfd495527f8b85ce21db806567de52d98f5078a8e9427b18e251c68bd573a26"}, + {file = "simplejson-3.17.0.win-amd64-py3.4.exe", hash = "sha256:8de378d589eccbc75941e480b4d5b4db66f22e4232f87543b136b1f093fff342"}, + {file = "simplejson-3.17.0.win-amd64-py3.5.exe", hash = "sha256:f4b64a1031acf33e281fd9052336d6dad4d35eee3404c95431c8c6bc7a9c0588"}, + {file = "simplejson-3.17.0.win-amd64-py3.6.exe", hash = "sha256:ad8dd3454d0c65c0f92945ac86f7b9efb67fa2040ba1b0189540e984df904378"}, + {file = "simplejson-3.17.0.win-amd64-py3.7.exe", hash = "sha256:229edb079d5dd81bf12da952d4d825bd68d1241381b37d3acf961b384c9934de"}, + {file = "simplejson-3.17.0.win32-py2.7.exe", hash = "sha256:4fd5f79590694ebff8dc980708e1c182d41ce1fda599a12189f0ca96bf41ad70"}, + {file = "simplejson-3.17.0.win32-py3.3.exe", hash = "sha256:d140e9376e7f73c1f9e0a8e3836caf5eec57bbafd99259d56979da05a6356388"}, + {file = "simplejson-3.17.0.win32-py3.4.exe", hash = "sha256:da00675e5e483ead345429d4f1374ab8b949fba4429d60e71ee9d030ced64037"}, + {file = "simplejson-3.17.0.win32-py3.5.exe", hash = "sha256:7739940d68b200877a15a5ff5149e1599737d6dd55e302625650629350466418"}, + {file = "simplejson-3.17.0.win32-py3.6.exe", hash = "sha256:60aad424e47c5803276e332b2a861ed7a0d46560e8af53790c4c4fb3420c26c2"}, + {file = "simplejson-3.17.0.win32-py3.7.exe", hash = "sha256:1fbba86098bbfc1f85c5b69dc9a6d009055104354e0d9880bb00b692e30e0078"}, +] six = [ {file = "six-1.14.0-py2.py3-none-any.whl", hash = "sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c"}, {file = "six-1.14.0.tar.gz", hash = "sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a"}, @@ -907,20 +1115,20 @@ snowballstemmer = [ {file = "snowballstemmer-2.0.0.tar.gz", hash = "sha256:df3bac3df4c2c01363f3dd2cfa78cce2840a79b9f1c2d2de9ce8d31683992f52"}, ] sphinx = [ - {file = "Sphinx-2.3.1-py3-none-any.whl", hash = "sha256:298537cb3234578b2d954ff18c5608468229e116a9757af3b831c2b2b4819159"}, - {file = "Sphinx-2.3.1.tar.gz", hash = "sha256:e6e766b74f85f37a5f3e0773a1e1be8db3fcb799deb58ca6d18b70b0b44542a5"}, + {file = "Sphinx-2.4.4-py3-none-any.whl", hash = "sha256:fc312670b56cb54920d6cc2ced455a22a547910de10b3142276495ced49231cb"}, + {file = "Sphinx-2.4.4.tar.gz", hash = "sha256:b4c750d546ab6d7e05bdff6ac24db8ae3e8b8253a3569b754e445110a0a12b66"}, ] sphinxcontrib-applehelp = [ - {file = "sphinxcontrib-applehelp-1.0.1.tar.gz", hash = "sha256:edaa0ab2b2bc74403149cb0209d6775c96de797dfd5b5e2a71981309efab3897"}, - {file = "sphinxcontrib_applehelp-1.0.1-py2.py3-none-any.whl", hash = "sha256:fb8dee85af95e5c30c91f10e7eb3c8967308518e0f7488a2828ef7bc191d0d5d"}, + {file = "sphinxcontrib-applehelp-1.0.2.tar.gz", hash = "sha256:a072735ec80e7675e3f432fcae8610ecf509c5f1869d17e2eecff44389cdbc58"}, + {file = "sphinxcontrib_applehelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:806111e5e962be97c29ec4c1e7fe277bfd19e9652fb1a4392105b43e01af885a"}, ] sphinxcontrib-devhelp = [ - {file = "sphinxcontrib-devhelp-1.0.1.tar.gz", hash = "sha256:6c64b077937330a9128a4da74586e8c2130262f014689b4b89e2d08ee7294a34"}, - {file = "sphinxcontrib_devhelp-1.0.1-py2.py3-none-any.whl", hash = "sha256:9512ecb00a2b0821a146736b39f7aeb90759834b07e81e8cc23a9c70bacb9981"}, + {file = "sphinxcontrib-devhelp-1.0.2.tar.gz", hash = "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4"}, + {file = "sphinxcontrib_devhelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e"}, ] sphinxcontrib-htmlhelp = [ - {file = "sphinxcontrib-htmlhelp-1.0.2.tar.gz", hash = "sha256:4670f99f8951bd78cd4ad2ab962f798f5618b17675c35c5ac3b2132a14ea8422"}, - {file = "sphinxcontrib_htmlhelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:d4fd39a65a625c9df86d7fa8a2d9f3cd8299a3a4b15db63b50aac9e161d8eff7"}, + {file = "sphinxcontrib-htmlhelp-1.0.3.tar.gz", hash = "sha256:e8f5bb7e31b2dbb25b9cc435c8ab7a79787ebf7f906155729338f3156d93659b"}, + {file = "sphinxcontrib_htmlhelp-1.0.3-py2.py3-none-any.whl", hash = "sha256:3c0bc24a2c41e340ac37c85ced6dafc879ab485c095b1d65d2461ac2f7cca86f"}, ] sphinxcontrib-httpdomain = [ {file = "sphinxcontrib-httpdomain-1.7.0.tar.gz", hash = "sha256:ac40b4fba58c76b073b03931c7b8ead611066a6aebccafb34dc19694f4eb6335"}, @@ -931,12 +1139,12 @@ sphinxcontrib-jsmath = [ {file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"}, ] sphinxcontrib-qthelp = [ - {file = "sphinxcontrib-qthelp-1.0.2.tar.gz", hash = "sha256:79465ce11ae5694ff165becda529a600c754f4bc459778778c7017374d4d406f"}, - {file = "sphinxcontrib_qthelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:513049b93031beb1f57d4daea74068a4feb77aa5630f856fcff2e50de14e9a20"}, + {file = "sphinxcontrib-qthelp-1.0.3.tar.gz", hash = "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72"}, + {file = "sphinxcontrib_qthelp-1.0.3-py2.py3-none-any.whl", hash = "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6"}, ] sphinxcontrib-serializinghtml = [ - {file = "sphinxcontrib-serializinghtml-1.1.3.tar.gz", hash = "sha256:c0efb33f8052c04fd7a26c0a07f1678e8512e0faec19f4aa8f2473a8b81d5227"}, - {file = "sphinxcontrib_serializinghtml-1.1.3-py2.py3-none-any.whl", hash = "sha256:db6615af393650bf1151a6cd39120c29abaf93cc60db8c48eb2dddbfdc3a9768"}, + {file = "sphinxcontrib-serializinghtml-1.1.4.tar.gz", hash = "sha256:eaa0eccc86e982a9b939b2b82d12cc5d013385ba5eadcc7e4fed23f4405f77bc"}, + {file = "sphinxcontrib_serializinghtml-1.1.4-py2.py3-none-any.whl", hash = "sha256:f242a81d423f59617a8e5cf16f5d4d74e28ee9a66f9e5b637a18082991db5a9a"}, ] toml = [ {file = "toml-0.10.0-py2.7.egg", hash = "sha256:f1db651f9657708513243e61e6cc67d101a39bad662eaa9b5546f789338e07a3"}, @@ -967,17 +1175,17 @@ typed-ast = [ {file = "typed_ast-1.4.1.tar.gz", hash = "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b"}, ] urllib3 = [ - {file = "urllib3-1.25.7-py2.py3-none-any.whl", hash = "sha256:a8a318824cc77d1fd4b2bec2ded92646630d7fe8619497b142c84a9e6f5a7293"}, - {file = "urllib3-1.25.7.tar.gz", hash = "sha256:f3c5fd51747d450d4dcf6f923c81f78f811aab8205fda64b0aba34a4e48b0745"}, + {file = "urllib3-1.25.8-py2.py3-none-any.whl", hash = "sha256:2f3db8b19923a873b3e5256dc9c2dedfa883e33d87c690d9c7913e1f40673cdc"}, + {file = "urllib3-1.25.8.tar.gz", hash = "sha256:87716c2d2a7121198ebcb7ce7cccf6ce5e9ba539041cfbaeecfb641dc0bf6acc"}, ] webargs = [ - {file = "webargs-5.5.2-py2-none-any.whl", hash = "sha256:3f9dc15de183d356c9a0acc159c100ea0506c0c240c1e6f1d8b308c5fed4dbbd"}, - {file = "webargs-5.5.2-py3-none-any.whl", hash = "sha256:fa4ad3ad9b38bedd26c619264fdc50d7ae014b49186736bca851e5b5228f2a1b"}, - {file = "webargs-5.5.2.tar.gz", hash = "sha256:3beca296598067cec24a0b6f91c0afcc19b6e3c4d84ab026b931669628bb47b4"}, + {file = "webargs-5.5.3-py2-none-any.whl", hash = "sha256:fc81c9f9d391acfbce406a319217319fd8b2fd862f7fdb5319ad06944f36ed25"}, + {file = "webargs-5.5.3-py3-none-any.whl", hash = "sha256:4f04918864c7602886335d8099f9b8960ee698b6b914f022736ed50be6b71235"}, + {file = "webargs-5.5.3.tar.gz", hash = "sha256:871642a2e0c62f21d5b78f357750ac7a87e6bc734c972f633aa5fb6204fbf29a"}, ] werkzeug = [ - {file = "Werkzeug-0.16.0-py2.py3-none-any.whl", hash = "sha256:e5f4a1f98b52b18a93da705a7458e55afb26f32bff83ff5d19189f92462d65c4"}, - {file = "Werkzeug-0.16.0.tar.gz", hash = "sha256:7280924747b5733b246fe23972186c6b348f9ae29724135a6dfc1e53cea433e7"}, + {file = "Werkzeug-1.0.0-py2.py3-none-any.whl", hash = "sha256:6dc65cf9091cf750012f56f2cad759fa9e879f511b5ff8685e456b4e3bf90d16"}, + {file = "Werkzeug-1.0.0.tar.gz", hash = "sha256:169ba8a33788476292d04186ab33b01d6add475033dfc07215e6d219cc077096"}, ] wrapt = [ {file = "wrapt-1.11.2.tar.gz", hash = "sha256:565a021fd19419476b9362b05eeaa094178de64f8361e44468f9e9d7843901e1"}, diff --git a/pyproject.toml b/pyproject.toml index 65e4def9..c5080ad0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.masonry.api" [tool.poetry] name = "openflexure_microscope" -version = "2.0.0-beta.2" +version = "2.0.0" description = "Python module, and Flask-based web API, to run the OpenFlexure Microscope." authors = [ @@ -25,16 +25,17 @@ license = "GPL-3.0" [tool.poetry.dependencies] python = "^3.6" Flask = "^1.0" -pyyaml = "^3.13" numpy = "^1.17.0" Pillow = "^5.4" scipy = "1.3.0" # Exact version until we can guarantee a wheel -picamera = { git = "https://github.com/rwb27/picamera.git", branch = "lens-shading", optional = true } # Lens shading requires >1.14 or RWB fork, lens-shading branch. +picamera = { git = "https://github.com/rwb27/picamera.git", branch = "master", optional = true } # Lens shading requires >1.14 or RWB fork, lens-shading branch. "RPi.GPIO" = { version = "^0.6.5", optional = true } pyserial = "^3.4" # Used for sangaboard (basic_serial_instrument) until we move to sangaboard pip library -labthings = { git = "https://github.com/labthings/python-labthings.git", branch = "master"} # TODO: Pin to a fixed version before 2.0.0 stable +python-dateutil = "^2.8" +psutil = "^5.6.7" # Autostorage extension +labthings = "0.2.0" [tool.poetry.extras] rpi = ["picamera", "RPi.GPIO"]