diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 00000000..32ad879e --- /dev/null +++ b/.coveragerc @@ -0,0 +1,28 @@ +[run] +branch = True +source = ./labthings +omit = .venv/*, labthings/server/wsgi/*, , labthings/server/monkey.py +concurrency = greenlet + +[report] +# Regexes for lines to exclude from consideration +exclude_lines = + # Have to re-enable the standard pragma + pragma: no cover + + # Don't complain about missing debug-only code: + def __repr__ + if self\.debug + + # Don't complain if tests don't hit defensive assertion code: + raise AssertionError + raise NotImplementedError + + # Don't complain if non-runnable code isn't run: + if 0: + if __name__ == .__main__.: + +ignore_errors = True + +[html] +directory = coverage_html_report \ No newline at end of file diff --git a/.flake8 b/.flake8 new file mode 100644 index 00000000..35ed40d2 --- /dev/null +++ b/.flake8 @@ -0,0 +1,3 @@ +[flake8] +max-line-length = 88 +exclude = tests/* \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..6179a3d9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,52 @@ +name: CI + +on: [push] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v1 + with: + fetch-depth: 1 + + - name: Set up Python 3.7 + uses: actions/setup-python@v1 + with: + python-version: 3.7 + + - name: Install Poetry + uses: dschep/install-poetry-action@v1.3 + + - name: Cache Poetry virtualenv + uses: actions/cache@v1 + id: cache + with: + path: ~/.virtualenvs + key: poetry-${{ hashFiles('**/poetry.lock') }} + restore-keys: | + poetry-${{ hashFiles('**/poetry.lock') }} + + - name: Set Poetry config + run: | + poetry config virtualenvs.in-project false + poetry config virtualenvs.path ~/.virtualenvs + + - name: Install Dependencies + run: poetry install + if: steps.cache.outputs.cache-hit != 'true' + + - name: Code Quality + run: poetry run black . --check + + - name: Test with pytest + run: poetry run pytest --cov-report term-missing --cov-report=xml --cov=labthings ./tests + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v1 + with: + file: ./coverage.xml + flags: unittests + name: codecov-umbrella + fail_ci_if_error: true diff --git a/.gitignore b/.gitignore index 38b3e274..262eef0c 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,7 @@ pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ +coverage_html_report/ .tox/ .nox/ .coverage @@ -50,6 +51,8 @@ coverage.xml *.py,cover .hypothesis/ .pytest_cache/ +coverage_html_report/ +prof/ # Translations *.mo diff --git a/README.md b/README.md index c51239fb..d42e749e 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,8 @@ [![LabThings](https://img.shields.io/badge/-LabThings-8E00FF?style=flat&logo=data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4NCjwhRE9DVFlQRSBzdmcgIFBVQkxJQyAnLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4nICAnaHR0cDovL3d3dy53My5vcmcvR3JhcGhpY3MvU1ZHLzEuMS9EVEQvc3ZnMTEuZHRkJz4NCjxzdmcgY2xpcC1ydWxlPSJldmVub2RkIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIHN0cm9rZS1taXRlcmxpbWl0PSIyIiB2ZXJzaW9uPSIxLjEiIHZpZXdCb3g9IjAgMCAxNjMgMTYzIiB4bWw6c3BhY2U9InByZXNlcnZlIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxwYXRoIGQ9Im0xMjIuMjQgMTYyLjk5aDQwLjc0OHYtMTYyLjk5aC0xMDEuODd2NDAuNzQ4aDYxLjEyMnYxMjIuMjR6IiBmaWxsPSIjZmZmIi8+PHBhdGggZD0ibTAgMTIuMjI0di0xMi4yMjRoNDAuNzQ4djEyMi4yNGg2MS4xMjJ2NDAuNzQ4aC0xMDEuODd2LTEyLjIyNGgyMC4zNzR2LTguMTVoLTIwLjM3NHYtOC4xNDloOC4wMTl2LTguMTVoLTguMDE5di04LjE1aDIwLjM3NHYtOC4xNDloLTIwLjM3NHYtOC4xNWg4LjAxOXYtOC4xNWgtOC4wMTl2LTguMTQ5aDIwLjM3NHYtOC4xNWgtMjAuMzc0di04LjE0OWg4LjAxOXYtOC4xNWgtOC4wMTl2LTguMTVoMjAuMzc0di04LjE0OWgtMjAuMzc0di04LjE1aDguMDE5di04LjE0OWgtOC4wMTl2LTguMTVoMjAuMzc0di04LjE1aC0yMC4zNzR6IiBmaWxsPSIjZmZmIi8+PC9zdmc+DQo=)](https://github.com/labthings/) [![PyPI](https://img.shields.io/pypi/v/labthings)](https://pypi.org/project/labthings/) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) -[![Gitter](https://badges.gitter.im/labthings/community.svg)](https://gitter.im/labthings/community?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) +[![codecov](https://codecov.io/gh/labthings/python-labthings/branch/master/graph/badge.svg)](https://codecov.io/gh/labthings/python-labthings) +[![Riot.im](https://img.shields.io/badge/chat-on%20riot.im-368BD6)](https://riot.im/app/#/room/#labthings:matrix.org) A Python implementation of the LabThings API structure, based on the Flask microframework. diff --git a/examples/builder.py b/examples/builder.py index 19f1156c..10b6b9db 100644 --- a/examples/builder.py +++ b/examples/builder.py @@ -1,19 +1,17 @@ -import uuid -import types -import functools -import atexit +# Monkey patch for easy concurrency +from labthings.server.monkey import patch_all + +patch_all() + +# Import requirements import logging from labthings.server.quick import create_app -from labthings.server.view.builder import property_of +from labthings.server.view.builder import property_of, action_from from components.pdf_component import PdfComponent -def cleanup(): - logging.info("Exiting. Running any cleanup code here...") - - # Create LabThings Flask app app, labthing = create_app( __name__, @@ -42,8 +40,17 @@ def cleanup(): ), "/dictionary", ) +labthing.add_view( + action_from( + my_component.average_data, + description="Take an averaged measurement", + task=True, # Is the action a long-running task? + safe=True, # Is the state of the Thing unchanged by calling the action? + idempotent=True, # Can the action be called repeatedly with the same result? + ), + "/average", +) -atexit.register(cleanup) # Start the app if __name__ == "__main__": diff --git a/examples/components/pdf_component.py b/examples/components/pdf_component.py index f4af40a8..07c7670f 100644 --- a/examples/components/pdf_component.py +++ b/examples/components/pdf_component.py @@ -2,6 +2,8 @@ import math import time +from typing import List + """ Class for our lab component functionality. This could include serial communication, equipment API calls, network requests, or a "virtual" device as seen here. @@ -38,7 +40,7 @@ def data(self): """Return a 1D data trace.""" return [self.noisy_pdf(x) for x in self.x_range] - def average_data(self, n: int): + def average_data(self, n: int = 10, optlist: List[int] = [1, 2, 3]): """Average n-sets of data. Emulates a measurement that may take a while.""" summed_data = self.data diff --git a/examples/simple_extensions.py b/examples/simple_extensions.py index 87bbfe13..c2af825e 100644 --- a/examples/simple_extensions.py +++ b/examples/simple_extensions.py @@ -40,7 +40,6 @@ def ext_on_my_component(component): static_folder = path_relative_to(__file__, "static") -print(static_folder) example_extension = BaseExtension( "org.labthings.examples.extension", static_folder=static_folder diff --git a/examples/simple_thing.py b/examples/simple_thing.py index edca6653..0ce46cfa 100644 --- a/examples/simple_thing.py +++ b/examples/simple_thing.py @@ -1,6 +1,16 @@ +#!/usr/bin/env python +from gevent import monkey + +# Patch most system modules. Leave threads untouched so we can still use them normally if needed. +print("Monkey patching with Gevenet") +monkey.patch_all(thread=False) +print("Monkey patching successful") + import random import math import time +import logging +import atexit from labthings.server.quick import create_app from labthings.server.decorators import ( @@ -13,7 +23,7 @@ from labthings.server.view import View from labthings.server.find import find_component from labthings.server import fields -from labthings.core.tasks import taskify +from labthings.core.tasks import taskify, update_task_data """ @@ -22,6 +32,14 @@ """ +from gevent.monkey import get_original + +get_ident = get_original("_thread", "get_ident") + +print(f"ROOT IDENT") +print(get_ident()) + + class MyComponent: def __init__(self): self.x_range = range(-100, 100) @@ -48,8 +66,10 @@ def average_data(self, n: int): """Average n-sets of data. Emulates a measurement that may take a while.""" summed_data = self.data + logging.warning("Starting an averaged measurement. This may take a while...") for i in range(n): summed_data = [summed_data[i] + el for i, el in enumerate(self.data)] + update_task_data({"data": summed_data}) time.sleep(0.25) summed_data = [i / n for i in summed_data] @@ -150,6 +170,13 @@ def post(self, args): return task +# Handle exit cleanup +def cleanup(): + logging.info("Exiting. Running any cleanup code here...") + + +atexit.register(cleanup) + # Create LabThings Flask app app, labthing = create_app( __name__, @@ -173,4 +200,4 @@ def post(self, args): from labthings.server.wsgi import Server server = Server(app) - server.run(host="0.0.0.0", port=5000, debug=False) + server.run(host="0.0.0.0", port=5000, debug=False, zeroconf=False) diff --git a/labthings/core/event.py b/labthings/core/event.py new file mode 100644 index 00000000..a6fcc23b --- /dev/null +++ b/labthings/core/event.py @@ -0,0 +1,69 @@ +from gevent.hub import getcurrent +import gevent +import time +import logging + +from gevent.monkey import get_original + +# Guarantee that Task threads will always be proper system threads, regardless of Gevent patches +Event = get_original("threading", "Event") + + +class ClientEvent(object): + """ + An event-signaller object with per-client setting and waiting. + + A client can be any Greenlet or native Thread. This can be used, for example, + to signal to clients that new data is available + """ + + def __init__(self): + self.events = {} + + def wait(self, timeout: int = 5): + """Wait for the next data frame (invoked from each client's thread).""" + ident = id(getcurrent()) + if ident not in self.events: + # this is a new client + # add an entry for it in the self.events dict + # each entry has two elements, a threading.Event() and a timestamp + self.events[ident] = [Event(), time.time()] + + # We have to reimplement event waiting here as we need native thread events to allow gevent context switching + wait_start = time.time() + while not self.events[ident][0].is_set(): + now = time.time() + if now - wait_start > timeout: + return False + gevent.sleep(0) + return True + + def set(self, timeout=5): + """Signal that a new frame is available.""" + now = time.time() + remove = None + for ident, event in self.events.items(): + if not event[0].is_set(): + # if this client's event is not set, then set it + # also update the last set timestamp to now + event[0].set() + event[1] = now + else: + # if the client's event is already set, it means the client + # did not process a previous frame + # if the event stays set for more than `timeout` seconds, then assume + # the client is gone and remove it + if now - event[1] >= timeout: + remove = ident + if remove: + del self.events[remove] + + def clear(self): + """Clear frame event, once processed.""" + ident = id(getcurrent()) + if ident not in self.events: + logging.error(f"Mismatched ident. Current: {ident}, available:") + logging.error(self.events.keys()) + return False + self.events[id(getcurrent())][0].clear() + return True diff --git a/labthings/core/lock.py b/labthings/core/lock.py index 4c5a93e5..ffa9991b 100644 --- a/labthings/core/lock.py +++ b/labthings/core/lock.py @@ -1,8 +1,14 @@ -from threading import RLock +from gevent.hub import getcurrent +from gevent.lock import RLock as _RLock from .exceptions import LockError +class RLock(_RLock): + def locked(self): + return self._block.locked() + + class StrictLock: """ Class that behaves like a Python RLock, @@ -16,29 +22,43 @@ class StrictLock: timeout (int): Time in seconds acquisition will wait before raising an exception """ - def __init__(self, timeout=1): + def __init__(self, timeout=1, name=None): self._lock = RLock() self.timeout = timeout + self.name = name def locked(self): return self._lock.locked() - def acquire(self, blocking=True): - return self._lock.acquire(blocking, timeout=self.timeout) + def acquire(self, blocking=True, timeout=None, _strict=True): + if not timeout: + timeout = self.timeout + result = self._lock.acquire(blocking, timeout=timeout) + if _strict and not result: + raise LockError("ACQUIRE_ERROR", self) + else: + return result def __enter__(self): - result = self._lock.acquire(blocking=True, timeout=self.timeout) - if result: - return result - else: - raise LockError("ACQUIRE_ERROR", self) + return self.acquire(blocking=True, timeout=self.timeout) def __exit__(self, *args): - self._lock.release() + self.release() def release(self): self._lock.release() + @property + def _owner(self): + return self._lock._owner + + @_owner.setter + def _owner(self, new_owner): + self._lock._owner = new_owner + + def _is_owned(self): + return self._lock._is_owned() + class CompositeLock: """ @@ -58,20 +78,53 @@ def __init__(self, locks, timeout=1): self.locks = locks self.timeout = timeout - def acquire(self, blocking=True): - return (lock.acquire(blocking=blocking) for lock in self.locks) + def acquire(self, blocking=True, timeout=None): + if not timeout: + timeout = self.timeout - def __enter__(self): - result = (lock.acquire(blocking=True) for lock in self.locks) - if all(result): - return result - else: + lock_all = all( + [ + lock.acquire(blocking=blocking, timeout=timeout, _strict=False) + for lock in self.locks + ] + ) + + if not lock_all: + self._emergency_release() raise LockError("ACQUIRE_ERROR", self) + return True + + def __enter__(self): + return self.acquire(blocking=True, timeout=self.timeout) + def __exit__(self, *args): - for lock in self.locks: - lock.release() + return self.release() def release(self): + # If not all child locks are owner by caller + if not all([owner is getcurrent() for owner in self._owner]): + raise RuntimeError("cannot release un-acquired lock") + for lock in self.locks: + if lock.locked(): + lock.release() + + def _emergency_release(self): for lock in self.locks: - lock.release() + if lock.locked() and lock._is_owned(): + lock.release() + + def locked(self): + return any([lock.locked() for lock in self.locks]) + + @property + def _owner(self): + return [lock._owner for lock in self.locks] + + @_owner.setter + def _owner(self, new_owner): + for lock in self.locks: + lock._owner = new_owner + + def _is_owned(self): + return all([lock._is_owned() for lock in self.locks]) diff --git a/labthings/core/tasks/pool.py b/labthings/core/tasks/pool.py index 452c5d2d..04aeaa81 100644 --- a/labthings/core/tasks/pool.py +++ b/labthings/core/tasks/pool.py @@ -1,6 +1,6 @@ -import threading import logging from functools import wraps +from gevent import getcurrent from .thread import TaskThread @@ -38,23 +38,25 @@ def states(self): def new(self, f, *args, **kwargs): # copy_current_request_context allows threads to access flask current_app if has_request_context(): - # The if block stops this failing if we're outside a Flask app. - f = copy_current_request_context(f) - task = TaskThread( - target=f, args=args, kwargs=kwargs - ) + target = copy_current_request_context(f) + else: + target = f + task = TaskThread(target=target, args=args, kwargs=kwargs) self._tasks.append(task) return task def remove(self, task_id): for task in self._tasks: - if (task.id == task_id) and not task.isAlive(): - del task + if (str(task.id) == str(task_id)) and task.dead: + self._tasks.remove(task) def cleanup(self): - for task in self._tasks: - if not task.isAlive(): - del task + for i, task in enumerate(self._tasks): + if task.dead: + # Mark for delection + self._tasks[i] = None + # Remove items marked for deletion + self._tasks = [t for t in self._tasks if t] # Task management @@ -117,7 +119,7 @@ def current_task(): Returns: TaskThread -- Currently running Task thread. """ - current_task_thread = threading.current_thread() + current_task_thread = getcurrent() if not isinstance(current_task_thread, TaskThread): return None return current_task_thread diff --git a/labthings/core/tasks/thread.py b/labthings/core/tasks/thread.py index 4bf456d2..1ab4b5e7 100644 --- a/labthings/core/tasks/thread.py +++ b/labthings/core/tasks/thread.py @@ -1,16 +1,11 @@ -import ctypes +from gevent import Greenlet, GreenletExit +from gevent.thread import get_ident +from gevent.event import Event import datetime import logging import traceback import uuid -from gevent.monkey import get_original - -# Guarantee that Task threads will always be proper system threads, regardless of Gevent patches -Thread = get_original("threading", "Thread") -Event = get_original("threading", "Event") -Lock = get_original("threading", "Lock") - _LOG = logging.getLogger(__name__) @@ -18,17 +13,13 @@ class ThreadTerminationError(SystemExit): """Sibling of SystemExit, but specific to thread termination.""" -class TaskThread(Thread): - def __init__(self, target=None, name=None, args=None, kwargs=None, daemon=True): - Thread.__init__( - self, - group=None, - target=target, - name=name, - args=args, - kwargs=kwargs, - daemon=daemon, - ) +class TaskKillException(Exception): + """Sibling of SystemExit, but specific to thread termination.""" + + +class TaskThread(Greenlet): + def __init__(self, target=None, args=None, kwargs=None): + Greenlet.__init__(self) # Handle arguments if args is None: args = () @@ -38,6 +29,9 @@ def __init__(self, target=None, name=None, args=None, kwargs=None, daemon=True): # A UUID for the TaskThread (not the same as the threading.Thread ident) self._ID = uuid.uuid4() # Task ID + # Event to track if the task has started + self.started_event = Event() + # Make _target, _args, and _kwargs available to the subclass self._target = target self._args = args @@ -57,15 +51,16 @@ def __init__(self, target=None, name=None, args=None, kwargs=None, daemon=True): self.data = {} # Dictionary of custom data added during the task self.log = [] # The log will hold dictionary objects with log information - # Stuff for handling termination - self._running_lock = Lock() # Lock obtained while self._target is running - self._killed = Event() # Event triggered when thread is manually terminated - @property def id(self): """Return ID of current TaskThread""" return self._ID + @property + def ident(self): + """Compatibility with threading interface. A small, unique non-negative integer that identifies this object.""" + return get_ident(self) + @property def state(self): return { @@ -87,6 +82,9 @@ def update_data(self, data: dict): # Store data to be used before task finishes (eg for real-time plotting) self.data.update(data) + def _run(self): # pylint: disable=E0202 + return self._thread_proc(self._target)(*self._args, **self._kwargs) + def _thread_proc(self, f): """ Wraps the target function to handle recording `status` and `return` to `state`. @@ -102,9 +100,15 @@ def wrapped(*args, **kwargs): self._status = "running" self._start_time = datetime.datetime.now().strftime("%Y-%m-%d %H-%M-%S") + self.started_event.set() try: self._return_value = f(*args, **kwargs) self._status = "success" + except (TaskKillException, GreenletExit) as e: + logging.error(e) + # Set state to terminated + self._status = "terminated" + self.progress = None except Exception as e: # skipcq: PYL-W0703 logging.error(e) logging.error(traceback.format_exc()) @@ -113,105 +117,20 @@ def wrapped(*args, **kwargs): finally: self._end_time = datetime.datetime.now().strftime("%Y-%m-%d %H-%M-%S") logging.getLogger().removeHandler(handler) # Stop logging this thread - # If we don't remove the handler, it's a memory leak. + # If we don't remove the handler, it's a memory leak. + return wrapped - def run(self): - """Overrides default threading.Thread run() method""" - logging.debug((self._args, self._kwargs)) - try: - with self._running_lock: - if self._killed.is_set(): - raise ThreadTerminationError() - if self._target: - self._thread_proc(self._target)(*self._args, **self._kwargs) - finally: - # Avoid a refcycle if the thread is running a function with - # an argument that has a member that points to the thread. - del self._target, self._args, self._kwargs - - def wait(self): - """Start waiting for the task to finish before returning""" - print("Joining thread {}".format(self)) - self.join() - return self._return_value - - def async_raise(self, exc_type): - """Raise an exception in this thread.""" - # Should only be called on a started thread, so raise otherwise. - if self.ident is None: - raise RuntimeError( - "Cannot halt a thread that hasn't started. " - "No valid running thread identifier." - ) - - # If the thread has died we don't want to raise an exception so log. - if not self.is_alive(): - _LOG.debug( - "Not raising %s because thread %s (%s) is not alive", - exc_type, - self.name, - self.ident, - ) - return - - result = ctypes.pythonapi.PyThreadState_SetAsyncExc( - ctypes.c_long(self.ident), ctypes.py_object(exc_type) - ) - if result == 0 and self.is_alive(): - # Don't raise an exception an error unnecessarily if the thread is dead. - raise ValueError("Thread ID was invalid.", self.ident) - elif result > 1: - # Something bad happened, call with a NULL exception to undo. - ctypes.pythonapi.PyThreadState_SetAsyncExc(self.ident, None) - raise RuntimeError( - "Error: PyThreadState_SetAsyncExc %s %s (%s) %s" - % (exc_type, self.name, self.ident, result) - ) - - def _is_thread_proc_running(self): - """ - Test if thread funtion (_thread_proc) is running, - by attemtping to acquire the lock _thread_proc acquires at runtime. - Returns: - bool: If _thread_proc is currently running - """ - could_acquire = self._running_lock.acquire(False) # skipcq: PYL-E1111 - if could_acquire: - self._running_lock.release() - return False - return True + def kill(self, exception=TaskKillException, block=True, timeout=None): + # Kill the greenlet + Greenlet.kill(self, exception=exception, block=block, timeout=timeout) def terminate(self): - """ - Raise ThreadTerminatedException in the context of the given thread, - which should cause the thread to exit silently. - """ - _LOG.warning(f"Terminating thread {self}") - self._killed.set() - if not self.is_alive(): - logging.debug("Cannot kill thread that is no longer running.") - return - if not self._is_thread_proc_running(): - logging.debug( - "Thread's _thread_proc function is no longer running, " - "will not kill; letting thread exit gracefully." - ) - return - self.async_raise(ThreadTerminationError) - - # Wait for the thread for finish closing. If the threaded function has cleanup code in a try-except, - # this pause allows it to finish running before the main process can continue. - while self._is_thread_proc_running(): - pass - - # Set state to terminated - self._status = "terminated" - self.progress = None + return self.kill() class ThreadLogHandler(logging.Handler): - def __init__(self, thread=None, dest=None): + def __init__(self, thread=None, dest=None, level=logging.WARNING): """Set up a log handler that appends messages to a list. This log handler will first filter by ``thread``, if one is @@ -228,20 +147,22 @@ def __init__(self, thread=None, dest=None): lot of log messages, you may run into memory problems. """ logging.Handler.__init__(self) + self.setLevel(level) self.thread = thread - self.dest = [] if dest is None else dest + self.dest = dest if dest is not None else [] self.addFilter(self.check_thread) - + def check_thread(self, record): """Determine if a thread matches the desired record""" if self.thread is None: return 1 - if record.thread == self.thread.ident: + + if get_ident() == get_ident(self.thread): return 1 if record.threadName == self.thread.name: return 1 # TODO: check if this is unsafe, or better with greenlets return 0 - + def emit(self, record): """Do something with a logged message""" record_dict = {"message": record.getMessage()} @@ -251,4 +172,4 @@ def emit(self, record): # FIXME: make sure this doesn't become a memory disaster! # We probably need to check the size of the list... # TODO: think about whether any of the keys are security flaws - # (this is why I don't dump the whole logrecord) \ No newline at end of file + # (this is why I don't dump the whole logrecord) diff --git a/labthings/core/utilities.py b/labthings/core/utilities.py index a474953e..d23e9851 100644 --- a/labthings/core/utilities.py +++ b/labthings/core/utilities.py @@ -55,10 +55,15 @@ def rupdate(destination_dict, update_dict): for k, v in update_dict.items(): # Merge lists if they're present in both objects if isinstance(v, list): + # If key is missing from destination, create the list if k not in destination_dict: destination_dict[k] = [] + # If destination value is also a list, merge if isinstance(destination_dict[k], list): destination_dict[k].extend(v) + # If destination exists but isn't a list, replace + else: + destination_dict[k] = v # Recursively merge dictionaries if the element is a dictionary elif isinstance(v, collections.abc.Mapping): if k not in destination_dict: diff --git a/labthings/server/__init__.py b/labthings/server/__init__.py index b21f21fc..e69de29b 100644 --- a/labthings/server/__init__.py +++ b/labthings/server/__init__.py @@ -1,3 +0,0 @@ -import logging - -EXTENSION_NAME = "flask-labthings" diff --git a/labthings/server/decorators.py b/labthings/server/decorators.py index 2c89d402..30734791 100644 --- a/labthings/server/decorators.py +++ b/labthings/server/decorators.py @@ -4,13 +4,19 @@ from werkzeug.wrappers import Response as ResponseBase from http import HTTPStatus from marshmallow.exceptions import ValidationError -from collections import Mapping +from collections.abc import Mapping -from .spec.utilities import update_spec +from marshmallow import Schema as _Schema + +from .spec.utilities import update_spec, tag_spec from .schema import TaskSchema, Schema, FieldSchema from .fields import Field from .view import View from .find import current_labthing +from .utilities import unpack + +from labthings.core.tasks.pool import TaskThread +from labthings.core.utilities import rupdate import logging @@ -18,29 +24,9 @@ from marshmallow import pre_dump, pre_load -def unpack(value): - """Return a three tuple of data, code, and headers""" - if not isinstance(value, tuple): - return value, 200, {} - - try: - data, code, headers = value - return data, code, headers - except ValueError: - pass - - try: - data, code = value - return data, code, {} - except ValueError: - pass - - return value, 200, {} - - class marshal_with: def __init__(self, schema, code=200): - """Decorator to format the response of a View with a Marshmallow schema + """Decorator to format the return of a function with a Marshmallow schema Args: schema: Marshmallow schema, field, or dict of Fields, describing @@ -50,11 +36,11 @@ def __init__(self, schema, code=200): self.code = code if isinstance(self.schema, Mapping): - self.converter = Schema.from_dict(self.schema)().jsonify + self.converter = Schema.from_dict(self.schema)().dump elif isinstance(self.schema, Field): - self.converter = FieldSchema(self.schema).jsonify - elif isinstance(self.schema, Schema): - self.converter = self.schema.jsonify + self.converter = FieldSchema(self.schema).dump + elif isinstance(self.schema, _Schema): + self.converter = self.schema.dump else: raise TypeError( f"Unsupported schema type {type(self.schema)} for marshal_with" @@ -67,11 +53,15 @@ def __call__(self, f): @wraps(f) def wrapper(*args, **kwargs): resp = f(*args, **kwargs) - if isinstance(resp, tuple): - data, code, headers = unpack(resp) - return make_response(self.converter(data), code, headers) + if isinstance(resp, ResponseBase): + resp.data = self.converter(resp.data) + return resp + elif isinstance(resp, tuple): + resp, code, headers = unpack(resp) + return (self.converter(resp), code, headers) else: - return make_response(self.converter(resp)) + resp, code, headers = resp, 200, {} + return (self.converter(resp), code, headers) return wrapper @@ -87,10 +77,15 @@ def marshal_task(f): def wrapper(*args, **kwargs): resp = f(*args, **kwargs) if isinstance(resp, tuple): - data, code, headers = unpack(resp) - return make_response(TaskSchema().jsonify(data), code, headers) + resp, code, headers = unpack(resp) else: - return make_response(TaskSchema().jsonify(resp)) + resp, code, headers = resp, 201, {} + + if not isinstance(resp, TaskThread): + raise TypeError( + f"Function {f.__name__} expected to return a TaskThread object, but instead returned a {type(resp).__name__}. If it does not return a task, remove the @marshall_task decorator from {f.__name__}." + ) + return (TaskSchema().dump(resp), code, headers) return wrapper @@ -105,14 +100,47 @@ def ThingAction(viewcls: View): View: View class with Action spec tags """ # Update Views API spec - update_spec(viewcls, {"tags": ["actions"]}) - update_spec(viewcls, {"_groups": ["actions"]}) + tag_spec(viewcls, "actions") return viewcls thing_action = ThingAction +def Safe(viewcls: View): + """Decorator to tag a view or function as being safe + + Args: + viewcls (View): View class to tag as Safe + + Returns: + View: View class with Safe spec tags + """ + # Update Views API spec + update_spec(viewcls, {"_safe": True}) + return viewcls + + +safe = Safe + + +def Idempotent(viewcls: View): + """Decorator to tag a view or function as being idempotent + + Args: + viewcls (View): View class to tag as idempotent + + Returns: + View: View class with idempotent spec tags + """ + # Update Views API spec + update_spec(viewcls, {"_idempotent": True}) + return viewcls + + +idempotent = Idempotent + + def ThingProperty(viewcls): """Decorator to tag a view as a Thing Property @@ -144,8 +172,7 @@ def wrapped(*args, **kwargs): viewcls.put = property_notify(viewcls.put) # Update Views API spec - update_spec(viewcls, {"tags": ["properties"]}) - update_spec(viewcls, {"_groups": ["properties"]}) + tag_spec(viewcls, "properties") return viewcls @@ -252,16 +279,11 @@ def __call__(self, f): class Tag: def __init__(self, tags): - if isinstance(tags, str): - self.tags = [tags] - elif isinstance(tags, list) and all([isinstance(e, str) for e in tags]): - self.tags = tags - else: - raise TypeError("Tags must be a string or list of strings") + self.tags = tags def __call__(self, f): # Pass params to call function attribute for external access - update_spec(f, {"tags": self.tags}) + tag_spec(f, self.tags) return f @@ -285,11 +307,12 @@ def __init__(self, code, description=None, mimetype=None, **kwargs): } if self.mimetype: - self.response_dict.update( + rupdate( + self.response_dict, { "responses": {self.code: {"content": {self.mimetype: {}}}}, "_content_type": self.mimetype, - } + }, ) def __call__(self, f): diff --git a/labthings/server/views/__init__.py b/labthings/server/default_views/__init__.py similarity index 100% rename from labthings/server/views/__init__.py rename to labthings/server/default_views/__init__.py diff --git a/labthings/server/views/docs/__init__.py b/labthings/server/default_views/docs/__init__.py similarity index 100% rename from labthings/server/views/docs/__init__.py rename to labthings/server/default_views/docs/__init__.py diff --git a/labthings/server/views/docs/static/favicon-16x16.png b/labthings/server/default_views/docs/static/favicon-16x16.png similarity index 100% rename from labthings/server/views/docs/static/favicon-16x16.png rename to labthings/server/default_views/docs/static/favicon-16x16.png diff --git a/labthings/server/views/docs/static/favicon-32x32.png b/labthings/server/default_views/docs/static/favicon-32x32.png similarity index 100% rename from labthings/server/views/docs/static/favicon-32x32.png rename to labthings/server/default_views/docs/static/favicon-32x32.png diff --git a/labthings/server/views/docs/static/index.html b/labthings/server/default_views/docs/static/index.html similarity index 100% rename from labthings/server/views/docs/static/index.html rename to labthings/server/default_views/docs/static/index.html diff --git a/labthings/server/views/docs/static/oauth2-redirect.html b/labthings/server/default_views/docs/static/oauth2-redirect.html similarity index 100% rename from labthings/server/views/docs/static/oauth2-redirect.html rename to labthings/server/default_views/docs/static/oauth2-redirect.html diff --git a/labthings/server/views/docs/static/swagger-ui-bundle.js b/labthings/server/default_views/docs/static/swagger-ui-bundle.js similarity index 100% rename from labthings/server/views/docs/static/swagger-ui-bundle.js rename to labthings/server/default_views/docs/static/swagger-ui-bundle.js diff --git a/labthings/server/views/docs/static/swagger-ui-bundle.js.map b/labthings/server/default_views/docs/static/swagger-ui-bundle.js.map similarity index 100% rename from labthings/server/views/docs/static/swagger-ui-bundle.js.map rename to labthings/server/default_views/docs/static/swagger-ui-bundle.js.map diff --git a/labthings/server/views/docs/static/swagger-ui-standalone-preset.js b/labthings/server/default_views/docs/static/swagger-ui-standalone-preset.js similarity index 100% rename from labthings/server/views/docs/static/swagger-ui-standalone-preset.js rename to labthings/server/default_views/docs/static/swagger-ui-standalone-preset.js diff --git a/labthings/server/views/docs/static/swagger-ui-standalone-preset.js.map b/labthings/server/default_views/docs/static/swagger-ui-standalone-preset.js.map similarity index 100% rename from labthings/server/views/docs/static/swagger-ui-standalone-preset.js.map rename to labthings/server/default_views/docs/static/swagger-ui-standalone-preset.js.map diff --git a/labthings/server/views/docs/static/swagger-ui.css b/labthings/server/default_views/docs/static/swagger-ui.css similarity index 100% rename from labthings/server/views/docs/static/swagger-ui.css rename to labthings/server/default_views/docs/static/swagger-ui.css diff --git a/labthings/server/views/docs/static/swagger-ui.css.map b/labthings/server/default_views/docs/static/swagger-ui.css.map similarity index 100% rename from labthings/server/views/docs/static/swagger-ui.css.map rename to labthings/server/default_views/docs/static/swagger-ui.css.map diff --git a/labthings/server/views/docs/static/swagger-ui.js b/labthings/server/default_views/docs/static/swagger-ui.js similarity index 100% rename from labthings/server/views/docs/static/swagger-ui.js rename to labthings/server/default_views/docs/static/swagger-ui.js diff --git a/labthings/server/views/docs/static/swagger-ui.js.map b/labthings/server/default_views/docs/static/swagger-ui.js.map similarity index 100% rename from labthings/server/views/docs/static/swagger-ui.js.map rename to labthings/server/default_views/docs/static/swagger-ui.js.map diff --git a/labthings/server/views/docs/templates/swagger-ui.html b/labthings/server/default_views/docs/templates/swagger-ui.html similarity index 100% rename from labthings/server/views/docs/templates/swagger-ui.html rename to labthings/server/default_views/docs/templates/swagger-ui.html diff --git a/labthings/server/views/extensions.py b/labthings/server/default_views/extensions.py similarity index 82% rename from labthings/server/views/extensions.py rename to labthings/server/default_views/extensions.py index 77de1f8a..543091b4 100644 --- a/labthings/server/views/extensions.py +++ b/labthings/server/default_views/extensions.py @@ -2,9 +2,10 @@ from ..view import View from ..find import registered_extensions from ..schema import ExtensionSchema -from ..decorators import marshal_with +from ..decorators import marshal_with, Tag +@Tag("extensions") class ExtensionList(View): """List and basic documentation for all enabled Extensions""" @@ -16,4 +17,4 @@ def get(self): Returns a list of Extension representations, including basic documentation. Describes server methods, web views, and other relevant Lab Things metadata. """ - return registered_extensions().values() + return registered_extensions().values() or [] diff --git a/labthings/server/default_views/root.py b/labthings/server/default_views/root.py new file mode 100644 index 00000000..48ba70a2 --- /dev/null +++ b/labthings/server/default_views/root.py @@ -0,0 +1,7 @@ +from ..find import current_labthing +from ..view import View + + +class RootView(View): + def get(self): + return current_labthing().thing_description.to_dict() diff --git a/labthings/server/default_views/sockets.py b/labthings/server/default_views/sockets.py new file mode 100644 index 00000000..99f19ceb --- /dev/null +++ b/labthings/server/default_views/sockets.py @@ -0,0 +1,16 @@ +from ..sockets import SocketSubscriber, socket_handler_loop +from ..find import current_labthing + +import logging + + +def socket_handler(ws): + # Create a socket subscriber + wssub = SocketSubscriber(ws) + current_labthing().subscribers.add(wssub) + logging.info(f"Added subscriber {wssub}") + # Start the socket connection handler loop + socket_handler_loop(ws) + # Remove the subscriber once the loop returns + current_labthing().subscribers.remove(wssub) + logging.info(f"Removed subscriber {wssub}") diff --git a/labthings/server/views/tasks.py b/labthings/server/default_views/tasks.py similarity index 64% rename from labthings/server/views/tasks.py rename to labthings/server/default_views/tasks.py index 04595a04..09bdb4fa 100644 --- a/labthings/server/views/tasks.py +++ b/labthings/server/default_views/tasks.py @@ -1,4 +1,4 @@ -from flask import abort, url_for +from flask import abort from ..decorators import marshal_with, Tag from ..view import View @@ -17,6 +17,13 @@ def get(self): @Tag(["properties", "tasks"]) class TaskView(View): + """ + Manage a particular background task. + + GET will safely return the current task progress. + DELETE will terminate the background task, if running. + """ + @marshal_with(TaskSchema()) def get(self, task_id): """ @@ -24,11 +31,13 @@ def get(self, task_id): Includes progress and intermediate data. """ + task_dict = tasks.dictionary() - task = tasks.dictionary().get(task_id) - if not task: + if not task_id in task_dict: return abort(404) # 404 Not Found + task = task_dict.get(task_id) + return task @marshal_with(TaskSchema()) @@ -38,11 +47,13 @@ def delete(self, task_id): If the task is finished, deletes its entry. """ + task_dict = tasks.dictionary() - task = tasks.dictionary().get(task_id) - if not task: + if not task_id in task_dict: return abort(404) # 404 Not Found - task.terminate() + task = task_dict.get(task_id) + + task.kill(block=True, timeout=3) return task diff --git a/labthings/server/exceptions.py b/labthings/server/exceptions.py index 54ed488c..464abdc5 100644 --- a/labthings/server/exceptions.py +++ b/labthings/server/exceptions.py @@ -1,4 +1,4 @@ -from flask import jsonify, escape +from flask import escape from werkzeug.exceptions import default_exceptions from werkzeug.exceptions import HTTPException @@ -24,14 +24,21 @@ def std_handler(self, error): status_code = error.code if isinstance(error, HTTPException) else 500 - response = {"code": status_code, "message": escape(message)} - return jsonify(response), status_code + response = { + "code": status_code, + "message": escape(message), + "name": getattr(error, "__name__", None) + or getattr(getattr(error, "__class__", None), "__name__", None) + or None, + } + return (response, status_code) def init_app(self, app): self.app = app self.register(HTTPException) for code, v in default_exceptions.items(): self.register(code) + self.register(Exception) def register(self, exception_or_code, handler=None): self.app.errorhandler(exception_or_code)(handler or self.std_handler) diff --git a/labthings/server/extensions.py b/labthings/server/extensions.py index 5c37e6d5..920fae9f 100644 --- a/labthings/server/extensions.py +++ b/labthings/server/extensions.py @@ -55,7 +55,7 @@ def __init__( self.static_view_class = static_from(static_folder) self.add_view( - self.static_view_class, f"{static_url_path}/", view_id="static", + self.static_view_class, f"{static_url_path}/", view_id="static" ) @property @@ -65,7 +65,7 @@ def views(self): def add_view(self, view_class, rule, view_id=None, **kwargs): # Remove all leading slashes from view route cleaned_rule = rule - while cleaned_rule[0] == "/": + while cleaned_rule and cleaned_rule[0] == "/": cleaned_rule = cleaned_rule[1:] # Expand the rule to include extension name @@ -141,7 +141,7 @@ def add_method(self, method, method_name): if not hasattr(self, method_name): setattr(self, method_name, method) else: - logging.warning( + raise NameError( "Unable to bind method to extension. Method name already exists." ) @@ -221,6 +221,7 @@ def find_extensions(extension_dir: str, module_name="extensions") -> list: extensions = [] extension_paths = glob.glob(os.path.join(extension_dir, "*.py")) + extension_paths.extend(glob.glob(os.path.join(extension_dir, "*", "__init__.py"))) for extension_path in extension_paths: extensions.extend( diff --git a/labthings/server/find.py b/labthings/server/find.py index c59a771e..d661635a 100644 --- a/labthings/server/find.py +++ b/labthings/server/find.py @@ -1,7 +1,8 @@ import logging from flask import current_app +import weakref -from . import EXTENSION_NAME +from .names import EXTENSION_NAME def current_labthing(app=None): @@ -13,14 +14,15 @@ def current_labthing(app=None): # reach the Flask app object. Just using current_app returns # a wrapper, which breaks it's use in Task threads if not app: - app = current_app._get_current_object() # skipcq: PYL-W0212 - if not app: - return None - logging.debug("Active app extensions:") - logging.debug(app.extensions) - logging.debug("Active labthing:") - logging.debug(app.extensions[EXTENSION_NAME]) - return app.extensions.get(EXTENSION_NAME, None) + try: + app = current_app._get_current_object() # skipcq: PYL-W0212 + except RuntimeError: + return None + ext = app.extensions.get(EXTENSION_NAME, None) + if isinstance(ext, weakref.ref): + return ext() + else: + return ext def registered_extensions(labthing_instance=None): @@ -35,7 +37,8 @@ def registered_extensions(labthing_instance=None): """ if not labthing_instance: labthing_instance = current_labthing() - return labthing_instance.extensions + + return getattr(labthing_instance, "extensions", {}) def registered_components(labthing_instance=None): diff --git a/labthings/server/labthing.py b/labthings/server/labthing.py index e79a6ac2..46ff5a2d 100644 --- a/labthings/server/labthing.py +++ b/labthings/server/labthing.py @@ -2,22 +2,32 @@ from apispec import APISpec from apispec.ext.marshmallow import MarshmallowPlugin -from . import EXTENSION_NAME # TODO: Move into .names -from .names import TASK_ENDPOINT, TASK_LIST_ENDPOINT, EXTENSION_LIST_ENDPOINT +from .names import ( + EXTENSION_NAME, + TASK_ENDPOINT, + TASK_LIST_ENDPOINT, + EXTENSION_LIST_ENDPOINT, +) from .extensions import BaseExtension -from .utilities import description_from_view +from .utilities import description_from_view, clean_url_string +from .exceptions import JSONExceptionHandler +from .logging import LabThingLogger +from .representations import LabThingsJSONEncoder from .spec.apispec import rule_to_apispec_path from .spec.utilities import get_spec from .spec.td import ThingDescription from .decorators import tag -from .sockets import Sockets, SocketSubscriber, socket_handler_loop +from .sockets import Sockets -from .views.extensions import ExtensionList -from .views.tasks import TaskList, TaskView -from .views.docs import docs_blueprint, SwaggerUIView +from .default_views.extensions import ExtensionList +from .default_views.tasks import TaskList, TaskView +from .default_views.docs import docs_blueprint, SwaggerUIView +from .default_views.root import RootView +from .default_views.sockets import socket_handler from ..core.utilities import get_docstring +import weakref import logging @@ -30,6 +40,7 @@ def __init__( description: str = "", types: list = [], version: str = "0.0.0", + format_flask_exceptions: bool = True, ): self.app = app # Becomes a Flask app self.sockets = None # Becomes a Socket(app) websocket handler @@ -59,8 +70,13 @@ def __init__( self._title = title self._version = version - # Store handlers for things like errors and CORS - self.handlers = {} + # Flags for error handling + self.format_flask_exceptions = format_flask_exceptions + + # Logging handler + # TODO: Add cleanup code + self.log_handler = LabThingLogger() + logging.getLogger().addHandler(self.log_handler) self.spec = APISpec( title=self.title, @@ -84,7 +100,7 @@ def description(self, description: str): self.spec.description = description @property - def title(self,): + def title(self): return self._title @title.setter @@ -92,6 +108,15 @@ def title(self, title: str): self._title = title self.spec.title = title + @property + def safe_title(self): + title = self.title + if not title: + title = "unknown" + title = title.replace(" ", "") + title = title.lower() + return title + @property def version(self,): return str(self._version) @@ -104,11 +129,19 @@ def version(self, version: str): # Flask stuff def init_app(self, app): - app.teardown_appcontext(self.teardown) + self.app = app # Register Flask extension app.extensions = getattr(app, "extensions", {}) - app.extensions[EXTENSION_NAME] = self + app.extensions[EXTENSION_NAME] = weakref.ref(self) + + # Flask error formatter + if self.format_flask_exceptions: + error_handler = JSONExceptionHandler() + error_handler.init_app(app) + + # Custom JSON encoder + app.json_encoder = LabThingsJSONEncoder # Add resources, if registered before tying to a Flask app if len(self.views) > 0: @@ -122,12 +155,9 @@ def init_app(self, app): self.sockets = Sockets(app) self._create_base_sockets() - def teardown(self, exception): - pass - def _create_base_routes(self): # Add root representation - self.app.add_url_rule(self._complete_url("/", ""), "root", self.root) + self.add_view(RootView, "/", endpoint="root") # Add thing descriptions self.app.register_blueprint( docs_blueprint, url_prefix=f"{self.url_prefix}/docs" @@ -143,20 +173,7 @@ def _create_base_routes(self): self.add_view(TaskView, "/tasks/", endpoint=TASK_ENDPOINT) def _create_base_sockets(self): - self.sockets.add_url_rule(f"{self.url_prefix}", self._socket_handler) - - def _socket_handler(self, ws): - # Create a socket subscriber - wssub = SocketSubscriber(ws) - self.subscribers.add(wssub) - logging.info(f"Added subscriber {wssub}") - logging.debug(list(self.subscribers)) - # Start the socket connection handler loop - socket_handler_loop(ws) - # Remove the subscriber once the loop returns - self.subscribers.remove(wssub) - logging.info(f"Removed subscriber {wssub}") - logging.debug(list(self.subscribers)) + self.sockets.add_view(self._complete_url("", ""), socket_handler) # Device stuff @@ -215,8 +232,9 @@ def _complete_url(self, url_part, registration_prefix): :param registration_prefix: The part of the url contributed by the blueprint. Generally speaking, BlueprintSetupState.url_prefix """ - parts = [registration_prefix, self.url_prefix, url_part] - return "".join([part for part in parts if part]) + parts = [self.url_prefix, registration_prefix, url_part] + u = "".join([clean_url_string(part) for part in parts if part]) + return u if u else "/" def add_view(self, resource, *urls, endpoint=None, **kwargs): """Adds a view to the api. @@ -264,18 +282,6 @@ def _register_view(self, app, view, *urls, endpoint=None, **kwargs): resource_class_args = kwargs.pop("resource_class_args", ()) resource_class_kwargs = kwargs.pop("resource_class_kwargs", {}) - # NOTE: 'view_functions' is cleaned up from Blueprint class in Flask 1.0 - if endpoint in getattr(app, "view_functions", {}): - previous_view_class = app.view_functions[endpoint].__dict__["view_class"] - - # If you override the endpoint with a different class, - # avoid the collision by raising an exception - if previous_view_class != view: - raise ValueError( - "This endpoint (%s) is already set to the class %s." - % (endpoint, previous_view_class.__name__) - ) - view.endpoint = endpoint resource_func = view.as_view( endpoint, *resource_class_args, **resource_class_kwargs @@ -289,17 +295,19 @@ def _register_view(self, app, view, *urls, endpoint=None, **kwargs): # There might be a better way to do this than _rules_by_endpoint, # but I can't find one so this will do for now. Skipping PYL-W0212 + # FIXME: There is a MASSIVE memory leak or something going on in APISpec! + # This is grinding tests to a halt, and is really annoying... Should be fixed. flask_rules = app.url_map._rules_by_endpoint.get(endpoint) # skipcq: PYL-W0212 for flask_rule in flask_rules: self.spec.path(**rule_to_apispec_path(flask_rule, view, self.spec)) # Handle resource groups listed in API spec view_spec = get_spec(view) - view_groups = view_spec.get("_groups", {}) - if "actions" in view_groups: + view_tags = view_spec.get("tags", set()) + if "actions" in view_tags: self.thing_description.action(flask_rules, view) self._action_views[view.endpoint] = view - if "properties" in view_groups: + if "properties" in view_tags: self.thing_description.property(flask_rules, view) self._property_views[view.endpoint] = view @@ -308,7 +316,9 @@ def _register_view(self, app, view, *urls, endpoint=None, **kwargs): def url_for(self, view, **values): """Generates a URL to the given resource. Works like :func:`flask.url_for`.""" - endpoint = view.endpoint + endpoint = getattr(view, "endpoint", None) + if not endpoint: + return "" # Default to external links if "_external" not in values: values["_external"] = True @@ -323,8 +333,3 @@ def add_root_link(self, view, rel, kwargs=None, params=None): if params is None: params = {} self.thing_description.add_link(view, rel, kwargs=kwargs, params=params) - - # Description - def root(self): - """Root representation""" - return self.thing_description.to_dict() diff --git a/labthings/server/logging.py b/labthings/server/logging.py new file mode 100644 index 00000000..8d53ba99 --- /dev/null +++ b/labthings/server/logging.py @@ -0,0 +1,26 @@ +from .find import current_labthing + +from logging import StreamHandler +import datetime + + +class LabThingLogger(StreamHandler): + def __init__(self, *args, **kwargs): + StreamHandler.__init__(self, *args, **kwargs) + + def emit(self, record): + log_event = self.rest_format_record(record) + + # Broadcast to subscribers + subscribers = getattr(current_labthing(), "subscribers", []) + for sub in subscribers: + sub.event_notify(log_event) + + def rest_format_record(self, record): + data = { + "data": str(record.msg), + "timestamp": datetime.datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.%fZ"), + } + level_string = record.levelname.lower() + + return {level_string: data} diff --git a/labthings/server/monkey.py b/labthings/server/monkey.py new file mode 100644 index 00000000..006f1e58 --- /dev/null +++ b/labthings/server/monkey.py @@ -0,0 +1,18 @@ +from gevent.monkey import patch_all + +__all__ = ["patch_all"] + +""" +NOTE: THIS FILE IS EXCLUDED FROM OUR UNIT TESTS. + +MONKEY PATCHING IN THE MIDDLE OF A TEST SUITE RUNNING +MAY CAUSE PROBLEMS. + +GIVEN THAT THIS MODULE IS SIMPLY A PROXY FOR GEVENTS +MONKEY PATCHER, TESTSING IS FAIRLY REDUNDANT ANYWAY. + +THIS SHOULD BE PERIODICALLY REVISITED TO MAKE SURE ITS +STILL TRUE. + +THANKS +""" diff --git a/labthings/server/names.py b/labthings/server/names.py index 417c5138..00568fc1 100644 --- a/labthings/server/names.py +++ b/labthings/server/names.py @@ -1,3 +1,4 @@ TASK_ENDPOINT = "labthing_task" TASK_LIST_ENDPOINT = "labthing_task_list" EXTENSION_LIST_ENDPOINT = "labthing_extension_list" +EXTENSION_NAME = "flask-labthings" diff --git a/labthings/server/quick.py b/labthings/server/quick.py index d3691f76..a797d597 100644 --- a/labthings/server/quick.py +++ b/labthings/server/quick.py @@ -46,11 +46,6 @@ def create_app( if handle_cors: cors_handler = CORS(app, resources=f"{prefix}/*") - # Handle errors - if handle_errors: - error_handler = JSONExceptionHandler() - error_handler.init_app(app) - # Create a LabThing labthing = LabThing( app, @@ -59,13 +54,7 @@ def create_app( description=description, types=types, version=str(version), + format_flask_exceptions=handle_errors, ) - # Store references to added-in handlers - if cors_handler: - labthing.handlers["cors"] = cors_handler - if error_handler: - labthing.handlers["error"] = error_handler - return app, labthing - diff --git a/labthings/server/representations.py b/labthings/server/representations.py index c91eb5ed..f59e16db 100644 --- a/labthings/server/representations.py +++ b/labthings/server/representations.py @@ -1,21 +1,22 @@ from flask import make_response, current_app -from json import dumps +from json import dumps, JSONEncoder from ..core.utilities import PY3 -def encode_json(data): - """Makes JSON encoded data using the current Flask apps JSON settings""" +class LabThingsJSONEncoder(JSONEncoder): + """ + A custom JSON encoder, with type conversions for PiCamera fractions, Numpy integers, and Numpy arrays + """ - settings = current_app.config.get("LABTHINGS_JSON", {}) - encoder = current_app.json_encoder + def default(self, o): + if isinstance(o, set): + return list(o) + return JSONEncoder.default(self, o) - # If we're in debug mode, and the indent is not set, we set it to a - # reasonable value here. Note that this won't override any existing value - # that was set. We also set the "sort_keys" value. - if current_app.debug: - settings.setdefault("indent", 4) - settings.setdefault("sort_keys", not PY3) + +def encode_json(data, encoder=LabThingsJSONEncoder, **settings): + """Makes JSON encoded data using the LabThings JSON encoder""" # always end the json dumps with a new line # see https://github.com/mitsuhiko/flask/pull/1262 @@ -25,9 +26,19 @@ def encode_json(data): def output_json(data, code, headers=None): - """Makes a Flask response with a JSON encoded body""" + """Makes a Flask response with a JSON encoded body, using app JSON settings""" + + settings = current_app.config.get("LABTHINGS_JSON", {}) + encoder = current_app.json_encoder + + # If we're in debug mode, and the indent is not set, we set it to a + # reasonable value here. Note that this won't override any existing value + # that was set. We also set the "sort_keys" value. + if current_app.debug: + settings.setdefault("indent", 4) + settings.setdefault("sort_keys", not PY3) - dumped = encode_json(data) + "\n" + dumped = encode_json(data, encoder=encoder, **settings) resp = make_response(dumped, code) resp.headers.extend(headers or {}) diff --git a/labthings/server/schema.py b/labthings/server/schema.py index d9f9a89d..f8bbfd5a 100644 --- a/labthings/server/schema.py +++ b/labthings/server/schema.py @@ -1,15 +1,12 @@ # -*- coding: utf-8 -*- from flask import jsonify, url_for +from werkzeug.routing import BuildError import marshmallow from .names import TASK_ENDPOINT, TASK_LIST_ENDPOINT, EXTENSION_LIST_ENDPOINT from .utilities import view_class_from_endpoint, description_from_view from . import fields -MARSHMALLOW_VERSION_INFO = tuple( - [int(part) for part in marshmallow.__version__.split(".") if part.isdigit()] -) - sentinel = object() @@ -25,21 +22,10 @@ def jsonify(self, obj, *args, many=sentinel, **kwargs): or as a collection. If unset, defaults to the value of the `many` attribute on this Schema. :param kwargs: Additional keyword arguments passed to `flask.jsonify`. - .. versionchanged:: 0.6.0 - Takes the same arguments as `marshmallow.Schema.dump`. Additional - keyword arguments are passed to `flask.jsonify`. - .. versionchanged:: 0.6.3 - The `many` argument for this method defaults to the value of - the `many` attribute on the Schema. Previously, the `many` - argument of this method defaulted to False, regardless of the - value of `Schema.many`. """ if many is sentinel: many = self.many - if MARSHMALLOW_VERSION_INFO[0] >= 3: - data = self.dump(obj, many=many) - else: - data = self.dump(obj, many=many).data + data = self.dump(obj, many=many) return jsonify(data, *args, **kwargs) @@ -75,6 +61,9 @@ def serialize(self, value): return self.field.serialize("value", obj) + def dump(self, value): + return self.serialize(value) + def jsonify(self, value): """Serialize a value to JSON @@ -102,9 +91,13 @@ class TaskSchema(Schema): @marshmallow.pre_dump def generate_links(self, data, **kwargs): + try: + url = url_for(TASK_ENDPOINT, task_id=data.id, _external=True) + except BuildError: + url = None data.links = { "self": { - "href": url_for(TASK_ENDPOINT, task_id=data.id, _external=True), + "href": url, "mimetype": "application/json", **description_from_view(view_class_from_endpoint(TASK_ENDPOINT)), } @@ -127,11 +120,13 @@ def generate_links(self, data, **kwargs): for view_id, view_data in data.views.items(): view_cls = view_data["view"] view_rule = view_data["rule"] + # Try to build a URL + try: + url = url_for(EXTENSION_LIST_ENDPOINT, _external=True) + view_rule + except BuildError: + url = None # Make links dictionary if it doesn't yet exist - d[view_id] = { - "href": url_for(EXTENSION_LIST_ENDPOINT, _external=True) + view_rule, - **description_from_view(view_cls), - } + d[view_id] = {"href": url, **description_from_view(view_cls)} data.links = d diff --git a/labthings/server/sockets/base.py b/labthings/server/sockets/base.py index b3caaeb3..48509e11 100644 --- a/labthings/server/sockets/base.py +++ b/labthings/server/sockets/base.py @@ -20,14 +20,21 @@ def property_notify(self, viewcls): else: property_value = None - property_name = str(getattr(viewcls, "endpoint", "unknown")) + property_name = getattr(viewcls, "endpoint", None) or getattr( + viewcls, "__name__", "unknown" + ) response = encode_json( - {"messageType": "propertyStatus", "data": {property_name: property_value},} + {"messageType": "propertyStatus", "data": {property_name: property_value}} ) self.ws.send(response) + def event_notify(self, event_dict: dict): + response = encode_json({"messageType": "event", "data": event_dict}) + + self.ws.send(response) + class BaseSockets(ABC): def __init__(self, app=None): @@ -49,18 +56,21 @@ def __init__(self, app=None): @abstractmethod def init_app(self, app): - pass + "Registers Flask middleware" def route(self, rule, **options): - def decorator(view_func): - options.pop("endpoint", None) - self.add_url_rule(rule, view_func, **options) - return view_func + def decorator(f): + endpoint = options.pop("endpoint", None) + self.add_url_rule(rule, endpoint, f, **options) + return f return decorator - def add_url_rule(self, rule, view_func, **options): - self.url_map.add(Rule(rule, endpoint=view_func)) + def add_url_rule(self, rule, _, f, **options): + self.url_map.add(Rule(rule, endpoint=f)) + + def add_view(self, rule, f, **options): + return self.add_url_rule(rule, None, f, **options) def register_blueprint(self, blueprint, **options): """ @@ -87,7 +97,6 @@ def register_blueprint(self, blueprint, **options): def process_socket_message(message: str): if message: - # return f"Recieved: {message}" return None else: return None diff --git a/labthings/server/sockets/gevent.py b/labthings/server/sockets/gevent.py index eb0ed70c..88d97796 100644 --- a/labthings/server/sockets/gevent.py +++ b/labthings/server/sockets/gevent.py @@ -59,6 +59,5 @@ def socket_handler_loop(ws): break response = process_socket_message(message) if response: - logging.info(response) ws.send(response) gevent.sleep(0.1) diff --git a/labthings/server/spec/apispec.py b/labthings/server/spec/apispec.py index e4aaecde..bdeae9a1 100644 --- a/labthings/server/spec/apispec.py +++ b/labthings/server/spec/apispec.py @@ -9,36 +9,6 @@ from http import HTTPStatus -def build_spec(view, inherit_from=None): - # Create empty spec if missing so we can work safely with it - if not hasattr(view, "__apispec__"): - view.__apispec__ = {} - # Check for a spec to inherit from - inherited_spec = getattr(inherit_from, "__apispec__", {}) - - # Build a description - description = ( - getattr(view, "__apispec__").get("description") - or get_docstring(view) - or inherited_spec.get("description") - ) - - # Build a summary - summary = ( - getattr(view, "__apispec__").get("summary") - or inherited_spec.get("summary") - or description - ) - - # Build tags - tags = getattr(view, "__apispec__").get("tags", []) - tags.extend(inherited_spec.get("tags", [])) - - return update_spec( - view, {"description": description, "summary": summary, "tags": tags} - ) - - def rule_to_apispec_path(rule: Rule, view: View, spec: APISpec): """Generate APISpec Path arguments from a flask Rule and View @@ -67,9 +37,8 @@ def rule_to_apispec_path(rule: Rule, view: View, spec: APISpec): params["operations"][op].update({"parameters": rule_to_params(rule)}) # Add extra parameters - if hasattr(view, "__apispec__"): - # Recursively update params - rupdate(params, view.__apispec__) + # build_spec(view) guarantees view.__apispec__ exists + rupdate(params, view.__apispec__) return params @@ -87,26 +56,26 @@ def view_to_apispec_operations(view: View, spec: APISpec): # Build dictionary of operations (HTTP methods) ops = {} - for method in View.methods: - if hasattr(view, method): - ops[method] = {} - method_function = getattr(view, method) + for method in view.methods: + method = str(method).lower() + ops[method] = {} + method_function = getattr(view, method) - # Populate missing spec parameters - build_spec(method_function, inherit_from=view) + # Populate missing spec parameters + build_spec(method_function, inherit_from=view) - rupdate( - ops[method], - { - "description": getattr(method_function, "__apispec__").get( - "description" - ), - "summary": getattr(method_function, "__apispec__").get("summary"), - "tags": getattr(method_function, "__apispec__").get("tags"), - }, - ) + rupdate( + ops[method], + { + "description": getattr(method_function, "__apispec__").get( + "description" + ), + "summary": getattr(method_function, "__apispec__").get("summary"), + "tags": getattr(method_function, "__apispec__").get("tags"), + }, + ) - rupdate(ops[method], method_to_apispec_operation(method_function, spec)) + rupdate(ops[method], method_to_apispec_operation(method_function, spec)) return ops @@ -175,3 +144,33 @@ def method_to_apispec_operation(method: callable, spec: APISpec): rupdate(op, {key: val}) return op + + +def build_spec(view, inherit_from=None): + # Create empty spec if missing so we can work safely with it + if not hasattr(view, "__apispec__"): + view.__apispec__ = {} + # Check for a spec to inherit from + inherited_spec = getattr(inherit_from, "__apispec__", {}) + + # Build a description + description = ( + getattr(view, "__apispec__").get("description") + or get_docstring(view) + or inherited_spec.get("description") + ) + + # Build a summary + summary = ( + getattr(view, "__apispec__").get("summary") + or inherited_spec.get("summary") + or description + ) + + # Build tags + tags = getattr(view, "__apispec__").get("tags", set()) + tags = tags.union(inherited_spec.get("tags", set())) + + return update_spec( + view, {"description": description, "summary": summary, "tags": tags} + ) diff --git a/labthings/server/spec/td.py b/labthings/server/spec/td.py index 10ce9f9d..0a7ad4d8 100644 --- a/labthings/server/spec/td.py +++ b/labthings/server/spec/td.py @@ -1,5 +1,6 @@ from flask import url_for, request from apispec import APISpec +import weakref from ..view import View @@ -14,8 +15,7 @@ def find_schema_for_view(view: View): """Find the broadest available data schema for a Flask view - First looks for class-level, then GET, POST, and PUT methods depending on if the - view is read/write only + Looks for GET, POST, and PUT methods depending on if the view is read/write only Args: view (View): View to search for schema @@ -34,22 +34,28 @@ def find_schema_for_view(view: View): if hasattr(view, "post"): # Use POST schema prop_schema = get_spec(view.post).get("_params") - elif hasattr(view, "put"): + else: # Use PUT schema prop_schema = get_spec(view.put).get("_params") + else: + prop_schema = {} return prop_schema class ThingDescription: def __init__(self, apispec: APISpec): - self.apispec = apispec + self._apispec = weakref.ref(apispec) self.properties = {} self.actions = {} self.events = {} self._links = [] super().__init__() + @property + def apispec(self): + return self._apispec() + @property def links(self): td_links = [] @@ -133,7 +139,13 @@ def view_to_thing_property(self, rules: list, view: View): for prop_rule in rules: params_dict = {} for param in rule_to_params(prop_rule): - params_dict.update({param.get("name"): {"type": param.get("type")}}) + params_dict.update( + { + param.get("name"): { + "type": param.get("type") or param.get("schema").get("type") + } + } + ) prop_description["uriVariables"].update(params_dict) if not prop_description["uriVariables"]: del prop_description["uriVariables"] @@ -157,6 +169,15 @@ def view_to_thing_property_forms(self, rules: list, view: View): def view_to_thing_action(self, rules: list, view: View): action_urls = [rule_to_path(rule) for rule in rules] + # Check if action is safe + is_safe = get_spec(view.post).get("_safe", False) or get_spec(view).get( + "_safe", False + ) + + is_idempotent = get_spec(view.post).get("_idempotent", False) or get_spec( + view + ).get("_idempotent", False) + # Basic description action_description = { "title": view.__name__, @@ -166,19 +187,33 @@ def view_to_thing_action(self, rules: list, view: View): # TODO: Make URLs absolute "links": [{"href": f"{url}"} for url in action_urls], "forms": self.view_to_thing_action_forms(rules, view), + "safe": is_safe, + "idempotent": is_idempotent, } + # Look for a _propertySchema in the Property classes API SPec + action_schema = get_spec(view.post).get("_params") + + if action_schema: + # Ensure valid schema type + action_schema = convert_schema(action_schema, self.apispec) + + # Add schema to prop description + action_description["input"] = schema_to_json(action_schema, self.apispec) + return action_description def view_to_thing_action_forms(self, rules: list, view: View): return self.build_forms_for_view(rules, view, op=["invokeaction"]) def property(self, rules: list, view: View): - key = snake_to_camel(view.endpoint) + endpoint = getattr(view, "endpoint") or getattr(rules[0], "endpoint") + key = snake_to_camel(endpoint) self.properties[key] = self.view_to_thing_property(rules, view) def action(self, rules: list, view: View): - key = snake_to_camel(view.endpoint) + endpoint = getattr(view, "endpoint") or getattr(rules[0], "endpoint") + key = snake_to_camel(endpoint) self.actions[key] = self.view_to_thing_action(rules, view) def build_forms_for_view(self, rules: list, view: View, op: list): diff --git a/labthings/server/spec/utilities.py b/labthings/server/spec/utilities.py index 8e1d0186..edc3a8a3 100644 --- a/labthings/server/spec/utilities.py +++ b/labthings/server/spec/utilities.py @@ -6,7 +6,7 @@ from ..fields import Field from marshmallow import Schema as BaseSchema -from collections import Mapping +from collections.abc import Mapping def update_spec(obj, spec: dict): @@ -21,6 +21,22 @@ def update_spec(obj, spec: dict): return obj.__apispec__ or {} +def tag_spec(obj, tags, add_group: bool = True): + obj.__apispec__ = obj.__dict__.get("__apispec__", {}) + + if "tags" not in obj.__apispec__: + obj.__apispec__["tags"] = set() + + if isinstance(tags, set) or isinstance(tags, list): + if not all([isinstance(e, str) for e in tags]): + raise TypeError("All tags must be strings") + obj.__apispec__["tags"] = obj.__apispec__["tags"].union(tags) + elif isinstance(tags, str): + obj.__apispec__["tags"].add(tags) + else: + raise TypeError("All tags must be strings") + + def get_spec(obj): """ Get the __apispec__ dictionary, created by LabThings decorators, diff --git a/labthings/server/types.py b/labthings/server/types.py deleted file mode 100644 index 7ce615b8..00000000 --- a/labthings/server/types.py +++ /dev/null @@ -1,168 +0,0 @@ -# Marshmallow fields to JSON schema types -# Note: We shouldn't ever need to use this directly. -# We should go via the apispec converter -from apispec.ext.marshmallow.field_converter import DEFAULT_FIELD_MAPPING - -from labthings.server import fields -from labthings.core.utilities import rapply - -from labthings.server.schema import Schema - -# Extra standard library Python types -from datetime import date, datetime, time, timedelta -from decimal import Decimal -from typing import Dict, List, Tuple, Union -from uuid import UUID - -import logging -import inspect -import copy - -# Python types to Marshmallow fields -DEFAULT_TYPE_MAPPING = { - bool: fields.Boolean, - date: fields.Date, - datetime: fields.DateTime, - Decimal: fields.Decimal, - float: fields.Float, - int: fields.Integer, - str: fields.String, - time: fields.Time, - timedelta: fields.TimeDelta, - UUID: fields.UUID, - dict: fields.Dict, - Dict: fields.Dict, -} - -# Functions to handle conversion of common Python types into serialisable Python types - - -def ndarray_to_list(o): - """Convert a Numpy ndarray into a list of values - - Args: - o (numpy.ndarray): Data to convert - - Returns: - list: Python list of data - """ - return o.tolist() - - -def to_int(o): - """Convert a value into a Python integer - - Args: - o: Data to convert - - Returns: - int: Python int of data - """ - return int(o) - - -def to_float(o): - """Convert a value into a Python float - - Args: - o: Data to convert - - Returns: - int: Python float of data - """ - return float(o) - - -def to_string(o): - """Convert a value into a Python string - - Args: - o: Data to convert - - Returns: - int: Python string of data - """ - return str(o) - - -# Map of Python type conversions -DEFAULT_BUILTIN_CONVERSIONS = { - "numpy.ndarray": ndarray_to_list, - "numpy.int": to_int, - "fractions.Fraction": to_float, -} - - -def make_primative(value): - """Attempt to convert a value into a primative Python type - - Args: - value: Data to convert - - Returns: - Converted data if possible, otherwise original data - """ - global DEFAULT_BUILTIN_CONVERSIONS, DEFAULT_TYPE_MAPPING - - logging.debug(f"Converting {value} to primative type...") - value_typestrings = [ - x.__module__ + "." + x.__name__ for x in inspect.getmro(type(value)) - ] - - for typestring in value_typestrings: - if typestring in DEFAULT_BUILTIN_CONVERSIONS: - value = DEFAULT_BUILTIN_CONVERSIONS.get(typestring)(value) - break - - # If the final type is not primative - if not type(value) in DEFAULT_TYPE_MAPPING: - # Fall back to a string representation - value = str(value) - - return value - - -def value_to_field(value): - """Attempt to match a value to a Marshmallow field type - - Args: - value: Data to obtain field from - - Raises: - TypeError: Data is not of a type that maps to a Marshmallow field - - Returns: - Marshmallow field best matching the value type - """ - global DEFAULT_TYPE_MAPPING - - if isinstance(value, (List, Tuple)) or type(value) is type(Union): - # Get type of elements from the zeroth element. - # NOTE: This is definitely not ideal, but we can TODO later - element_field = value_to_field(value[0]) - return fields.List(element_field, example=value) - if type(value) in DEFAULT_TYPE_MAPPING: - return DEFAULT_TYPE_MAPPING.get(type(value))(example=value) - else: - raise TypeError(f"Unsupported data type {type(value)}") - - -def data_dict_to_schema(data_dict: dict): - """Attempt to create a Marshmallow schema from a dictionary of data - - Args: - data_dict (dict): Dictionary of data - - Returns: - dict: Dictionary of Marshmallow fields matching input data types - """ - working_dict = copy.deepcopy(data_dict) - - working_dict = rapply(working_dict, make_primative) - working_dict = rapply(working_dict, value_to_field, apply_to_iterables=False) - - return working_dict - - -# TODO: Deserialiser with inverse defaults -# TODO: Option to switch to .npy serialisation/deserialisation (see OFM server) diff --git a/labthings/server/types/__init__.py b/labthings/server/types/__init__.py new file mode 100644 index 00000000..133291b4 --- /dev/null +++ b/labthings/server/types/__init__.py @@ -0,0 +1,3 @@ +from .properties import value_to_field, data_dict_to_schema, PropertyConverter +from .annotations import function_signature_to_schema, AnnotationConverter +from .preprocess import make_primitive diff --git a/labthings/server/types/annotations.py b/labthings/server/types/annotations.py new file mode 100644 index 00000000..18c0b33a --- /dev/null +++ b/labthings/server/types/annotations.py @@ -0,0 +1,71 @@ +from .registry import TypeRegistry +from labthings.server import fields + +from typing import List, Tuple +from inspect import Parameter +from marshmallow.base import FieldABC + +import inspect + +NoneType = type(None) + + +class AnnotationConverter: + def __init__(self): + self._registry = TypeRegistry() + self._registry.register(List, self._list_converter) + self._registry.register(list, self._list_converter) + + def _list_converter(self, subtypes: Tuple[type], **opts) -> FieldABC: + sub_opts = opts.pop("_interior", {}) + return fields.List(self.convert(subtypes[0], **sub_opts), **opts) + + def convert(self, parameter, **kwargs) -> FieldABC: + # sane defaults + allow_none = False + required = True + optional = False + subtypes = () + + if isinstance(parameter, Parameter): + typehint = parameter.annotation + optional = not (parameter.default is parameter.empty) + elif isinstance(parameter, type): + typehint = parameter + else: + typehint = type(parameter) + + if optional: + allow_none = True + required = False + + if isinstance(parameter, Parameter): + # Get subtypes + subtypes = getattr(typehint, "__args__", ()) + if subtypes != (): + typehint = typehint.__origin__ + + # Get default + if not (parameter.default is parameter.empty): + kwargs.setdefault("default", parameter.default) + kwargs.setdefault("example", parameter.default) + + kwargs.setdefault("allow_none", allow_none) + kwargs.setdefault("required", required) + + field_constructor = self._registry.get(typehint) + return field_constructor(subtypes, **kwargs) + + +def function_signature_to_schema(function: callable): + """ + """ + converter = AnnotationConverter() + + schema_dict = {} + params = inspect.signature(function).parameters + + for k, p in params.items(): + schema_dict[k] = converter.convert(p) + + return schema_dict diff --git a/labthings/server/types/preprocess.py b/labthings/server/types/preprocess.py new file mode 100644 index 00000000..1d8b3567 --- /dev/null +++ b/labthings/server/types/preprocess.py @@ -0,0 +1,95 @@ +# Functions to handle conversion of common Python types into serialisable Python types +import inspect + +from .registry import PRIMITIVE_TYPES + + +def ndarray_to_list(o): + """Convert a Numpy ndarray into a list of values + + Args: + o (numpy.ndarray): Data to convert + + Returns: + list: Python list of data + """ + return o.tolist() + + +def to_int(o): + """Convert a value into a Python integer + + Args: + o: Data to convert + + Returns: + int: Python int of data + """ + return int(o) + + +def to_float(o): + """Convert a value into a Python float + + Args: + o: Data to convert + + Returns: + int: Python float of data + """ + return float(o) + + +def to_string(o): + """Convert a value into a Python string + + Args: + o: Data to convert + + Returns: + int: Python string of data + """ + return str(o) + + +# Map of Python type conversions +DEFAULT_BUILTIN_CONVERSIONS = { + "numpy.ndarray": ndarray_to_list, + "numpy.integer": to_int, + "fractions.Fraction": to_float, +} + + +def make_primitive(value): + """Attempt to convert a value into a primitive Python type + + Args: + value: Data to convert + + Returns: + Converted data if possible, otherwise original data + """ + # Return if already primitive + if type(value) in PRIMITIVE_TYPES: + return value + + value_typestrings = [ + x.__module__ + "." + x.__name__ for x in inspect.getmro(type(value)) + ] + + return_value = None + for typestring in value_typestrings: + if typestring in DEFAULT_BUILTIN_CONVERSIONS: + return_value = DEFAULT_BUILTIN_CONVERSIONS.get(typestring)(value) + break + + # If the final type is not primitive + if not type(return_value) in PRIMITIVE_TYPES: + # Fall back to a string representation + return_value = to_string(value) + + return return_value + + +# TODO: Deserialiser with inverse defaults +# TODO: Option to switch to .npy serialisation/deserialisation (see OFM server) diff --git a/labthings/server/types/properties.py b/labthings/server/types/properties.py new file mode 100644 index 00000000..74cc6be1 --- /dev/null +++ b/labthings/server/types/properties.py @@ -0,0 +1,67 @@ +from .registry import TypeRegistry +from .preprocess import make_primitive + +from labthings.server import fields +from labthings.core.utilities import rapply + +from marshmallow.base import FieldABC +from typing import List, Tuple, Union +import copy + + +class PropertyConverter: + def __init__(self, required=True, allow_none=False): + self._registry = TypeRegistry() + self._registry.register(List, self._list_converter) + self._registry.register(list, self._list_converter) + + self._required = required + self._allow_none = allow_none + + def _list_converter(self, subtypes: Tuple[type], **opts) -> FieldABC: + return fields.List(subtypes[0], **opts) + + def convert(self, value, **kwargs) -> FieldABC: + allow_none = self._allow_none + required = self._required + example = value + + # set this after optional check + if isinstance(value, (List, Tuple)) or type(value) is type(Union): + subtypes = [self.convert(e) for e in value] + else: + subtypes = [] + + typehint = type(value) + + kwargs.setdefault("allow_none", allow_none) + kwargs.setdefault("required", required) + kwargs.setdefault("example", example) + + field_constructor = self._registry.get(typehint) + return field_constructor(subtypes, **kwargs) + + +def data_dict_to_schema(data_dict: dict): + """Attempt to create a Marshmallow schema from a dictionary of data + + Args: + data_dict (dict): Dictionary of data + + Returns: + dict: Dictionary of Marshmallow fields matching input data types + """ + converter = PropertyConverter(required=False) + + working_dict = copy.deepcopy(data_dict) + + working_dict = rapply(working_dict, make_primitive) + working_dict = rapply(working_dict, converter.convert, apply_to_iterables=False) + + return working_dict + + +def value_to_field(value): + converter = PropertyConverter() + + return converter.convert(value) diff --git a/labthings/server/types/registry.py b/labthings/server/types/registry.py new file mode 100644 index 00000000..9699e591 --- /dev/null +++ b/labthings/server/types/registry.py @@ -0,0 +1,77 @@ +from labthings.server import fields + +from marshmallow.base import FieldABC +from datetime import date, datetime, time, timedelta +from decimal import Decimal +from typing import Dict, Tuple, List +from uuid import UUID +from inspect import _empty + + +PRIMITIVE_TYPES = [ + bool, + date, + datetime, + Decimal, + float, + int, + str, + time, + timedelta, + UUID, + dict, + Dict, + List, + list, +] + + +def _field_factory(field: FieldABC): + """ + Maps a marshmallow field into a field factory + """ + + def _(subtypes: Tuple[type], **opts) -> FieldABC: + return field(**opts) + + _.__name__ = f"{field.__name__}FieldFactory" + return _ + + +class TypeRegistry: + """ + Default implementation of :class:`~marshmallow_annotations.base.TypeRegistry`. + """ + + def __init__(self) -> None: + self._registry = { + k: _field_factory(v) + for k, v in { + bool: fields.Boolean, + date: fields.Date, + datetime: fields.DateTime, + Decimal: fields.Decimal, + float: fields.Float, + int: fields.Integer, + str: fields.String, + time: fields.Time, + timedelta: fields.TimeDelta, + UUID: fields.UUID, + dict: fields.Dict, + Dict: fields.Dict, + _empty: fields.Field, + }.items() + } + + def register(self, target: type, constructor) -> None: + self._registry[target] = constructor + + def get(self, target: type): + converter = self._registry.get(target) + + if converter is None: + raise TypeError(f"No field factory found for {target!r}") + return converter + + def has(self, target: type) -> bool: + return target in self._registry diff --git a/labthings/server/utilities.py b/labthings/server/utilities.py index cf6dabe2..076c441d 100644 --- a/labthings/server/utilities.py +++ b/labthings/server/utilities.py @@ -1,9 +1,21 @@ -from ..core.utilities import get_summary +from labthings.core.utilities import get_summary from werkzeug.http import HTTP_STATUS_CODES from flask import current_app +http_method_funcs = [ + "get", + "post", + "put", + "delete", + "patch", + "head", + "options", + "trace", +] + + def http_status_message(code): """Maps an HTTP status code to the textual status""" return HTTP_STATUS_CODES.get(code, "") @@ -21,7 +33,7 @@ def description_from_view(view_class): summary = get_summary(view_class) methods = [] - for method_key in view_class.methods: + for method_key in http_method_funcs: if hasattr(view_class, method_key): methods.append(method_key.upper()) @@ -43,7 +55,7 @@ def view_class_from_endpoint(endpoint: str): Returns: View: View class attached to the specified endpoint """ - return current_app.view_functions[endpoint].view_class + return getattr(current_app.view_functions.get(endpoint), "view_class", None) def unpack(value): @@ -64,3 +76,12 @@ def unpack(value): pass return value, 200, {} + + +def clean_url_string(url: str): + if not url: + return "/" + if url[0] != "/": + return "/" + url + else: + return url diff --git a/labthings/server/view/__init__.py b/labthings/server/view/__init__.py index 72a177d2..b2d550a6 100644 --- a/labthings/server/view/__init__.py +++ b/labthings/server/view/__init__.py @@ -1,5 +1,5 @@ from flask.views import MethodView -from flask import request +from flask import request, make_response from werkzeug.wrappers import Response as ResponseBase from werkzeug.exceptions import MethodNotAllowed @@ -17,7 +17,6 @@ class View(MethodView): These functions will allow for automated documentation generation """ - methods = ["get", "post", "put", "delete"] endpoint = None def __init__(self, *args, **kwargs): @@ -29,8 +28,11 @@ def __init__(self, *args, **kwargs): def get_value(self): get_method = getattr(self, "get", None) # Look for this views GET method - if callable(get_method): # Check it's callable - response = get_method() # pylint: disable=not-callable + if get_method is None: + return None + if not callable(get_method): + raise TypeError("Attribute 'get' of View must be a callable") + response = get_method() # pylint: disable=not-callable if isinstance(response, ResponseBase): # Pluck useful data out of HTTP response return response.json if response.json else response.data.decode() else: # Unless somehow an HTTP response isn't returned... @@ -44,8 +46,8 @@ def dispatch_request(self, *args, **kwargs): if meth is None and request.method == "HEAD": meth = getattr(self, "get", None) - if meth is None: - raise MethodNotAllowed(f"Unimplemented method {request.method}") + # Flask should ensure this is assersion never fails + assert meth is not None, f"Unimplemented method {request.method!r}" # Generate basic response resp = meth(*args, **kwargs) diff --git a/labthings/server/view/builder.py b/labthings/server/view/builder.py index 01927719..2320e5cc 100644 --- a/labthings/server/view/builder.py +++ b/labthings/server/view/builder.py @@ -1,5 +1,19 @@ -from labthings.server.types import value_to_field, data_dict_to_schema -from labthings.server.decorators import ThingProperty, PropertySchema, Doc +from labthings.core.tasks import taskify +from labthings.server.types import ( + value_to_field, + data_dict_to_schema, + function_signature_to_schema, +) +from labthings.server.decorators import ( + ThingProperty, + PropertySchema, + ThingAction, + marshal_task, + use_args, + Doc, + Safe, + Idempotent, +) from . import View from flask import send_from_directory, abort @@ -40,9 +54,11 @@ def _put(self, args): # Override read-write capabilities if not readonly: generated_class.post = _post + generated_class.methods.add("POST") # Enable PUT requests for dictionaries if type(getattr(property_object, property_name)) == dict: generated_class.put = _put + generated_class.methods.add("PUT") # Add decorators for arguments etc initial_property_value = getattr(property_object, property_name) @@ -62,6 +78,56 @@ def _put(self, args): return generated_class +def action_from( + function, + name: str = None, + description=None, + task=False, + safe=False, + idempotent=False, +): + + # Create a class name + if not name: + name = f"Action_{function.__name__}" + + # Create schema + action_schema = function_signature_to_schema(function) + + # Handle taskification + if task: + function = taskify(function) + + # Create inner functions + def _post(self, args): + return function(**args) + + # Generate a basic property class + generated_class = type(name, (View, object), {"post": _post}) + + # Add decorators for arguments etc + + generated_class.post = use_args(action_schema)(generated_class.post) + + if task: + generated_class.post = marshal_task(generated_class.post) + + generated_class = ThingAction(generated_class) + + if description: + generated_class = Doc(description=description, summary=description)( + generated_class + ) + + if safe: + generated_class = Safe(generated_class) + + if idempotent: + generated_class = Idempotent(generated_class) + + return generated_class + + def static_from(static_folder: str, name=None): # Create a class name @@ -74,9 +140,6 @@ def _get(self, path): return send_from_directory(static_folder, path) # Generate a basic property class - generated_class = type(name, (View, object), {},) - - if static_folder: - generated_class.get = _get + generated_class = type(name, (View, object), {"get": _get}) return generated_class diff --git a/labthings/server/wsgi/__init__.py b/labthings/server/wsgi/__init__.py index 8be7071e..56326f3f 100644 --- a/labthings/server/wsgi/__init__.py +++ b/labthings/server/wsgi/__init__.py @@ -1 +1,15 @@ from .gevent import Server + +""" +NOTE: THIS FILE IS EXCLUDED FROM OUR UNIT TESTS. +STARTING A WSGI SERVER IN A CI IS A BIT SKETCHY AS IT +MAKES ASSUMPTIONS ABOUT THE STATE OF THE HOST SYSTEM. + +FOR EXAMPLE, PORT CLASHES COULD CAUSE THE TEST TO FAIL +DESPITE THE CODE ITSELF BEING TOTALLY FINE. + +THIS DOES MEAN THAT EXTRA CARE SHOULD BE TAKEN WHEN +DEVELOPING THIS SECTION OF THE CODE. + +THANKS +""" diff --git a/labthings/server/wsgi/gevent.py b/labthings/server/wsgi/gevent.py index e0c4aa02..adf5edf1 100644 --- a/labthings/server/wsgi/gevent.py +++ b/labthings/server/wsgi/gevent.py @@ -13,34 +13,34 @@ class Server: - def __init__(self, app): + def __init__( + self, app, host="0.0.0.0", port=5000, log=None, debug=False, zeroconf=True + ): self.app = app # Find LabThing attached to app self.labthing = current_labthing(app) - def run( - self, - host="0.0.0.0", - port=5000, - log=None, - debug=False, - stop_timeout=1, - zeroconf=True, - ): - # Type checks - port = int(port) - host = str(host) + # Server properties + self.host = host + self.port = port + self.log = log + self.debug = debug + self.zeroconf = zeroconf - # Unmodified version of app - app_to_run = self.app + # Servers + self.wsgi_server = None + self.zeroconf_server = None + self.service_info = None - # Handle zeroconf - zeroconf_server = None - if zeroconf and self.labthing: - service_info = ServiceInfo( + # Events + self.started_event = gevent.event.Event() + + def register_zeroconf(self): + if self.labthing: + self.service_info = ServiceInfo( "_labthings._tcp.local.", - f"{self.labthing.title}._labthings._tcp.local.", - port=port, + f"{self.labthing.safe_title}._labthings._tcp.local.", + port=self.port, properties={ "path": self.labthing.url_prefix, "title": self.labthing.title, @@ -55,43 +55,94 @@ def run( ] ), ) - zeroconf_server = Zeroconf(ip_version=IPVersion.V4Only) - zeroconf_server.register_service(service_info) + self.zeroconf_server = Zeroconf(ip_version=IPVersion.V4Only) + self.zeroconf_server.register_service(self.service_info) + + def stop(self, timeout=1): + # Unregister zeroconf service + if self.zeroconf_server: + self.zeroconf_server.unregister_service(self.service_info) + self.zeroconf_server.close() + self.zeroconf_server = None + # Stop WSGI server with timeout + if self.wsgi_server: + self.wsgi_server.stop(timeout=timeout) + self.wsgi_server = None + # Clear started event + if self.started_event.is_set(): + self.started_event.clear() + + def start(self): + # Unmodified version of app + app_to_run = self.app + + # Handle zeroconf + if self.zeroconf: + self.register_zeroconf() # Handle logging - if not log: - log = logging.getLogger() + if not self.log: + self.log = logging.getLogger() # Handle debug mode - if debug: - log.setLevel(logging.DEBUG) + if self.debug: + self.log.setLevel(logging.DEBUG) app_to_run = DebuggedApplication(self.app) logging.getLogger("zeroconf").setLevel(logging.DEBUG) # Slightly more useful logger output - friendlyhost = "localhost" if host == "0.0.0.0" else host - logging.info("Starting LabThings WSGI Server") - logging.info(f"Debug mode: {debug}") - logging.info(f"Running on http://{friendlyhost}:{port} (Press CTRL+C to quit)") + friendlyhost = "localhost" if self.host == "0.0.0.0" else self.host + print("Starting LabThings WSGI Server") + print(f"Debug mode: {self.debug}") + print(f"Running on http://{friendlyhost}:{self.port} (Press CTRL+C to quit)") # Create WSGIServer - wsgi_server = gevent.pywsgi.WSGIServer( - (host, port), app_to_run, handler_class=WebSocketHandler, log=log + self.wsgi_server = gevent.pywsgi.WSGIServer( + (self.host, self.port), + app_to_run, + handler_class=WebSocketHandler, + log=self.log, ) - def stop(): - # Unregister zeroconf service - if zeroconf_server: - zeroconf_server.unregister_service(service_info) - zeroconf_server.close() - # Stop WSGI server with timeout - wsgi_server.stop(timeout=stop_timeout) - # Serve - gevent.signal(signal.SIGTERM, stop) + signal.signal(signal.SIGTERM, self.stop) + # Set started event + self.started_event.set() try: - wsgi_server.serve_forever() - except (KeyboardInterrupt, SystemExit): - logging.warning("Terminating by KeyboardInterrupt or SystemExit") - stop() + self.wsgi_server.serve_forever() + except (KeyboardInterrupt, SystemExit): # pragma: no cover + logging.warning( + "Terminating by KeyboardInterrupt or SystemExit" + ) # pragma: no cover + self.stop() # pragma: no cover + + def run( + self, host=None, port=None, log=None, debug=None, zeroconf=None, + ): + """Starts the server allowing for runtime parameters. Designed to immitate + the old Flask app.run style of starting an app + + Args: + host (string, optional): Host IP address. Defaults to None. + port (int, optional): Host port. Defaults to None. + log (optional): Logger to log to. Defaults to None. + debug (bool, optional): Enable server debug mode. Defaults to None. + zeroconf (bool, optional): Enable the zeroconf server. Defaults to None. + """ + if port is not None: + self.port = int(port) + + if host is not None: + self.host = str(host) + + if log is not None: + self.log = log + + if debug is not None: + self.debug = debug + + if zeroconf is not None: + self.zeroconf = zeroconf + + self.start() diff --git a/poetry.lock b/poetry.lock index 951a6aad..29ad3f34 100644 --- a/poetry.lock +++ b/poetry.lock @@ -68,7 +68,7 @@ d = ["aiohttp (>=3.3.2)", "aiohttp-cors"] [[package]] category = "main" description = "Foreign Function Interface for Python calling C code." -marker = "sys_platform == \"win32\" and platform_python_implementation == \"CPython\"" +marker = "platform_python_implementation == \"CPython\" and sys_platform == \"win32\"" name = "cffi" optional = false python-versions = "*" @@ -94,13 +94,24 @@ optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" version = "0.4.3" +[[package]] +category = "dev" +description = "Code coverage measurement for Python" +name = "coverage" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" +version = "5.1" + +[package.extras] +toml = ["toml"] + [[package]] category = "main" description = "A simple framework for building complex web applications." name = "flask" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "1.1.1" +version = "1.1.2" [package.dependencies] Jinja2 = ">=2.10.1" @@ -130,18 +141,20 @@ 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" +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*" +version = "1.5.0" [package.dependencies] -cffi = ">=1.11.5" +cffi = ">=1.12.2" greenlet = ">=0.4.14" [package.extras] -dnspython = ["dnspython", "idna"] -doc = ["repoze.sphinx.autointerface"] +dnspython = ["dnspython (>=1.16.0)", "idna"] +docs = ["repoze.sphinx.autointerface", "sphinxcontrib-programoutput"] events = ["zope.event", "zope.interface"] -test = ["zope.interface", "zope.event", "requests", "objgraph", "psutil", "futures", "mock", "coverage (>=5.0a3)", "coveralls (>=1.0)"] +monitor = ["psutil (>=5.6.1)", "psutil (5.6.3)"] +recommended = ["dnspython (>=1.16.0)", "idna", "zope.event", "zope.interface", "cffi (>=1.12.2)", "psutil (>=5.6.1)", "psutil (5.6.3)"] +test = ["dnspython (>=1.16.0)", "idna", "zope.event", "zope.interface", "requests", "objgraph", "cffi (>=1.12.2)", "psutil (>=5.6.1)", "psutil (5.6.3)", "futures", "mock", "coverage (<5.0)", "coveralls (>=1.7.0)"] [[package]] category = "main" @@ -178,7 +191,7 @@ marker = "python_version < \"3.8\"" name = "importlib-metadata" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" -version = "1.5.0" +version = "1.6.0" [package.dependencies] zipp = ">=0.5" @@ -201,7 +214,7 @@ description = "A very fast and expressive template engine." name = "jinja2" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "2.11.1" +version = "2.11.2" [package.dependencies] MarkupSafe = ">=0.23" @@ -209,6 +222,28 @@ MarkupSafe = ">=0.23" [package.extras] i18n = ["Babel (>=0.8)"] +[[package]] +category = "dev" +description = "An implementation of JSON Schema validation for Python" +name = "jsonschema" +optional = false +python-versions = "*" +version = "3.2.0" + +[package.dependencies] +attrs = ">=17.4.0" +pyrsistent = ">=0.14.0" +setuptools = "*" +six = ">=1.11.0" + +[package.dependencies.importlib-metadata] +python = "<3.8" +version = "*" + +[package.extras] +format = ["idna", "jsonpointer (>1.13)", "rfc3987", "strict-rfc3339", "webcolors"] +format_nongpl = ["idna", "jsonpointer (>1.13)", "webcolors", "rfc3986-validator (>0.1.0)", "rfc3339-validator"] + [[package]] category = "main" description = "Safely add untrusted strings to HTML/XML markup." @@ -239,6 +274,14 @@ optional = false python-versions = ">=3.5" version = "8.2.0" +[[package]] +category = "dev" +description = "NumPy is the fundamental package for array computing with Python." +name = "numpy" +optional = false +python-versions = ">=3.5" +version = "1.18.2" + [[package]] category = "dev" description = "Core utilities for Python packages" @@ -257,7 +300,7 @@ description = "Utility library for gitignore style pattern matching of file path name = "pathspec" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "0.7.0" +version = "0.8.0" [[package]] category = "dev" @@ -286,7 +329,7 @@ version = "1.8.1" [[package]] category = "main" description = "C parser in Python" -marker = "sys_platform == \"win32\" and platform_python_implementation == \"CPython\"" +marker = "platform_python_implementation == \"CPython\" and sys_platform == \"win32\"" name = "pycparser" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" @@ -298,7 +341,18 @@ description = "Python parsing module" name = "pyparsing" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" -version = "2.4.6" +version = "2.4.7" + +[[package]] +category = "dev" +description = "Persistent/Functional/Immutable data structures" +name = "pyrsistent" +optional = false +python-versions = "*" +version = "0.16.0" + +[package.dependencies] +six = "*" [[package]] category = "dev" @@ -306,7 +360,7 @@ description = "pytest: simple powerful testing with Python" name = "pytest" optional = false python-versions = ">=3.5" -version = "5.3.5" +version = "5.4.1" [package.dependencies] atomicwrites = ">=1.0" @@ -328,19 +382,26 @@ testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xm [[package]] category = "dev" -description = "Alternative regular expression module, to replace re." -name = "regex" +description = "Pytest plugin for measuring coverage." +name = "pytest-cov" optional = false -python-versions = "*" -version = "2020.2.20" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "2.8.1" + +[package.dependencies] +coverage = ">=4.4" +pytest = ">=3.6" + +[package.extras] +testing = ["fields", "hunter", "process-tests (2.0.2)", "six", "virtualenv"] [[package]] -category = "main" -description = "Simple, fast, extensible JSON encoder/decoder for Python" -name = "simplejson" +category = "dev" +description = "Alternative regular expression module, to replace re." +name = "regex" optional = false -python-versions = ">=2.5, !=3.0.*, !=3.1.*, !=3.2.*" -version = "3.17.0" +python-versions = "*" +version = "2020.4.4" [[package]] category = "main" @@ -372,26 +433,25 @@ description = "Measures number of Terminal column cells of wide-character codes" name = "wcwidth" optional = false python-versions = "*" -version = "0.1.8" +version = "0.1.9" [[package]] category = "main" description = "Declarative parsing and validation of HTTP request objects, with built-in support for popular web frameworks, including Flask, Django, Bottle, Tornado, Pyramid, webapp2, Falcon, and aiohttp." name = "webargs" optional = false -python-versions = "*" -version = "5.5.3" +python-versions = ">=3.5" +version = "6.0.0" [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)"] -docs = ["Sphinx (2.2.0)", "sphinx-issues (1.2.0)", "sphinx-typlog-theme (0.7.3)", "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)", "aiohttp (>=3.0.0)"] -frameworks = ["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)", "aiohttp (>=3.0.0)"] -lint = ["flake8 (3.7.8)", "pre-commit (>=1.17,<2.0)", "mypy (0.730)", "flake8-bugbear (19.8.0)"] -tests = ["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)", "webtest-aiohttp (2.0.0)", "pytest-aiohttp (>=0.3.0)", "aiohttp (>=3.0.0)"] +dev = ["pytest", "webtest (2.0.34)", "webtest-aiohttp (2.0.0)", "pytest-aiohttp (>=0.3.0)", "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 (>=2.0.0)", "aiohttp (>=3.0.0)", "mypy (0.761)", "flake8 (3.7.9)", "flake8-bugbear (20.1.4)", "pre-commit (>=1.20,<3.0)", "tox", "mock"] +docs = ["Sphinx (2.4.3)", "sphinx-issues (1.2.0)", "sphinx-typlog-theme (0.8.0)", "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 (>=2.0.0)", "aiohttp (>=3.0.0)"] +frameworks = ["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 (>=2.0.0)", "aiohttp (>=3.0.0)"] +lint = ["mypy (0.761)", "flake8 (3.7.9)", "flake8-bugbear (20.1.4)", "pre-commit (>=1.20,<3.0)"] +tests = ["pytest", "webtest (2.0.34)", "webtest-aiohttp (2.0.0)", "pytest-aiohttp (>=0.3.0)", "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 (>=2.0.0)", "aiohttp (>=3.0.0)", "mock"] [[package]] category = "main" @@ -399,10 +459,10 @@ description = "The comprehensive WSGI web application library." name = "werkzeug" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "1.0.0" +version = "1.0.1" [package.extras] -dev = ["pytest", "coverage", "tox", "sphinx", "pallets-sphinx-themes", "sphinx-issues"] +dev = ["pytest", "pytest-timeout", "coverage", "tox", "sphinx", "pallets-sphinx-themes", "sphinx-issues"] watchdog = ["watchdog"] [[package]] @@ -411,7 +471,7 @@ description = "Pure Python Multicast DNS Service Discovery Library (Bonjour/Avah name = "zeroconf" optional = false python-versions = "*" -version = "0.24.5" +version = "0.25.1" [package.dependencies] ifaddr = "*" @@ -430,7 +490,7 @@ docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] testing = ["jaraco.itertools", "func-timeout"] [metadata] -content-hash = "cd172c72eed4e2c7386562fdae39220a65aa224edc1325487815d1bbb2e30010" +content-hash = "a818f232a1be67ea77348f1a9ed04c9ef79fcf87f123d850250f637c530d2423" python-versions = "^3.6" [metadata.files] @@ -492,38 +552,71 @@ colorama = [ {file = "colorama-0.4.3-py2.py3-none-any.whl", hash = "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff"}, {file = "colorama-0.4.3.tar.gz", hash = "sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1"}, ] +coverage = [ + {file = "coverage-5.1-cp27-cp27m-macosx_10_12_x86_64.whl", hash = "sha256:0cb4be7e784dcdc050fc58ef05b71aa8e89b7e6636b99967fadbdba694cf2b65"}, + {file = "coverage-5.1-cp27-cp27m-macosx_10_13_intel.whl", hash = "sha256:c317eaf5ff46a34305b202e73404f55f7389ef834b8dbf4da09b9b9b37f76dd2"}, + {file = "coverage-5.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:b83835506dfc185a319031cf853fa4bb1b3974b1f913f5bb1a0f3d98bdcded04"}, + {file = "coverage-5.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:5f2294dbf7875b991c381e3d5af2bcc3494d836affa52b809c91697449d0eda6"}, + {file = "coverage-5.1-cp27-cp27m-win32.whl", hash = "sha256:de807ae933cfb7f0c7d9d981a053772452217df2bf38e7e6267c9cbf9545a796"}, + {file = "coverage-5.1-cp27-cp27m-win_amd64.whl", hash = "sha256:bf9cb9a9fd8891e7efd2d44deb24b86d647394b9705b744ff6f8261e6f29a730"}, + {file = "coverage-5.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:acf3763ed01af8410fc36afea23707d4ea58ba7e86a8ee915dfb9ceff9ef69d0"}, + {file = "coverage-5.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:dec5202bfe6f672d4511086e125db035a52b00f1648d6407cc8e526912c0353a"}, + {file = "coverage-5.1-cp35-cp35m-macosx_10_12_x86_64.whl", hash = "sha256:7a5bdad4edec57b5fb8dae7d3ee58622d626fd3a0be0dfceda162a7035885ecf"}, + {file = "coverage-5.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:1601e480b9b99697a570cea7ef749e88123c04b92d84cedaa01e117436b4a0a9"}, + {file = "coverage-5.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:dbe8c6ae7534b5b024296464f387d57c13caa942f6d8e6e0346f27e509f0f768"}, + {file = "coverage-5.1-cp35-cp35m-win32.whl", hash = "sha256:a027ef0492ede1e03a8054e3c37b8def89a1e3c471482e9f046906ba4f2aafd2"}, + {file = "coverage-5.1-cp35-cp35m-win_amd64.whl", hash = "sha256:0e61d9803d5851849c24f78227939c701ced6704f337cad0a91e0972c51c1ee7"}, + {file = "coverage-5.1-cp36-cp36m-macosx_10_13_x86_64.whl", hash = "sha256:2d27a3f742c98e5c6b461ee6ef7287400a1956c11421eb574d843d9ec1f772f0"}, + {file = "coverage-5.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:66460ab1599d3cf894bb6baee8c684788819b71a5dc1e8fa2ecc152e5d752019"}, + {file = "coverage-5.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:5c542d1e62eece33c306d66fe0a5c4f7f7b3c08fecc46ead86d7916684b36d6c"}, + {file = "coverage-5.1-cp36-cp36m-win32.whl", hash = "sha256:2742c7515b9eb368718cd091bad1a1b44135cc72468c731302b3d641895b83d1"}, + {file = "coverage-5.1-cp36-cp36m-win_amd64.whl", hash = "sha256:dead2ddede4c7ba6cb3a721870f5141c97dc7d85a079edb4bd8d88c3ad5b20c7"}, + {file = "coverage-5.1-cp37-cp37m-macosx_10_13_x86_64.whl", hash = "sha256:01333e1bd22c59713ba8a79f088b3955946e293114479bbfc2e37d522be03355"}, + {file = "coverage-5.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:e1ea316102ea1e1770724db01998d1603ed921c54a86a2efcb03428d5417e489"}, + {file = "coverage-5.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:adeb4c5b608574a3d647011af36f7586811a2c1197c861aedb548dd2453b41cd"}, + {file = "coverage-5.1-cp37-cp37m-win32.whl", hash = "sha256:782caea581a6e9ff75eccda79287daefd1d2631cc09d642b6ee2d6da21fc0a4e"}, + {file = "coverage-5.1-cp37-cp37m-win_amd64.whl", hash = "sha256:00f1d23f4336efc3b311ed0d807feb45098fc86dee1ca13b3d6768cdab187c8a"}, + {file = "coverage-5.1-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:402e1744733df483b93abbf209283898e9f0d67470707e3c7516d84f48524f55"}, + {file = "coverage-5.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:a3f3654d5734a3ece152636aad89f58afc9213c6520062db3978239db122f03c"}, + {file = "coverage-5.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:6402bd2fdedabbdb63a316308142597534ea8e1895f4e7d8bf7476c5e8751fef"}, + {file = "coverage-5.1-cp38-cp38-win32.whl", hash = "sha256:8fa0cbc7ecad630e5b0f4f35b0f6ad419246b02bc750de7ac66db92667996d24"}, + {file = "coverage-5.1-cp38-cp38-win_amd64.whl", hash = "sha256:79a3cfd6346ce6c13145731d39db47b7a7b859c0272f02cdb89a3bdcbae233a0"}, + {file = "coverage-5.1-cp39-cp39-win32.whl", hash = "sha256:a82b92b04a23d3c8a581fc049228bafde988abacba397d57ce95fe95e0338ab4"}, + {file = "coverage-5.1-cp39-cp39-win_amd64.whl", hash = "sha256:bb28a7245de68bf29f6fb199545d072d1036a1917dca17a1e75bbb919e14ee8e"}, + {file = "coverage-5.1.tar.gz", hash = "sha256:f90bfc4ad18450c80b024036eaf91e4a246ae287701aaa88eaebebf150868052"}, +] flask = [ - {file = "Flask-1.1.1-py2.py3-none-any.whl", hash = "sha256:45eb5a6fd193d6cf7e0cf5d8a5b31f83d5faae0293695626f539a823e93b13f6"}, - {file = "Flask-1.1.1.tar.gz", hash = "sha256:13f9f196f330c7c2c5d7a5cf91af894110ca0215ac051b5844701f2bfd934d52"}, + {file = "Flask-1.1.2-py2.py3-none-any.whl", hash = "sha256:8a4fdd8936eba2512e9c85df320a37e694c93945b33ef33c89946a340a238557"}, + {file = "Flask-1.1.2.tar.gz", hash = "sha256:4efa1ae2d7c9865af48986de8aeb8504bf32c7f3d6fdc9353d34b21f4b127060"}, ] 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"}, + {file = "gevent-1.5.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:3c9229e4eac2df1ce2b097996d3ee318ea90eb11d9e4d7cb14558cbcf02b2262"}, + {file = "gevent-1.5.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:b34b42e86b764a9e948991af5fc43f6d39ee0148a8502ad4d9267ec1401e5401"}, + {file = "gevent-1.5.0-cp27-cp27m-win32.whl", hash = "sha256:608b13b4e2fa462175a53f61c907c24a179abb4d7902f25709a0f908105c22db"}, + {file = "gevent-1.5.0-cp27-cp27m-win_amd64.whl", hash = "sha256:4c6103fa852c352b4f906ea07008fabc06a1f5d2f2209b2f8fbae41227f80a79"}, + {file = "gevent-1.5.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:8753de5a3501093508e6f89c347f37a847d7acf541ff28c977bbbedc2e917c13"}, + {file = "gevent-1.5.0-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:975047b90345f7d811977fb859a1455bd9768d584f32c23a06a4821dd9735d1c"}, + {file = "gevent-1.5.0-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:b94f8f25c6f6ddf9ee3266db9113928c1eca9b01378f8376928620243ee66358"}, + {file = "gevent-1.5.0-cp35-cp35m-win32.whl", hash = "sha256:cae2bffbda0f1641db20055506105d7c209f79ace0a32134359b3c65a0e9b02f"}, + {file = "gevent-1.5.0-cp35-cp35m-win_amd64.whl", hash = "sha256:ce7c562d02ad6c351799f4c8bf81207056118b01e04908de7aca49580f7f1ead"}, + {file = "gevent-1.5.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:7593740e5faeb17d5c5a79e6f80c11a618cf5d250b93df1eafa38324ff275676"}, + {file = "gevent-1.5.0-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:d3c93c39d4a23979d199741fc5610e3f75fc6fcc15f779dd2469e343368a5794"}, + {file = "gevent-1.5.0-cp36-cp36m-win32.whl", hash = "sha256:75dd068dfa83865f4a51121068b1644be9d61921fe1f5b79cf14cc86729f79b7"}, + {file = "gevent-1.5.0-cp36-cp36m-win_amd64.whl", hash = "sha256:82bd100f70699809be1848c0a04bed86bd817b0f79f67d7340205d23badc7096"}, + {file = "gevent-1.5.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c5972a6e8ef5b4ed06c719ab9ea40f76b35e399f76111621009cb8b2a5a20b9c"}, + {file = "gevent-1.5.0-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:25a094ecdc4f503e81b81b94e654a1a2343bfecafedf7b481e5aa6b0adb84206"}, + {file = "gevent-1.5.0-cp37-cp37m-win32.whl", hash = "sha256:f0fda50447a6f6f50ddc9b865ce7fc3d3389694b3a0648f059f7f5b639fc33d3"}, + {file = "gevent-1.5.0-cp37-cp37m-win_amd64.whl", hash = "sha256:33c08d6b4a906169727dc1b9dc709e40f8abd0a966d310bceabc790acd950a56"}, + {file = "gevent-1.5.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c182733b7445074f11cd2ccb9b6c19f6407167d551089b24db6c6823224e085f"}, + {file = "gevent-1.5.0-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:2f33b4f2d55b562d839e93e2355d7f9a6947a9c68e3044eab17a086a725601e6"}, + {file = "gevent-1.5.0-cp38-cp38-win32.whl", hash = "sha256:0eab938d65485b900b4f716a099a59459fc7e8b53b8af75bf6267a12f9830a66"}, + {file = "gevent-1.5.0-cp38-cp38-win_amd64.whl", hash = "sha256:45a5af965cc969dd06128740f5999b9bdb440cb0ba4e9c066e5c17a2c33c89a8"}, + {file = "gevent-1.5.0-pp27-pypy_73-macosx_10_7_x86_64.whl", hash = "sha256:03385b7d2da0e3d3a7682d85a5f19356f7caa861787363fe12edd1d52227163f"}, + {file = "gevent-1.5.0.tar.gz", hash = "sha256:b2814258e3b3fb32786bb73af271ad31f51e1ac01f33b37426b66cb8491b4c29"}, ] gevent-websocket = [ {file = "gevent-websocket-0.10.1.tar.gz", hash = "sha256:7eaef32968290c9121f7c35b973e2cc302ffb076d018c9068d2f5ca8b2d85fb0"}, @@ -557,16 +650,20 @@ ifaddr = [ {file = "ifaddr-0.1.6.tar.gz", hash = "sha256:c19c64882a7ad51a394451dabcbbed72e98b5625ec1e79789924d5ea3e3ecb93"}, ] importlib-metadata = [ - {file = "importlib_metadata-1.5.0-py2.py3-none-any.whl", hash = "sha256:b97607a1a18a5100839aec1dc26a1ea17ee0d93b20b0f008d80a5a050afb200b"}, - {file = "importlib_metadata-1.5.0.tar.gz", hash = "sha256:06f5b3a99029c7134207dd882428a66992a9de2bef7c2b699b5641f9886c3302"}, + {file = "importlib_metadata-1.6.0-py2.py3-none-any.whl", hash = "sha256:2a688cbaa90e0cc587f1df48bdc97a6eadccdcd9c35fb3f976a09e3b5016d90f"}, + {file = "importlib_metadata-1.6.0.tar.gz", hash = "sha256:34513a8a0c4962bc66d35b359558fd8a5e10cd472d37aec5f66858addef32c1e"}, ] itsdangerous = [ {file = "itsdangerous-1.1.0-py2.py3-none-any.whl", hash = "sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749"}, {file = "itsdangerous-1.1.0.tar.gz", hash = "sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19"}, ] jinja2 = [ - {file = "Jinja2-2.11.1-py2.py3-none-any.whl", hash = "sha256:b0eaf100007721b5c16c1fc1eecb87409464edc10469ddc9a22a27a99123be49"}, - {file = "Jinja2-2.11.1.tar.gz", hash = "sha256:93187ffbc7808079673ef52771baa950426fd664d3aad1d0fa3e95644360e250"}, + {file = "Jinja2-2.11.2-py2.py3-none-any.whl", hash = "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035"}, + {file = "Jinja2-2.11.2.tar.gz", hash = "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0"}, +] +jsonschema = [ + {file = "jsonschema-3.2.0-py2.py3-none-any.whl", hash = "sha256:4e5b3cf8216f577bee9ce139cbe72eca3ea4f292ec60928ff24758ce626cd163"}, + {file = "jsonschema-3.2.0.tar.gz", hash = "sha256:c8a85b28d377cc7737e46e2d9f2b4f44ee3c0e1deac6bf46ddefc7187d30797a"}, ] markupsafe = [ {file = "MarkupSafe-1.1.1-cp27-cp27m-macosx_10_6_intel.whl", hash = "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161"}, @@ -606,13 +703,36 @@ more-itertools = [ {file = "more-itertools-8.2.0.tar.gz", hash = "sha256:b1ddb932186d8a6ac451e1d95844b382f55e12686d51ca0c68b6f61f2ab7a507"}, {file = "more_itertools-8.2.0-py3-none-any.whl", hash = "sha256:5dd8bcf33e5f9513ffa06d5ad33d78f31e1931ac9a18f33d37e77a180d393a7c"}, ] +numpy = [ + {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.3-py2.py3-none-any.whl", hash = "sha256:82f77b9bee21c1bafbf35a84905d604d5d1223801d639cf3ed140bd651c08752"}, {file = "packaging-20.3.tar.gz", hash = "sha256:3c292b474fda1671ec57d46d739d072bfd495a4f51ad01a055121d81e952b7a3"}, ] pathspec = [ - {file = "pathspec-0.7.0-py2.py3-none-any.whl", hash = "sha256:163b0632d4e31cef212976cf57b43d9fd6b0bac6e67c26015d611a647d5e7424"}, - {file = "pathspec-0.7.0.tar.gz", hash = "sha256:562aa70af2e0d434367d9790ad37aed893de47f1693e4201fd1d3dca15d19b96"}, + {file = "pathspec-0.8.0-py2.py3-none-any.whl", hash = "sha256:7d91249d21749788d07a2d0f94147accd8f845507400749ea19c1ec9054a12b0"}, + {file = "pathspec-0.8.0.tar.gz", hash = "sha256:da45173eb3a6f2a5a487efba21f050af2b41948be6ab52b6a1e3ff22bb8b7061"}, ] pluggy = [ {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, @@ -627,65 +747,42 @@ pycparser = [ {file = "pycparser-2.20.tar.gz", hash = "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0"}, ] pyparsing = [ - {file = "pyparsing-2.4.6-py2.py3-none-any.whl", hash = "sha256:c342dccb5250c08d45fd6f8b4a559613ca603b57498511740e65cd11a2e7dcec"}, - {file = "pyparsing-2.4.6.tar.gz", hash = "sha256:4c830582a84fb022400b85429791bc551f1f4871c33f23e44f353119e92f969f"}, + {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, + {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, +] +pyrsistent = [ + {file = "pyrsistent-0.16.0.tar.gz", hash = "sha256:28669905fe725965daa16184933676547c5bb40a5153055a8dee2a4bd7933ad3"}, ] pytest = [ - {file = "pytest-5.3.5-py3-none-any.whl", hash = "sha256:ff615c761e25eb25df19edddc0b970302d2a9091fbce0e7213298d85fb61fef6"}, - {file = "pytest-5.3.5.tar.gz", hash = "sha256:0d5fe9189a148acc3c3eb2ac8e1ac0742cb7618c084f3d228baaec0c254b318d"}, + {file = "pytest-5.4.1-py3-none-any.whl", hash = "sha256:0e5b30f5cb04e887b91b1ee519fa3d89049595f428c1db76e73bd7f17b09b172"}, + {file = "pytest-5.4.1.tar.gz", hash = "sha256:84dde37075b8805f3d1f392cc47e38a0e59518fb46a431cfdaf7cf1ce805f970"}, +] +pytest-cov = [ + {file = "pytest-cov-2.8.1.tar.gz", hash = "sha256:cc6742d8bac45070217169f5f72ceee1e0e55b0221f54bcf24845972d3a47f2b"}, + {file = "pytest_cov-2.8.1-py2.py3-none-any.whl", hash = "sha256:cdbdef4f870408ebdbfeb44e63e07eb18bb4619fae852f6e760645fa36172626"}, ] regex = [ - {file = "regex-2020.2.20-cp27-cp27m-win32.whl", hash = "sha256:99272d6b6a68c7ae4391908fc15f6b8c9a6c345a46b632d7fdb7ef6c883a2bbb"}, - {file = "regex-2020.2.20-cp27-cp27m-win_amd64.whl", hash = "sha256:974535648f31c2b712a6b2595969f8ab370834080e00ab24e5dbb9d19b8bfb74"}, - {file = "regex-2020.2.20-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:5de40649d4f88a15c9489ed37f88f053c15400257eeb18425ac7ed0a4e119400"}, - {file = "regex-2020.2.20-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:82469a0c1330a4beb3d42568f82dffa32226ced006e0b063719468dcd40ffdf0"}, - {file = "regex-2020.2.20-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:d58a4fa7910102500722defbde6e2816b0372a4fcc85c7e239323767c74f5cbc"}, - {file = "regex-2020.2.20-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:f1ac2dc65105a53c1c2d72b1d3e98c2464a133b4067a51a3d2477b28449709a0"}, - {file = "regex-2020.2.20-cp36-cp36m-win32.whl", hash = "sha256:8c2b7fa4d72781577ac45ab658da44c7518e6d96e2a50d04ecb0fd8f28b21d69"}, - {file = "regex-2020.2.20-cp36-cp36m-win_amd64.whl", hash = "sha256:269f0c5ff23639316b29f31df199f401e4cb87529eafff0c76828071635d417b"}, - {file = "regex-2020.2.20-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:bed7986547ce54d230fd8721aba6fd19459cdc6d315497b98686d0416efaff4e"}, - {file = "regex-2020.2.20-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:046e83a8b160aff37e7034139a336b660b01dbfe58706f9d73f5cdc6b3460242"}, - {file = "regex-2020.2.20-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:b33ebcd0222c1d77e61dbcd04a9fd139359bded86803063d3d2d197b796c63ce"}, - {file = "regex-2020.2.20-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:bba52d72e16a554d1894a0cc74041da50eea99a8483e591a9edf1025a66843ab"}, - {file = "regex-2020.2.20-cp37-cp37m-win32.whl", hash = "sha256:01b2d70cbaed11f72e57c1cfbaca71b02e3b98f739ce33f5f26f71859ad90431"}, - {file = "regex-2020.2.20-cp37-cp37m-win_amd64.whl", hash = "sha256:113309e819634f499d0006f6200700c8209a2a8bf6bd1bdc863a4d9d6776a5d1"}, - {file = "regex-2020.2.20-cp38-cp38-manylinux1_i686.whl", hash = "sha256:25f4ce26b68425b80a233ce7b6218743c71cf7297dbe02feab1d711a2bf90045"}, - {file = "regex-2020.2.20-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:9b64a4cc825ec4df262050c17e18f60252cdd94742b4ba1286bcfe481f1c0f26"}, - {file = "regex-2020.2.20-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:9ff16d994309b26a1cdf666a6309c1ef51ad4f72f99d3392bcd7b7139577a1f2"}, - {file = "regex-2020.2.20-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:c7f58a0e0e13fb44623b65b01052dae8e820ed9b8b654bb6296bc9c41f571b70"}, - {file = "regex-2020.2.20-cp38-cp38-win32.whl", hash = "sha256:200539b5124bc4721247a823a47d116a7a23e62cc6695744e3eb5454a8888e6d"}, - {file = "regex-2020.2.20-cp38-cp38-win_amd64.whl", hash = "sha256:7f78f963e62a61e294adb6ff5db901b629ef78cb2a1cfce3cf4eeba80c1c67aa"}, - {file = "regex-2020.2.20.tar.gz", hash = "sha256:9e9624440d754733eddbcd4614378c18713d2d9d0dc647cf9c72f64e39671be5"}, -] -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"}, + {file = "regex-2020.4.4-cp27-cp27m-win32.whl", hash = "sha256:90742c6ff121a9c5b261b9b215cb476eea97df98ea82037ec8ac95d1be7a034f"}, + {file = "regex-2020.4.4-cp27-cp27m-win_amd64.whl", hash = "sha256:24f4f4062eb16c5bbfff6a22312e8eab92c2c99c51a02e39b4eae54ce8255cd1"}, + {file = "regex-2020.4.4-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:08119f707f0ebf2da60d2f24c2f39ca616277bb67ef6c92b72cbf90cbe3a556b"}, + {file = "regex-2020.4.4-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:c9423a150d3a4fc0f3f2aae897a59919acd293f4cb397429b120a5fcd96ea3db"}, + {file = "regex-2020.4.4-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:c087bff162158536387c53647411db09b6ee3f9603c334c90943e97b1052a156"}, + {file = "regex-2020.4.4-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:1cbe0fa0b7f673400eb29e9ef41d4f53638f65f9a2143854de6b1ce2899185c3"}, + {file = "regex-2020.4.4-cp36-cp36m-win32.whl", hash = "sha256:0ce9537396d8f556bcfc317c65b6a0705320701e5ce511f05fc04421ba05b8a8"}, + {file = "regex-2020.4.4-cp36-cp36m-win_amd64.whl", hash = "sha256:7e1037073b1b7053ee74c3c6c0ada80f3501ec29d5f46e42669378eae6d4405a"}, + {file = "regex-2020.4.4-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:4385f12aa289d79419fede43f979e372f527892ac44a541b5446617e4406c468"}, + {file = "regex-2020.4.4-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:a58dd45cb865be0ce1d5ecc4cfc85cd8c6867bea66733623e54bd95131f473b6"}, + {file = "regex-2020.4.4-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:ccccdd84912875e34c5ad2d06e1989d890d43af6c2242c6fcfa51556997af6cd"}, + {file = "regex-2020.4.4-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:ea4adf02d23b437684cd388d557bf76e3afa72f7fed5bbc013482cc00c816948"}, + {file = "regex-2020.4.4-cp37-cp37m-win32.whl", hash = "sha256:2294f8b70e058a2553cd009df003a20802ef75b3c629506be20687df0908177e"}, + {file = "regex-2020.4.4-cp37-cp37m-win_amd64.whl", hash = "sha256:e91ba11da11cf770f389e47c3f5c30473e6d85e06d7fd9dcba0017d2867aab4a"}, + {file = "regex-2020.4.4-cp38-cp38-manylinux1_i686.whl", hash = "sha256:5635cd1ed0a12b4c42cce18a8d2fb53ff13ff537f09de5fd791e97de27b6400e"}, + {file = "regex-2020.4.4-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:23069d9c07e115537f37270d1d5faea3e0bdded8279081c4d4d607a2ad393683"}, + {file = "regex-2020.4.4-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:c162a21e0da33eb3d31a3ac17a51db5e634fc347f650d271f0305d96601dc15b"}, + {file = "regex-2020.4.4-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:fb95debbd1a824b2c4376932f2216cc186912e389bdb0e27147778cf6acb3f89"}, + {file = "regex-2020.4.4-cp38-cp38-win32.whl", hash = "sha256:2a3bf8b48f8e37c3a40bb3f854bf0121c194e69a650b209628d951190b862de3"}, + {file = "regex-2020.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:5bfed051dbff32fd8945eccca70f5e22b55e4148d2a8a45141a3b053d6455ae3"}, + {file = "regex-2020.4.4.tar.gz", hash = "sha256:295badf61a51add2d428a46b8580309c520d8b26e769868b922750cf3ce67142"}, ] six = [ {file = "six-1.14.0-py2.py3-none-any.whl", hash = "sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c"}, @@ -720,21 +817,20 @@ typed-ast = [ {file = "typed_ast-1.4.1.tar.gz", hash = "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b"}, ] wcwidth = [ - {file = "wcwidth-0.1.8-py2.py3-none-any.whl", hash = "sha256:8fd29383f539be45b20bd4df0dc29c20ba48654a41e661925e612311e9f3c603"}, - {file = "wcwidth-0.1.8.tar.gz", hash = "sha256:f28b3e8a6483e5d49e7f8949ac1a78314e740333ae305b4ba5defd3e74fb37a8"}, + {file = "wcwidth-0.1.9-py2.py3-none-any.whl", hash = "sha256:cafe2186b3c009a04067022ce1dcd79cb38d8d65ee4f4791b8888d6599d1bbe1"}, + {file = "wcwidth-0.1.9.tar.gz", hash = "sha256:ee73862862a156bf77ff92b09034fc4825dd3af9cf81bc5b360668d425f3c5f1"}, ] webargs = [ - {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"}, + {file = "webargs-6.0.0-py2.py3-none-any.whl", hash = "sha256:dfe01cd3711cee9a3651bfd5ca67770ace4a6f35f3cae583aa8400bbf7706bb4"}, + {file = "webargs-6.0.0.tar.gz", hash = "sha256:d66a056b3a40c5a3774a650fd349db5970cd91fa8f04ac50d00582c949b45b26"}, ] werkzeug = [ - {file = "Werkzeug-1.0.0-py2.py3-none-any.whl", hash = "sha256:6dc65cf9091cf750012f56f2cad759fa9e879f511b5ff8685e456b4e3bf90d16"}, - {file = "Werkzeug-1.0.0.tar.gz", hash = "sha256:169ba8a33788476292d04186ab33b01d6add475033dfc07215e6d219cc077096"}, + {file = "Werkzeug-1.0.1-py2.py3-none-any.whl", hash = "sha256:2de2a5db0baeae7b2d2664949077c2ac63fbd16d98da0ff71837f7d1dea3fd43"}, + {file = "Werkzeug-1.0.1.tar.gz", hash = "sha256:6c80b1e5ad3665290ea39320b91e1be1e0d5f60652b964a3070216de83d2e47c"}, ] zeroconf = [ - {file = "zeroconf-0.24.5-py3-none-any.whl", hash = "sha256:83c4f611338096cafea46509d08e26891800b75abdead43d13bb13094c459187"}, - {file = "zeroconf-0.24.5.tar.gz", hash = "sha256:893a841445663e0c4c20d1111ce41484bd62d58f59d653d0485187343368ef4a"}, + {file = "zeroconf-0.25.1-py3-none-any.whl", hash = "sha256:265bc23ddcea3d76940b6bb5b85d8a5a4e20618e5e6c3da677794e7e26a0e8c5"}, + {file = "zeroconf-0.25.1.tar.gz", hash = "sha256:9b6eb9f73410cc06d203ca510f470e23e83affbe1bd65551daea2990b9171f75"}, ] zipp = [ {file = "zipp-3.1.0-py3-none-any.whl", hash = "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b"}, diff --git a/pyproject.toml b/pyproject.toml index 84d73c7e..3e2f2e5e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "labthings" -version = "0.2.0" +version = "0.4.0" description = "Python implementation of LabThings, based on the Flask microframework" authors = ["jtc42 "] @@ -8,17 +8,24 @@ authors = ["jtc42 "] python = "^3.6" Flask = "^1.1.1" marshmallow = "^3.4.0" -webargs = "^5.5.3" +webargs = "^6.0.0" apispec = "^3.2.0" flask-cors = "^3.0.8" gevent = "^1.4.0" gevent-websocket = "^0.10.1" -zeroconf = "^0.24.5" +zeroconf = ">=0.24.5,<0.26.0" [tool.poetry.dev-dependencies] pytest = "^5.2" black = {version = "^19.10b0",allow-prereleases = true} +pytest-cov = "^2.8.1" +numpy = "^1.18.2" +jsonschema = "^3.2.0" + +[tool.black] +exclude = '(\.eggs|\.git|\.venv)' [build-system] requires = ["poetry>=0.12"] build-backend = "poetry.masonry.api" + diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 00000000..9fecfb11 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +addopts = --cov-report term-missing --cov=labthings --cov-report html --cov-report xml \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..7fe4591d --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,276 @@ +import pytest +import os +from flask import Flask +from flask.views import MethodView +from apispec import APISpec +from apispec.ext.marshmallow import MarshmallowPlugin +from labthings.server.labthing import LabThing +from labthings.server.view import View + +from werkzeug.test import EnvironBuilder +from flask.testing import FlaskClient + + +class FakeWebsocket: + def __init__(self, message: str, recieve_once=True): + self.message = message + self.responses = [] + self.closed = False + self.recieve_once = recieve_once + + # I mean screw whoever is responsible for this having to be a thing... + self.receive = self.recieve + + def recieve(self): + # Get message + message_to_send = self.message + # If only sending a message to the server once + if self.recieve_once: + # Clear our message + self.message = None + return message_to_send + + @property + def response(self): + if len(self.responses) >= 1: + return self.responses[-1] + else: + return None + + def send(self, response): + self.responses.append(response) + self.closed = True + return response + + +class JsonClient(FlaskClient): + def open(self, *args, **kwargs): + kwargs.setdefault( + "headers", + {"Content-Type": "application/json", "Accept": "application/json"}, + ) + kwargs.setdefault("content_type", "application/json") + return super().open(*args, **kwargs) + + +class SocketClient(FlaskClient): + def __init__(self, app, response_wrapper, *args, **kwargs): + super().__init__(app, response_wrapper, *args, **kwargs) + self.app = app + self.response_wrapper = response_wrapper + self.socket = FakeWebsocket(message=None) + self.environ_base = { + "HTTP_UPGRADE": "websocket", + "wsgi.websocket": self.socket, + } + + def connect(self, *args, message=None, **kwargs): + kwargs.setdefault("environ_overrides", {})[ + "flask._preserve_context" + ] = self.preserve_context + kwargs.setdefault("environ_base", self.environ_base) + builder = EnvironBuilder(*args, **kwargs) + + try: + environ = builder.get_environ() + finally: + builder.close() + + self.socket.message = message + + with self.app.app_context(): + run_wsgi_app(self.app, environ) + + # Once the connection has been closed, return responses + return self.socket.responses + + +def run_wsgi_app(app, environ, buffered=False): + response = [] + buffer = [] + + def start_response(status, headers, exc_info=None): + if exc_info: + try: + raise exc_info[1].with_traceback(exc_info[2]) + finally: + exc_info = None + response[:] = [status, headers] + return buffer.append + + # Return value from the wsgi_app call + # In the case of our SocketMiddleware, will return [] + app_rv = app(environ, start_response) + return app_rv + + +@pytest.fixture +def empty_view_cls(): + class EmptyViewClass(View): + def get(self): + pass + + def post(self): + pass + + def put(self): + pass + + def delete(self): + pass + + return EmptyViewClass + + +@pytest.fixture +def flask_view_cls(): + class ViewClass(MethodView): + def get(self): + return "GET" + + def post(self): + return "POST" + + def put(self): + return "PUT" + + def delete(self): + return "DELETE" + + return ViewClass + + +@pytest.fixture +def view_cls(): + class ViewClass(View): + def get(self): + return "GET" + + def post(self): + return "POST" + + def put(self): + return "PUT" + + def delete(self): + return "DELETE" + + return ViewClass + + +@pytest.fixture +def spec(): + return APISpec( + title="Python-LabThings PyTest", + version="1.0.0", + openapi_version="3.0.2", + plugins=[MarshmallowPlugin()], + ) + + +@pytest.fixture() +def app(request): + + app = Flask(__name__) + app.config["TESTING"] = True + + # pushes an application context manually + ctx = app.app_context() + ctx.push() + + # bind the test life with the context through the + request.addfinalizer(ctx.pop) + return app + + +@pytest.fixture +def thing(app): + thing = LabThing(app) + with app.app_context(): + return thing + + +@pytest.fixture() +def thing_ctx(thing): + with thing.app.app_context(): + yield thing.app + + +@pytest.fixture() +def debug_app(request): + + app = Flask(__name__) + app.config["TESTING"] = True + app.debug = True + + # pushes an application context manually + ctx = app.app_context() + ctx.push() + + # bind the test life with the context through the + request.addfinalizer(ctx.pop) + return app + + +@pytest.fixture() +def app_ctx(app): + with app.app_context(): + yield app + + +@pytest.fixture() +def app_ctx_debug(debug_app): + with debug_app.app_context(): + yield debug_app + + +@pytest.fixture +def req_ctx(app): + with app.test_request_context() as ctx: + yield ctx + + +@pytest.fixture +def client(app): + app.test_client_class = JsonClient + return app.test_client() + + +@pytest.fixture +def ws_client(app): + app.test_client_class = SocketClient + return app.test_client() + + +@pytest.fixture +def thing_client(thing): + thing.app.test_client_class = JsonClient + return thing.app.test_client() + + +@pytest.fixture +def static_path(app): + return os.path.join(os.path.dirname(__file__), "static") + + +@pytest.fixture +def schemas_path(app): + return os.path.join(os.path.dirname(__file__), "schemas") + + +@pytest.fixture +def extensions_path(app): + return os.path.join(os.path.dirname(__file__), "extensions") + + +@pytest.fixture +def fake_websocket(): + """ + Return a fake websocket client + that sends a given message, waits for a response, then closes + """ + + def _foo(msg, recieve_once=True): + return FakeWebsocket(msg, recieve_once=recieve_once) + + return _foo diff --git a/tests/extensions/extension.py b/tests/extensions/extension.py new file mode 100644 index 00000000..a5db19b4 --- /dev/null +++ b/tests/extensions/extension.py @@ -0,0 +1,3 @@ +from labthings.server.extensions import BaseExtension + +test_extension = BaseExtension("org.labthings.tests.extension") diff --git a/tests/extensions/extension_exception.py b/tests/extensions/extension_exception.py new file mode 100644 index 00000000..84380a74 --- /dev/null +++ b/tests/extensions/extension_exception.py @@ -0,0 +1 @@ +raise Exception diff --git a/tests/extensions/extension_explicit_list.py b/tests/extensions/extension_explicit_list.py new file mode 100644 index 00000000..dafed7cb --- /dev/null +++ b/tests/extensions/extension_explicit_list.py @@ -0,0 +1,6 @@ +from labthings.server.extensions import BaseExtension + +test_extension = BaseExtension("org.labthings.tests.extension") +test_extension_excluded = BaseExtension("org.labthings.tests.extension_excluded") + +__extensions__ = ["test_extension"] diff --git a/tests/extensions/extension_package/__init__.py b/tests/extensions/extension_package/__init__.py new file mode 100644 index 00000000..bd35f4ca --- /dev/null +++ b/tests/extensions/extension_package/__init__.py @@ -0,0 +1,3 @@ +from labthings.server.extensions import BaseExtension + +test_extension = BaseExtension("org.labthings.tests.extension_package") diff --git a/tests/schemas/w3_wot_td_v1.json b/tests/schemas/w3_wot_td_v1.json new file mode 100644 index 00000000..21444aa7 --- /dev/null +++ b/tests/schemas/w3_wot_td_v1.json @@ -0,0 +1,1035 @@ +{ + "title": "WoT TD Schema - 16 October 2019", + "description": "JSON Schema for validating TD instances against the TD model. TD instances can be with or without terms that have default values", + "$schema ": "http://json-schema.org/draft-07/schema#", + "definitions": { + "anyUri": { + "type": "string", + "format": "iri-reference" + }, + "description": { + "type": "string" + }, + "descriptions": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "title": { + "type": "string" + }, + "titles": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "security": { + "oneOf": [{ + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "string" + } + ] + }, + "scopes": { + "oneOf": [{ + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "string" + } + ] + }, + "subProtocol": { + "type": "string", + "enum": [ + "longpoll", + "websub", + "sse" + ] + }, + "thing-context-w3c-uri": { + "type": "string", + "enum": [ + "https://www.w3.org/2019/wot/td/v1" + ] + }, + "thing-context": { + "oneOf": [{ + "type": "array", + "items": [{ + "$ref": "#/definitions/thing-context-w3c-uri" + }], + "additionalItems": { + "anyOf": [{ + "$ref": "#/definitions/anyUri" + }, + { + "type": "object" + } + ] + } + }, + { + "$ref": "#/definitions/thing-context-w3c-uri" + } + ] + }, + "type_declaration": { + "oneOf": [{ + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "dataSchema": { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "title": { + "$ref": "#/definitions/title" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "titles": { + "$ref": "#/definitions/titles" + }, + "writeOnly": { + "type": "boolean" + }, + "readOnly": { + "type": "boolean" + }, + "oneOf": { + "type": "array", + "items": { + "$ref": "#/definitions/dataSchema" + } + }, + "unit": { + "type": "string" + }, + "enum": { + "type": "array", + "minItems": 1, + "uniqueItems": true + }, + "format": { + "type": "string" + }, + "const": {}, + "type": { + "type": "string", + "enum": [ + "boolean", + "integer", + "number", + "string", + "object", + "array", + "null" + ] + }, + "items": { + "oneOf": [{ + "$ref": "#/definitions/dataSchema" + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/dataSchema" + } + } + ] + }, + "maxItems": { + "type": "integer", + "minimum": 0 + }, + "minItems": { + "type": "integer", + "minimum": 0 + }, + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "properties": { + "additionalProperties": { + "$ref": "#/definitions/dataSchema" + } + }, + "required": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "form_element_property": { + "type": "object", + "properties": { + "op": { + "oneOf": [{ + "type": "string", + "enum": [ + "readproperty", + "writeproperty", + "observeproperty", + "unobserveproperty" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "readproperty", + "writeproperty", + "observeproperty", + "unobserveproperty" + ] + } + } + ] + }, + "href": { + "$ref": "#/definitions/anyUri" + }, + "contentType": { + "type": "string" + }, + "contentCoding": { + "type": "string" + }, + "subProtocol": { + "$ref": "#/definitions/subProtocol" + }, + "security": { + "$ref": "#/definitions/security" + }, + "scopes": { + "$ref": "#/definitions/scopes" + }, + "response": { + "type": "object", + "properties": { + "contentType": { + "type": "string" + } + } + } + }, + "required": [ + "href" + ], + "additionalProperties": true + }, + "form_element_action": { + "type": "object", + "properties": { + "op": { + "oneOf": [{ + "type": "string", + "enum": [ + "invokeaction" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "invokeaction" + ] + } + } + ] + }, + "href": { + "$ref": "#/definitions/anyUri" + }, + "contentType": { + "type": "string" + }, + "contentCoding": { + "type": "string" + }, + "subProtocol": { + "$ref": "#/definitions/subProtocol" + }, + "security": { + "$ref": "#/definitions/security" + }, + "scopes": { + "$ref": "#/definitions/scopes" + }, + "response": { + "type": "object", + "properties": { + "contentType": { + "type": "string" + } + } + } + }, + "required": [ + "href" + ], + "additionalProperties": true + }, + "form_element_event": { + "type": "object", + "properties": { + "op": { + "oneOf": [{ + "type": "string", + "enum": [ + "subscribeevent", + "unsubscribeevent" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "subscribeevent", + "unsubscribeevent" + ] + } + } + ] + }, + "href": { + "$ref": "#/definitions/anyUri" + }, + "contentType": { + "type": "string" + }, + "contentCoding": { + "type": "string" + }, + "subProtocol": { + "$ref": "#/definitions/subProtocol" + }, + "security": { + "$ref": "#/definitions/security" + }, + "scopes": { + "$ref": "#/definitions/scopes" + }, + "response": { + "type": "object", + "properties": { + "contentType": { + "type": "string" + } + } + } + }, + "required": [ + "href" + ], + "additionalProperties": true + }, + "form_element_root": { + "type": "object", + "properties": { + "op": { + "oneOf": [{ + "type": "string", + "enum": [ + "readallproperties", + "writeallproperties", + "readmultipleproperties", + "writemultipleproperties" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "readallproperties", + "writeallproperties", + "readmultipleproperties", + "writemultipleproperties" + ] + } + } + ] + }, + "href": { + "$ref": "#/definitions/anyUri" + }, + "contentType": { + "type": "string" + }, + "contentCoding": { + "type": "string" + }, + "subProtocol": { + "$ref": "#/definitions/subProtocol" + }, + "security": { + "$ref": "#/definitions/security" + }, + "scopes": { + "$ref": "#/definitions/scopes" + }, + "response": { + "type": "object", + "properties": { + "contentType": { + "type": "string" + } + } + } + }, + "required": [ + "href" + ], + "additionalProperties": true + }, + "property_element": { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "title": { + "$ref": "#/definitions/title" + }, + "titles": { + "$ref": "#/definitions/titles" + }, + "forms": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/form_element_property" + } + }, + "uriVariables": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/dataSchema" + } + }, + "observable": { + "type": "boolean" + }, + "writeOnly": { + "type": "boolean" + }, + "readOnly": { + "type": "boolean" + }, + "oneOf": { + "type": "array", + "items": { + "$ref": "#/definitions/dataSchema" + } + }, + "unit": { + "type": "string" + }, + "enum": { + "type": "array", + "minItems": 1, + "uniqueItems": true + }, + "format": { + "type": "string" + }, + "const": {}, + "type": { + "type": "string", + "enum": [ + "boolean", + "integer", + "number", + "string", + "object", + "array", + "null" + ] + }, + "items": { + "oneOf": [{ + "$ref": "#/definitions/dataSchema" + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/dataSchema" + } + } + ] + }, + "maxItems": { + "type": "integer", + "minimum": 0 + }, + "minItems": { + "type": "integer", + "minimum": 0 + }, + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "properties": { + "additionalProperties": { + "$ref": "#/definitions/dataSchema" + } + }, + "required": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "forms" + ], + "additionalProperties": true + }, + "action_element": { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "title": { + "$ref": "#/definitions/title" + }, + "titles": { + "$ref": "#/definitions/titles" + }, + "forms": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/form_element_action" + } + }, + "uriVariables": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/dataSchema" + } + }, + "input": { + "$ref": "#/definitions/dataSchema" + }, + "output": { + "$ref": "#/definitions/dataSchema" + }, + "safe": { + "type": "boolean" + }, + "idempotent": { + "type": "boolean" + } + }, + "required": [ + "forms" + ], + "additionalProperties": true + }, + "event_element": { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "title": { + "$ref": "#/definitions/title" + }, + "titles": { + "$ref": "#/definitions/titles" + }, + "forms": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/form_element_event" + } + }, + "uriVariables": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/dataSchema" + } + }, + "subscription": { + "$ref": "#/definitions/dataSchema" + }, + "data": { + "$ref": "#/definitions/dataSchema" + }, + "cancellation": { + "$ref": "#/definitions/dataSchema" + } + }, + "required": [ + "forms" + ], + "additionalProperties": true + }, + "link_element": { + "type": "object", + "properties": { + "href": { + "$ref": "#/definitions/anyUri" + }, + "type": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "anchor": { + "$ref": "#/definitions/anyUri" + } + }, + "required": [ + "href" + ], + "additionalProperties": true + }, + "securityScheme": { + "oneOf": [{ + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "proxy": { + "$ref": "#/definitions/anyUri" + }, + "scheme": { + "type": "string", + "enum": [ + "nosec" + ] + } + }, + "required": [ + "scheme" + ] + }, + { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "proxy": { + "$ref": "#/definitions/anyUri" + }, + "scheme": { + "type": "string", + "enum": [ + "basic" + ] + }, + "in": { + "type": "string", + "enum": [ + "header", + "query", + "body", + "cookie" + ] + }, + "name": { + "type": "string" + } + }, + "required": [ + "scheme" + ] + }, + { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "proxy": { + "$ref": "#/definitions/anyUri" + }, + "scheme": { + "type": "string", + "enum": [ + "digest" + ] + }, + "qop": { + "type": "string", + "enum": [ + "auth", + "auth-int" + ] + }, + "in": { + "type": "string", + "enum": [ + "header", + "query", + "body", + "cookie" + ] + }, + "name": { + "type": "string" + } + }, + "required": [ + "scheme" + ] + }, + { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "proxy": { + "$ref": "#/definitions/anyUri" + }, + "scheme": { + "type": "string", + "enum": [ + "apikey" + ] + }, + "in": { + "type": "string", + "enum": [ + "header", + "query", + "body", + "cookie" + ] + }, + "name": { + "type": "string" + } + }, + "required": [ + "scheme" + ] + }, + { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "proxy": { + "$ref": "#/definitions/anyUri" + }, + "scheme": { + "type": "string", + "enum": [ + "bearer" + ] + }, + "authorization": { + "$ref": "#/definitions/anyUri" + }, + "alg": { + "type": "string" + }, + "format": { + "type": "string" + }, + "in": { + "type": "string", + "enum": [ + "header", + "query", + "body", + "cookie" + ] + }, + "name": { + "type": "string" + } + }, + "required": [ + "scheme" + ] + }, + { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "proxy": { + "$ref": "#/definitions/anyUri" + }, + "scheme": { + "type": "string", + "enum": [ + "psk" + ] + }, + "identity": { + "type": "string" + } + }, + "required": [ + "scheme" + ] + }, + { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "proxy": { + "$ref": "#/definitions/anyUri" + }, + "scheme": { + "type": "string", + "enum": [ + "oauth2" + ] + }, + "authorization": { + "$ref": "#/definitions/anyUri" + }, + "token": { + "$ref": "#/definitions/anyUri" + }, + "refresh": { + "$ref": "#/definitions/anyUri" + }, + "scopes": { + "oneOf": [{ + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "string" + } + ] + }, + "flow": { + "type": "string", + "enum": [ + "code" + ] + } + }, + "required": [ + "scheme" + ] + } + ] + } + }, + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uri" + }, + "title": { + "$ref": "#/definitions/title" + }, + "titles": { + "$ref": "#/definitions/titles" + }, + "properties": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/property_element" + } + }, + "actions": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/action_element" + } + }, + "events": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/event_element" + } + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "version": { + "type": "object", + "properties": { + "instance": { + "type": "string" + } + }, + "required": [ + "instance" + ] + }, + "links": { + "type": "array", + "items": { + "$ref": "#/definitions/link_element" + } + }, + "forms": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/form_element_root" + } + }, + "base": { + "$ref": "#/definitions/anyUri" + }, + "securityDefinitions": { + "type": "object", + "minProperties": 1, + "additionalProperties": { + "$ref": "#/definitions/securityScheme" + } + }, + "support": { + "$ref": "#/definitions/anyUri" + }, + "created": { + "type": "string", + "format": "date-time" + }, + "modified": { + "type": "string", + "format": "date-time" + }, + "security": { + "oneOf": [{ + "type": "string" + }, + { + "type": "array", + "minItems": 1, + "items": { + "type": "string" + } + } + ] + }, + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "@context": { + "$ref": "#/definitions/thing-context" + } + }, + "required": [ + "title", + "security", + "securityDefinitions", + "@context" + ], + "additionalProperties": true +} \ No newline at end of file diff --git a/tests/static/text b/tests/static/text new file mode 100644 index 00000000..f3a34851 --- /dev/null +++ b/tests/static/text @@ -0,0 +1 @@ +text \ No newline at end of file diff --git a/tests/test_core_event.py b/tests/test_core_event.py new file mode 100644 index 00000000..be8ee02b --- /dev/null +++ b/tests/test_core_event.py @@ -0,0 +1,123 @@ +import pytest +import gevent +from gevent.hub import getcurrent + +from labthings.core import event + + +def test_clientevent_init(): + assert event.ClientEvent() + + +def test_clientevent_greenlet_wait(): + e = event.ClientEvent() + + def g(): + # Wait for e.set() + return e.wait() + + # Spawn greenlet + greenlet = gevent.spawn(g) + # Wait for e to notice greenlet is waiting for it + while e.events == {}: + gevent.sleep(0) + + # Assert greenlet is in the list of threads waiting for e + assert id(greenlet) in e.events + + # Set e from main thread + # Should cause greenlet to exit due to wait ending as event is set + e.set() + # Wait for greenlet to finish + greenlet.join() + # Ensure greenlet successfully waited without timing out + assert greenlet.value == True + + +def test_clientevent_greenlet_wait_timeout(): + e = event.ClientEvent() + + def g(): + # Wait for e.set(), but timeout immediately + return e.wait(timeout=0) + + # Spawn greenlet + greenlet = gevent.spawn(g) + # Wait for greenlet to finish without ever setting e + greenlet.join() + # Assert greenlet returns False, since wait() timed out immediately + assert greenlet.value == False + + +def test_clientevent_greenlet_wait_clear(): + e = event.ClientEvent() + + def g(): + # Wait for e.set() + e.wait() + # Clear e for this greenlet + # This informs e that the greenlet is alive + # and waiting for e to be set again + return e.clear() + + # Spawn greenlet + greenlet = gevent.spawn(g) + # Wait for e to notice greenlet is waiting for it + while e.events == {}: + gevent.sleep(0) + + # Set e from main thread + e.set() + # Wait for greenlet to finish + greenlet.join() + # Ensure greenlet successfully cleared e + assert greenlet.value == True + + +def test_clientevent_greenlet_wait_clear_wrong_greenlet(): + e = event.ClientEvent() + + def g(): + return e.wait() + + # Spawn greenlet + greenlet = gevent.spawn(g) + # Wait for e to notice greenlet is waiting for it + while e.events == {}: + gevent.sleep(0) + + # Set e from main thread + e.set() + # Wait for greenlet to finish + greenlet.join() + # Try to clear() e from main thread + # Should return False since main thread isn't registered as waiting for e + assert e.clear() == False + + +def test_clientevent_drop_client(): + e = event.ClientEvent() + + def g(): + # Wait for e.set() + e.wait() + # Exit without clearing + + # Spawn greenlet + greenlet = gevent.spawn(g) + # Wait for e to notice greenlet is waiting for it + while e.events == {}: + gevent.sleep(0) + + # Set e from main thread, causing the greenlet to exit + e.set() + # Wait for greenlet to finish + greenlet.join() + # Set e from main thread again, with immediate timeout + # This means that if the client greenlet hasn't cleared the event + # within 0 seconds, it will be assumed to have exited and dropped + # from the internal event list + e.set(timeout=0) + + # Assert that the exited greenlet was dropped from e + assert id(greenlet) not in e.events diff --git a/tests/test_core_exceptions.py b/tests/test_core_exceptions.py new file mode 100644 index 00000000..cc3a0b79 --- /dev/null +++ b/tests/test_core_exceptions.py @@ -0,0 +1,26 @@ +from labthings.core import exceptions +import pytest + + +def test_lockerror_valid_code(): + from threading import Lock + + lock = Lock() + + assert exceptions.LockError("ACQUIRE_ERROR", lock) + assert ( + str(exceptions.LockError("ACQUIRE_ERROR", lock)) + == f"ACQUIRE_ERROR: LOCK {lock}: Unable to acquire. Lock in use by another thread." + ) + + +def test_lockerror_invalid_code(): + from threading import Lock + + lock = Lock() + + assert exceptions.LockError("INVALID_ERROR", lock) + assert ( + str(exceptions.LockError("INVALID_ERROR", lock)) + == f"INVALID_ERROR: LOCK {lock}: Unknown error." + ) diff --git a/tests/test_core_lock.py b/tests/test_core_lock.py new file mode 100644 index 00000000..26c9f808 --- /dev/null +++ b/tests/test_core_lock.py @@ -0,0 +1,90 @@ +from labthings.core import lock +import pytest +from gevent.hub import getcurrent + +# Fixtures + + +@pytest.fixture( + params=[ + lock.StrictLock(), + lock.CompositeLock([lock.StrictLock(), lock.StrictLock()]), + ], + ids=["StrictLock", "CompositeLock"], +) +def this_lock(request): + return request.param + + +# RLock + + +def test_rlock_acquire(this_lock): + # Assert no owner + assert not this_lock.locked() + + # Acquire lock + assert this_lock.acquire() + # Assert owner + assert this_lock._is_owned() + + # Release lock + this_lock.release() + + # Release lock, assert not held + assert not this_lock.locked() + + +def test_rlock_entry(this_lock): + # Acquire lock + with this_lock: + # Assert owner + assert this_lock._is_owned() + + # Release lock, assert no owner + assert not this_lock.locked() + + +def test_rlock_reentry(this_lock): + # Acquire lock + with this_lock: + # Assert owner + assert this_lock._is_owned() + # Assert acquirable + with this_lock as acquired_return: + assert acquired_return + # Assert still owned + assert this_lock._is_owned() + + # Release lock, assert no owner + assert not this_lock.locked() + + +def test_rlock_block(this_lock): + from labthings.core.exceptions import LockError + + # Acquire lock + assert this_lock.acquire() + + # Override owner to force acquisition failure + this_lock._owner = None + print(this_lock._owner) + # Assert not owner + assert not this_lock._is_owned() + + # Assert acquisition fails + with pytest.raises(LockError): + this_lock.acquire(blocking=True, timeout=0.01) + + # Ensure an unheld lock cannot be released + with pytest.raises(RuntimeError): + this_lock.release() + + # Force ownership + this_lock._owner = getcurrent() + + # Release lock + this_lock.release() + + # Release lock, assert no owner + assert not this_lock._is_owned() diff --git a/tests/test_core_tasks_pool.py b/tests/test_core_tasks_pool.py new file mode 100644 index 00000000..9bdb09b6 --- /dev/null +++ b/tests/test_core_tasks_pool.py @@ -0,0 +1,109 @@ +from labthings.core import tasks +import pytest + +import gevent + + +def test_taskify_without_context(): + def task_func(): + pass + + task_obj = tasks.taskify(task_func)() + assert isinstance(task_obj, gevent.Greenlet) + + +def test_taskify_with_context(app_ctx): + def task_func(): + pass + + with app_ctx.test_request_context(): + task_obj = tasks.taskify(task_func)() + assert isinstance(task_obj, gevent.Greenlet) + + +def test_update_task_data(): + def task_func(): + tasks.update_task_data({"key": "value"}) + + task_obj = tasks.taskify(task_func)() + task_obj.join() + assert task_obj.state.get("data") == {"key": "value"} + + +def test_update_task_data_main_thread(): + # Should do nothing + tasks.update_task_data({"key": "value"}) + + +def test_update_task_progress(): + def task_func(): + tasks.update_task_progress(100) + + task_obj = tasks.taskify(task_func)() + task_obj.join() + assert task_obj.state.get("progress") == 100 + + +def test_update_task_progress_main_thread(): + # Should do nothing + tasks.update_task_progress(100) + + +def test_tasks_list(): + assert all([isinstance(task_obj, gevent.Greenlet) for task_obj in tasks.tasks()]) + + +def test_tasks_dict(): + assert all( + [ + isinstance(task_obj, gevent.Greenlet) + for task_obj in tasks.dictionary().values() + ] + ) + + assert all([k == str(t.id) for k, t in tasks.dictionary().items()]) + + +def test_task_states(): + state_keys = { + "function", + "id", + "status", + "progress", + "data", + "return", + "start_time", + "end_time", + } + + for state in tasks.states().values(): + assert all(k in state for k in state_keys) + + +def test_remove_task(): + def task_func(): + pass + + task_obj = tasks.taskify(task_func)() + assert str(task_obj.id) in tasks.dictionary() + task_obj.join() + + tasks.remove_task(task_obj.id) + assert not str(task_obj.id) in tasks.dictionary() + + +def test_cleanup_task(): + import time + + def task_func(): + pass + + # Make sure at least 1 tasks is around + tasks.taskify(task_func)() + + # Wait for all tasks to finish + gevent.joinall(tasks.tasks()) + + assert len(tasks.tasks()) > 0 + tasks.cleanup_tasks() + assert len(tasks.tasks()) == 0 diff --git a/tests/test_core_tasks_thread.py b/tests/test_core_tasks_thread.py new file mode 100644 index 00000000..e82fc22e --- /dev/null +++ b/tests/test_core_tasks_thread.py @@ -0,0 +1,118 @@ +from labthings.core.tasks import thread +import pytest + +import gevent +from gevent.thread import get_ident + + +def test_task_with_args(): + def task_func(arg, kwarg=False): + pass + + task_obj = thread.TaskThread( + target=task_func, args=("String arg",), kwargs={"kwarg": True} + ) + assert isinstance(task_obj, gevent.Greenlet) + assert task_obj._target == task_func + assert task_obj._args == ("String arg",) + assert task_obj._kwargs == {"kwarg": True} + + +def test_task_without_args(): + def task_func(): + pass + + task_obj = thread.TaskThread(target=task_func) + + assert isinstance(task_obj, gevent.Greenlet) + assert task_obj._target == task_func + assert task_obj._args == () + assert task_obj._kwargs == {} + + +def test_task_identity(): + def task_func(): + pass + + task_obj = thread.TaskThread(target=task_func) + + assert task_obj.ident == get_ident(task_obj) + + +def test_task_start(): + def task_func(): + return "Return value" + + task_obj = thread.TaskThread(target=task_func) + + assert task_obj._status == "idle" + assert task_obj._return_value is None + + task_obj.start() + task_obj.join() + assert task_obj._return_value == "Return value" + assert task_obj._status == "success" + + +def test_task_exception(): + exc_to_raise = Exception("Exception message") + + def task_func(): + raise exc_to_raise + + task_obj = thread.TaskThread(target=task_func) + task_obj.start() + task_obj.join() + + assert task_obj._status == "error" + assert task_obj._return_value == str(exc_to_raise) + + +def test_task_terminate(): + def task_func(): + while True: + gevent.sleep(0.5) + + task_obj = thread.TaskThread(target=task_func) + task_obj.start() + task_obj.started_event.wait() + assert task_obj._status == "running" + task_obj.terminate() + task_obj.join() + assert task_obj._status == "terminated" + assert task_obj._return_value is None + + +def test_task_log_list(): + import logging + import os + + def task_func(): + logging.warning("Task warning") + + task_obj = thread.TaskThread(target=task_func) + task_obj.start() + task_obj.started_event.wait() + + assert len(task_obj.log) == 1 + assert task_obj.log[0]["message"] == "Task warning" + assert task_obj.log[0]["levelname"] == "WARNING" + assert task_obj.log[0]["filename"] == os.path.basename(__file__) + + +def test_task_log_without_thread(): + + task_log_handler = thread.ThreadLogHandler() + + # Should always return True if not attached to a thread + assert task_log_handler.check_thread(record=None) + + +def test_task_log_with_incorrect_thread(): + + task_obj = thread.TaskThread() + task_log_handler = thread.ThreadLogHandler(thread=task_obj) + + # Should always return False if called from outside the log handlers thread + assert task_log_handler.thread == task_obj + assert not task_log_handler.check_thread(record=None) diff --git a/tests/test_core_utilities.py b/tests/test_core_utilities.py index bed294d9..9d2b6987 100644 --- a/tests/test_core_utilities.py +++ b/tests/test_core_utilities.py @@ -47,17 +47,51 @@ def test_get_summary(example_class): assert utilities.get_summary(example_class.class_method_no_docstring) == "" -def test_rupdate(): - d1 = {"a": "String", "b": 5, "c": [], "d": {"a": "String", "b": 5, "c": []}} +def test_rupdate_granular(): + # Update string value + s1 = {"a": "String"} + s2 = {"a": "String 2"} + assert utilities.rupdate(s1, s2) == s2 + + # Update int value + i1 = {"b": 5} + i2 = {"b": 50} + assert utilities.rupdate(i1, i2) == i2 + + # Update list elements + l1 = {"c": []} + l2 = {"c": [1, 2, 3, 4]} + assert utilities.rupdate(l1, l2) == l2 + + # Extend list elements + l1 = {"c": [1, 2, 3]} + l2 = {"c": [4, 5, 6]} + assert utilities.rupdate(l1, l2)["c"] == [1, 2, 3, 4, 5, 6] + + # Merge dictionaries + d1 = {"d": {"a": "String", "b": 5, "c": []}} + d2 = {"d": {"a": "String 2", "b": 50, "c": [1, 2, 3, 4, 5]}} + assert utilities.rupdate(d1, d2) == d2 - d2 = { - "a": "String 2", - "b": 50, - "c": [10, 20, 30, 40, 50], - "d": {"a": "String 2d", "b": 50, "c": [10, 20, 30, 40, 50]}, - } + # Replace value with list + ml1 = {"k": True} + ml2 = {"k": [1, 2, 3]} + assert utilities.rupdate(ml1, ml2) == ml2 - assert utilities.rupdate(d1, d2) == d2 + # Create missing value + ms1 = {} + ms2 = {"k": "v"} + assert utilities.rupdate(ms1, ms2) == ms2 + + # Create missing list + ml1 = {} + ml2 = {"k": [1, 2, 3]} + assert utilities.rupdate(ml1, ml2) == ml2 + + # Create missing dictionary + md1 = {} + md2 = {"d": {"a": "String 2", "b": 50, "c": [1, 2, 3, 4, 5]}} + assert utilities.rupdate(md1, md2) == md2 def test_rapply(): @@ -109,12 +143,24 @@ def test_create_from_path(): def test_camel_to_snake(): - assert utilities.camel_to_snake("SomeCamelString") == "some_camel_string" + assert utilities.camel_to_snake("someCamelString") == "some_camel_string" -def camel_to_spine(): - assert utilities.camel_to_snake("SomeCamelString") == "some-camel-string" +def test_camel_to_spine(): + assert utilities.camel_to_spine("someCamelString") == "some-camel-string" -def test_snake_to_spinee(): +def test_snake_to_spine(): assert utilities.snake_to_spine("some_snake_string") == "some-snake-string" + + +def test_snake_to_camel(): + assert utilities.snake_to_camel("some_snake_string") == "someSnakeString" + + +def test_path_relative_to(): + import os + + assert utilities.path_relative_to( + "/path/to/file.extension", "joinpath", "joinfile.extension" + ) == os.path.abspath("/path/to/joinpath/joinfile.extension") diff --git a/tests/test_server_decorators.py b/tests/test_server_decorators.py new file mode 100644 index 00000000..94116f59 --- /dev/null +++ b/tests/test_server_decorators.py @@ -0,0 +1,343 @@ +import pytest + +from marshmallow import Schema as _Schema +from flask import make_response + +from labthings.server.schema import Schema +from labthings.server import fields +from labthings.server.view import View +from labthings.core.tasks.thread import TaskThread + +from labthings.server import decorators + + +@pytest.fixture +def empty_cls(): + class Index: + pass + + return Index + + +def common_task_test(marshaled_task: dict): + assert isinstance(marshaled_task, dict) + assert isinstance(marshaled_task.get("id"), str) + assert marshaled_task.get("function") == "None(args=(), kwargs={})" + assert marshaled_task.get("status") == "idle" + + +def test_marshal_with_ma_schema(): + def func(): + obj = type("obj", (object,), {"integer": 1}) + return obj + + schema = _Schema.from_dict({"integer": fields.Int()})() + wrapped_func = decorators.marshal_with(schema)(func) + + assert wrapped_func() == ({"integer": 1}, 200, {}) + + +def test_marshal_with_dict_schema(): + def func(): + obj = type("obj", (object,), {"integer": 1}) + return obj + + schema = {"integer": fields.Int()} + wrapped_func = decorators.marshal_with(schema)(func) + + assert wrapped_func() == ({"integer": 1}, 200, {}) + + +def test_marshal_with_field_schema(): + def func(): + return 1 + + schema = fields.String() + wrapped_func = decorators.marshal_with(schema)(func) + + assert wrapped_func() == ("1", 200, {}) + + +def test_marshal_with_response_tuple_field_schema(app_ctx): + def func(): + return ("response", 200, {}) + + schema = fields.String() + wrapped_func = decorators.marshal_with(schema)(func) + + with app_ctx.test_request_context(): + assert wrapped_func() == ("response", 200, {}) + + +def test_marshal_with_response_field_schema(app_ctx): + def func(): + return make_response("response", 200) + + schema = fields.String() + wrapped_func = decorators.marshal_with(schema)(func) + + with app_ctx.test_request_context(): + assert wrapped_func().data == b"response" + + +def test_marshal_with_invalid_schema(): + def func(): + return 1 + + schema = object() + with pytest.raises(TypeError): + decorators.marshal_with(schema)(func) + + +def test_marshal_task(app_ctx): + def func(): + return TaskThread() + + wrapped_func = decorators.marshal_task(func) + + with app_ctx.test_request_context(): + out = wrapped_func() + common_task_test(out[0]) + + +def test_marshal_task_response_tuple(app_ctx): + def func(): + return (TaskThread(), 201, {}) + + wrapped_func = decorators.marshal_task(func) + + with app_ctx.test_request_context(): + out = wrapped_func() + common_task_test(out[0]) + + +def test_marshal_task_response_invalid(app_ctx): + def func(): + return object() + + wrapped_func = decorators.marshal_task(func) + + with app_ctx.test_request_context(), pytest.raises(TypeError): + wrapped_func() + + +def test_thing_action(empty_cls): + wrapped_cls = decorators.thing_action(empty_cls) + assert wrapped_cls.__apispec__["tags"] == set(["actions"]) + + +def test_safe(empty_cls): + wrapped_cls = decorators.safe(empty_cls) + assert wrapped_cls.__apispec__["_safe"] == True + + +def test_idempotent(empty_cls): + wrapped_cls = decorators.idempotent(empty_cls) + assert wrapped_cls.__apispec__["_idempotent"] == True + + +def test_thing_property(view_cls): + wrapped_cls = decorators.thing_property(view_cls) + assert wrapped_cls.__apispec__["tags"] == set(["properties"]) + + +def test_thing_property_empty_class(empty_cls, app_ctx): + wrapped_cls = decorators.thing_property(empty_cls) + assert wrapped_cls.__apispec__["tags"] == set(["properties"]) + + +def test_thing_property_property_notify(view_cls, app_ctx): + wrapped_cls = decorators.thing_property(view_cls) + + with app_ctx.test_request_context(): + wrapped_cls().post() + + +def test_property_schema(app, client): + class Index(View): + def get(self): + obj = type("obj", (object,), {"integer": 1}) + return obj + + def post(self, args): + i = args.get("integer") + obj = type("obj", (object,), {"integer": i}) + return obj + + def put(self, args): + i = args.get("integer") + obj = type("obj", (object,), {"integer": i}) + return obj + + schema = _Schema.from_dict({"integer": fields.Int()})() + WrappedCls = decorators.PropertySchema(schema)(Index) + + assert WrappedCls.__apispec__.get("_propertySchema") == schema + + app.add_url_rule("/", view_func=WrappedCls.as_view("index")) + + with client as c: + assert c.get("/").json == {"integer": 1} + assert c.post("/", json={"integer": 5}).json == {"integer": 5} + assert c.put("/", json={"integer": 5}).json == {"integer": 5} + + +def test_property_schema_empty_class(empty_cls): + schema = _Schema.from_dict({"integer": fields.Int()})() + WrappedCls = decorators.PropertySchema(schema)(empty_cls) + + assert WrappedCls.__apispec__.get("_propertySchema") == schema + + +def test_use_body(app, client): + class Index(View): + def post(self, data): + return str(data) + + schema = fields.Int() + Index.post = decorators.use_body(schema)(Index.post) + + assert Index.post.__apispec__.get("_params") == schema + + app.add_url_rule("/", view_func=Index.as_view("index")) + + with client as c: + assert c.post("/", data=b"5\n").data == b'"5"\n' + + +def test_use_body_required_no_data(app, client): + class Index(View): + def post(self, data): + return {} + + schema = fields.Int(required=True) + Index.post = decorators.use_body(schema)(Index.post) + + assert Index.post.__apispec__.get("_params") == schema + + app.add_url_rule("/", view_func=Index.as_view("index")) + + with client as c: + assert c.post("/").status_code == 400 + + +def test_use_body_no_data(app, client): + class Index(View): + def post(self, data): + assert data is None + return {} + + schema = fields.Int() + Index.post = decorators.use_body(schema)(Index.post) + + assert Index.post.__apispec__.get("_params") == schema + + app.add_url_rule("/", view_func=Index.as_view("index")) + + with client as c: + assert c.post("/").status_code == 200 + + +def test_use_body_no_data_missing_given(app, client): + class Index(View): + def post(self, data): + return str(data) + + schema = fields.Int(missing=5) + Index.post = decorators.use_body(schema)(Index.post) + + assert Index.post.__apispec__.get("_params") == schema + + app.add_url_rule("/", view_func=Index.as_view("index")) + + with client as c: + assert c.post("/").data == b'"5"\n' + + +def test_use_body_malformed(app, client): + class Index(View): + def post(self, data): + return {} + + schema = fields.Int(required=True) + Index.post = decorators.use_body(schema)(Index.post) + + assert Index.post.__apispec__.get("_params") == schema + + app.add_url_rule("/", view_func=Index.as_view("index")) + + with client as c: + assert c.post("/", data=b"{}").status_code == 400 + + +def test_use_args(app, client): + class Index(View): + def post(self, data): + return data + + schema = _Schema.from_dict({"integer": fields.Int()})() + Index.post = decorators.use_args(schema)(Index.post) + + assert Index.post.__apispec__.get("_params") == schema + + app.add_url_rule("/", view_func=Index.as_view("index")) + + with client as c: + assert c.post("/", json={"integer": 5}).json == {"integer": 5} + + +def test_use_args_field(app, client): + class Index(View): + def post(self, data): + return str(data) + + schema = fields.Int(missing=5) + Index.post = decorators.use_args(schema)(Index.post) + + assert Index.post.__apispec__.get("_params") == schema + + app.add_url_rule("/", view_func=Index.as_view("index")) + + with client as c: + assert c.post("/").data == b'"5"\n' + + +def test_doc(empty_cls): + wrapped_cls = decorators.doc(key="value")(empty_cls) + assert wrapped_cls.__apispec__["key"] == "value" + + +def test_tag(empty_cls): + wrapped_cls = decorators.tag(["tag", "tag2"])(empty_cls) + assert wrapped_cls.__apispec__["tags"] == set(["tag", "tag2"]) + + +def test_tag_single(empty_cls): + wrapped_cls = decorators.tag("tag")(empty_cls) + assert wrapped_cls.__apispec__["tags"] == set(["tag"]) + + +def test_tag_invalid(empty_cls): + with pytest.raises(TypeError): + decorators.tag(object())(empty_cls) + + +def test_doc_response(empty_cls): + wrapped_cls = decorators.doc_response( + 200, description="description", mimetype="text/plain", key="value" + )(empty_cls) + assert wrapped_cls.__apispec__ == { + "responses": { + 200: { + "description": "description", + "key": "value", + "content": {"text/plain": {}}, + } + }, + "_content_type": "text/plain", + } + + +def test_doc_response_no_mimetype(empty_cls): + wrapped_cls = decorators.doc_response(200)(empty_cls) + assert wrapped_cls.__apispec__ == {"responses": {200: {"description": "OK"}}} diff --git a/tests/test_server_default_views.py b/tests/test_server_default_views.py new file mode 100644 index 00000000..834eb4f2 --- /dev/null +++ b/tests/test_server_default_views.py @@ -0,0 +1,71 @@ +from labthings.core.tasks import taskify, dictionary + +import gevent + + +def test_docs(thing, thing_client, schemas_path): + + with thing_client as c: + json_out = c.get("/docs/swagger").json + assert "openapi" in json_out + assert "paths" in json_out + assert "info" in json_out + assert c.get("/docs/swagger-ui").status_code == 200 + + +def test_extensions(thing_client): + with thing_client as c: + assert c.get("/extensions").json == [] + + +def test_tasks_list(thing_client): + def task_func(): + pass + + task_obj = taskify(task_func)() + + with thing_client as c: + response = c.get("/tasks").json + ids = [task.get("id") for task in response] + assert str(task_obj.id) in ids + + +def test_task_representation(thing_client): + def task_func(): + pass + + task_obj = taskify(task_func)() + task_id = str(task_obj.id) + + with thing_client as c: + response = c.get(f"/tasks/{task_id}").json + assert response + + +def test_task_representation_missing(thing_client): + with thing_client as c: + assert c.get(f"/tasks/missing_id").status_code == 404 + + +def test_task_kill(thing_client): + def task_func(): + while True: + gevent.sleep(0) + + task_obj = taskify(task_func)() + task_id = str(task_obj.id) + + # Wait for task to start + task_obj.started_event.wait() + assert task_id in dictionary() + + # Send a DELETE request to terminate the task + with thing_client as c: + assert c.delete(f"/tasks/{task_id}").status_code == 200 + # Test task was terminated + assert task_obj.state.get("status") == "terminated" + + +def test_task_kill_missing(thing_client): + with thing_client as c: + assert c.delete(f"/tasks/missing_id").status_code == 404 diff --git a/tests/test_server_default_views_socket_handler.py b/tests/test_server_default_views_socket_handler.py new file mode 100644 index 00000000..3b626b94 --- /dev/null +++ b/tests/test_server_default_views_socket_handler.py @@ -0,0 +1,9 @@ +from labthings.server.default_views.sockets import socket_handler + + +def test_socket_handler(thing_ctx, fake_websocket): + with thing_ctx.test_request_context(): + ws = fake_websocket("", recieve_once=True) + socket_handler(ws) + # Expect no response + assert ws.responses == [] diff --git a/tests/test_server_exceptions.py b/tests/test_server_exceptions.py new file mode 100644 index 00000000..33ab4119 --- /dev/null +++ b/tests/test_server_exceptions.py @@ -0,0 +1,72 @@ +from flask import Flask +from labthings.server.exceptions import JSONExceptionHandler +import json +import pytest + + +@pytest.fixture +def client(): + app = Flask(__name__) + app.config["TESTING"] = True + + with app.test_client() as client: + yield client + + +@pytest.fixture +def app(): + app = Flask(__name__) + app.config["TESTING"] = True + return app + + +def test_registering_handler(app): + error_handler = JSONExceptionHandler() + error_handler.init_app(app) + + +def test_http_exception(app): + from werkzeug.exceptions import NotFound + + error_handler = JSONExceptionHandler(app) + + # Test a 404 HTTPException + response = error_handler.std_handler(NotFound()) + + response_json = json.dumps(response[0]) + assert ( + response_json + == '{"code": 404, "message": "The requested URL was not found on the server. If you entered the URL manually please check your spelling and try again.", "name": "NotFound"}' + ) + + assert response[1] == 404 + + +def test_generic_exception(app): + error_handler = JSONExceptionHandler(app) + + # Test a 404 HTTPException + response = error_handler.std_handler(RuntimeError("Exception message")) + + response_json = json.dumps(response[0]) + assert ( + response_json + == '{"code": 500, "message": "Exception message", "name": "RuntimeError"}' + ) + + assert response[1] == 500 + + +def test_blank_exception(app): + error_handler = JSONExceptionHandler(app) + + e = Exception() + e.message = None + + # Test a 404 HTTPException + response = error_handler.std_handler(e) + + response_json = json.dumps(response[0]) + assert response_json == '{"code": 500, "message": "None", "name": "Exception"}' + + assert response[1] == 500 diff --git a/tests/test_server_extensions.py b/tests/test_server_extensions.py new file mode 100644 index 00000000..8014f838 --- /dev/null +++ b/tests/test_server_extensions.py @@ -0,0 +1,168 @@ +from labthings.server import extensions +import os + +import pytest + + +@pytest.fixture +def lt_extension(): + return extensions.BaseExtension("org.labthings.tests.extension") + + +def test_extension_init(lt_extension): + assert lt_extension + assert lt_extension.name + + +def test_add_view(lt_extension, app, view_cls): + lt_extension.add_view(view_cls, "/index") + + assert "index" in lt_extension.views + assert lt_extension.views.get("index") == { + "rule": "/org.labthings.tests.extension/index", + "view": view_cls, + "kwargs": {}, + } + + +def test_on_register(lt_extension): + def f(arg, kwarg=1): + pass + + lt_extension.on_register(f, args=(1,), kwargs={"kwarg": 0}) + assert { + "function": f, + "args": (1,), + "kwargs": {"kwarg": 0}, + } in lt_extension._on_registers + + +def test_on_register_non_callable(lt_extension): + with pytest.raises(TypeError): + lt_extension.on_register(object()) + + +def test_on_component(lt_extension): + def f(): + pass + + lt_extension.on_component("org.labthings.tests.component", f) + assert { + "component": "org.labthings.tests.component", + "function": f, + "args": (), + "kwargs": {}, + } in lt_extension._on_components + + +def test_on_component_non_callable(lt_extension): + with pytest.raises(TypeError): + lt_extension.on_component("org.labthings.tests.component", object()) + + +def test_meta_simple(lt_extension): + lt_extension.add_meta("key", "value") + assert lt_extension.meta.get("key") == "value" + + +def test_meta_callable(lt_extension): + def f(): + return "callable value" + + lt_extension.add_meta("key", f) + assert lt_extension.meta.get("key") == "callable value" + + +def test_add_method(lt_extension): + def f(): + pass + + lt_extension.add_method( + f, "method_name", + ) + assert lt_extension.method_name == f + + +def test_add_method_name_clash(lt_extension): + def f(): + pass + + lt_extension.add_method( + f, "method_name", + ) + assert lt_extension.method_name == f + + with pytest.raises(NameError): + lt_extension.add_method( + f, "method_name", + ) + + +def test_static_file_url(lt_extension, app, app_ctx): + endpoint = "extensionstatic" + static_view = lt_extension.views.get("static").get("view") + static_view.endpoint = endpoint + static_rule = lt_extension.views.get("static").get("rule") + + assert static_view + assert static_rule + + app.add_url_rule(static_rule, view_func=static_view.as_view(endpoint)) + + with app_ctx.test_request_context(): + assert ( + "org.labthings.tests.extension/static/filename" + in lt_extension.static_file_url("filename") + ) + + +def test_static_file_url_no_endpoint(lt_extension, app, app_ctx): + static_view = lt_extension.views.get("static").get("view") + static_rule = lt_extension.views.get("static").get("rule") + + assert static_view + assert static_rule + + app.add_url_rule(static_rule, view_func=static_view.as_view("index")) + + with app_ctx.test_request_context(): + assert lt_extension.static_file_url("filename") is None + + +def test_find_instances_in_module(lt_extension): + mod = type( + "mod", + (object,), + {"extension_instance": lt_extension, "another_object": object()}, + ) + assert extensions.find_instances_in_module(mod, extensions.BaseExtension) == [ + lt_extension + ] + + +def test_find_extensions_in_file(extensions_path): + test_file = os.path.join(extensions_path, "extension.py") + + found_extensions = extensions.find_extensions_in_file(test_file) + assert len(found_extensions) == 1 + assert found_extensions[0].name == "org.labthings.tests.extension" + + +def test_find_extensions_in_file_explicit_list(extensions_path): + test_file = os.path.join(extensions_path, "extension_explicit_list.py") + + found_extensions = extensions.find_extensions_in_file(test_file) + assert len(found_extensions) == 1 + assert found_extensions[0].name == "org.labthings.tests.extension" + + +def test_find_extensions_in_file_exception(extensions_path): + test_file = os.path.join(extensions_path, "extension_exception.py") + + found_extensions = extensions.find_extensions_in_file(test_file) + assert found_extensions == [] + + +def test_find_extensions(extensions_path): + found_extensions = extensions.find_extensions(extensions_path) + assert len(found_extensions) == 3 diff --git a/tests/test_server_find.py b/tests/test_server_find.py new file mode 100644 index 00000000..e2a984a2 --- /dev/null +++ b/tests/test_server_find.py @@ -0,0 +1,75 @@ +from labthings.server import find + +from labthings.server.extensions import BaseExtension + + +def test_current_labthing(thing, thing_ctx): + with thing_ctx.test_request_context(): + assert find.current_labthing() is thing + + +def test_current_labthing_explicit_app(thing, thing_ctx): + with thing_ctx.test_request_context(): + assert find.current_labthing(thing.app) is thing + + +def test_current_labthing_missing_app(): + assert find.current_labthing() is None + + +def test_registered_extensions(thing_ctx): + with thing_ctx.test_request_context(): + assert find.registered_extensions() == {} + + +def test_registered_extensions_explicit_thing(thing): + assert find.registered_extensions(thing) == {} + + +def test_registered_components(thing_ctx): + with thing_ctx.test_request_context(): + assert find.registered_components() == {} + + +def test_registered_components_explicit_thing(thing): + assert find.registered_components(thing) == {} + + +def test_find_component(thing, thing_ctx): + component = type("component", (object,), {}) + thing.add_component(component, "org.labthings.tests.component") + + with thing_ctx.test_request_context(): + assert find.find_component("org.labthings.tests.component") == component + + +def test_find_component_explicit_thing(thing): + component = type("component", (object,), {}) + thing.add_component(component, "org.labthings.tests.component") + + assert find.find_component("org.labthings.tests.component", thing) == component + + +def test_find_component_missing_component(thing_ctx): + with thing_ctx.test_request_context(): + assert find.find_component("org.labthings.tests.component") is None + + +def test_find_extension(thing, thing_ctx): + extension = BaseExtension("org.labthings.tests.extension") + thing.register_extension(extension) + + with thing_ctx.test_request_context(): + assert find.find_extension("org.labthings.tests.extension") == extension + + +def test_find_extension_explicit_thing(thing): + extension = BaseExtension("org.labthings.tests.extension") + thing.register_extension(extension) + + assert find.find_extension("org.labthings.tests.extension", thing) == extension + + +def test_find_extension_missing_extesion(thing_ctx): + with thing_ctx.test_request_context(): + assert find.find_extension("org.labthings.tests.extension") is None diff --git a/tests/test_server_labthing.py b/tests/test_server_labthing.py new file mode 100644 index 00000000..8d57d634 --- /dev/null +++ b/tests/test_server_labthing.py @@ -0,0 +1,248 @@ +import pytest + +from labthings.server import labthing + +from labthings.server.view import View +from labthings.server.representations import LabThingsJSONEncoder +from labthings.server.names import EXTENSION_NAME +from labthings.server.extensions import BaseExtension + + +def test_init_types(): + types = ["org.labthings.test"] + thing = labthing.LabThing(types=types) + assert thing.types == types + + +def test_init_types_invalid(): + types = ["org;labthings;test"] + with pytest.raises(ValueError): + labthing.LabThing(types=types) + + +def test_init_app(app): + thing = labthing.LabThing() + thing.init_app(app) + + # Check weakref + assert app.extensions.get(EXTENSION_NAME)() == thing + + assert app.json_encoder == LabThingsJSONEncoder + assert 400 in app.error_handler_spec.get(None) + + +def test_init_app_no_error_formatter(app): + thing = labthing.LabThing(format_flask_exceptions=False) + thing.init_app(app) + assert app.error_handler_spec == {} + + +def test_add_view(thing, view_cls, client): + thing.add_view(view_cls, "/index", endpoint="index") + + with client as c: + assert c.get("/index").data == b'"GET"\n' + + +def test_add_view_endpoint_clash(thing, view_cls, client): + thing.add_view(view_cls, "/index", endpoint="index") + with pytest.raises(AssertionError): + thing.add_view(view_cls, "/index2", endpoint="index") + + +def test_view_decorator(thing, client): + @thing.view("/index") + class ViewClass(View): + def get(self): + return "GET" + + with client as c: + assert c.get("/index").data == b'"GET"\n' + + +def test_add_view_action(thing, view_cls, client): + view_cls.__apispec__ = {"tags": set(["actions"])} + thing.add_view(view_cls, "/index", endpoint="index") + assert view_cls in thing._action_views.values() + + +def test_add_view_property(thing, view_cls, client): + view_cls.__apispec__ = {"tags": set(["properties"])} + thing.add_view(view_cls, "/index", endpoint="index") + assert view_cls in thing._property_views.values() + + +def test_init_app_early_views(app, view_cls, client): + thing = labthing.LabThing() + thing.add_view(view_cls, "/index", endpoint="index") + + thing.init_app(app) + + with client as c: + assert c.get("/index").data == b'"GET"\n' + + +def test_register_extension(thing): + extension = BaseExtension("org.labthings.tests.extension") + thing.register_extension(extension) + assert thing.extensions.get("org.labthings.tests.extension") == extension + + +def test_register_extension_type_error(thing): + extension = object() + with pytest.raises(TypeError): + thing.register_extension(extension) + + +def test_add_component(thing): + component = type("component", (object,), {}) + + thing.add_component(component, "org.labthings.tests.component") + assert "org.labthings.tests.component" in thing.components + + +def test_on_component_callback(thing): + # Build extension + def f(component): + component.callback_called = True + + extension = BaseExtension("org.labthings.tests.extension") + extension.on_component("org.labthings.tests.component", f) + # Add extension + thing.register_extension(extension) + + # Build component + component = type("component", (object,), {"callback_called": False}) + + # Add component + thing.add_component(component, "org.labthings.tests.component") + # Check callback + assert component.callback_called + + +def test_on_component_callback_component_already_added(thing): + # Build component + component = type("component", (object,), {"callback_called": False}) + # Add component + thing.add_component(component, "org.labthings.tests.component") + + # Build extension + def f(component): + component.callback_called = True + + extension = BaseExtension("org.labthings.tests.extension") + extension.on_component("org.labthings.tests.component", f) + # Add extension + thing.register_extension(extension) + + # Check callback + assert component.callback_called + + +def test_on_component_callback_wrong_component(thing): + def f(component): + component.callback_called = True + + extension = BaseExtension("org.labthings.tests.extension") + extension.on_component("org.labthings.tests.component", f) + thing.register_extension(extension) + + component = type("component", (object,), {"callback_called": False}) + thing.add_component(component, "org.labthings.tests.wrong_component") + assert not component.callback_called + + +def test_on_register_callback(thing): + # Build extension + def f(extension): + extension.callback_called = True + + extension = BaseExtension("org.labthings.tests.extension") + extension.callback_called = False + extension.on_register(f, args=(extension,)) + # Add extension + thing.register_extension(extension) + + # Check callback + assert extension.callback_called + + +def test_complete_url(thing): + thing.url_prefix = "" + assert thing._complete_url("", "") == "/" + assert thing._complete_url("", "api") == "/api" + assert thing._complete_url("/method", "api") == "/api/method" + + thing.url_prefix = "prefix" + assert thing._complete_url("", "") == "/prefix" + assert thing._complete_url("", "api") == "/prefix/api" + assert thing._complete_url("/method", "api") == "/prefix/api/method" + + +def test_url_for(thing, view_cls, app_ctx): + with app_ctx.test_request_context(): + # Before added, should return no URL + assert thing.url_for(view_cls) == "" + # Add view + thing.add_view(view_cls, "/index", endpoint="index") + # Check URLs + assert thing.url_for(view_cls, _external=False) == "/index" + assert all( + substring in thing.url_for(view_cls) for substring in ["http://", "/index"] + ) + + +def test_owns_endpoint(thing, view_cls, app_ctx): + assert not thing.owns_endpoint("index") + thing.add_view(view_cls, "/index", endpoint="index") + assert thing.owns_endpoint("index") + + +def test_add_root_link(thing, view_cls, app_ctx, schemas_path): + thing.add_root_link(view_cls, "rel") + assert { + "rel": "rel", + "view": view_cls, + "params": {}, + "kwargs": {}, + } in thing.thing_description._links + + +def test_td_add_link_options(thing, view_cls): + thing.add_root_link( + view_cls, "rel", kwargs={"kwarg": "kvalue"}, params={"param": "pvalue"} + ) + assert { + "rel": "rel", + "view": view_cls, + "params": {"param": "pvalue"}, + "kwargs": {"kwarg": "kvalue"}, + } in thing.thing_description._links + + +def test_description(thing): + assert thing.description == "" + thing.description = "description" + assert thing.description == "description" + assert thing.spec.description == "description" + + +def test_title(thing): + assert thing.title == "" + thing.title = "title" + assert thing.title == "title" + assert thing.spec.title == "title" + + +def test_safe_title(thing): + assert thing.title == "" + assert thing.safe_title == "unknown" + thing.title = "Example LabThing 001" + assert thing.safe_title == "examplelabthing001" + + +def test_version(thing): + assert thing.version == "0.0.0" + thing.version = "x.x.x" + assert thing.version == "x.x.x" + assert thing.spec.version == "x.x.x" diff --git a/tests/test_server_quick.py b/tests/test_server_quick.py new file mode 100644 index 00000000..72274f13 --- /dev/null +++ b/tests/test_server_quick.py @@ -0,0 +1,20 @@ +import pytest + +from labthings.server import quick + +from flask import Flask +from labthings.server.labthing import LabThing + + +def test_create_app(): + app, labthing = quick.create_app(__name__) + assert isinstance(app, Flask) + assert isinstance(labthing, LabThing) + + +def test_create_app_options(): + app, labthing = quick.create_app( + __name__, flask_kwargs={"static_url_path": "/static"}, handle_cors=False + ) + assert isinstance(app, Flask) + assert isinstance(labthing, LabThing) diff --git a/tests/test_server_representations.py b/tests/test_server_representations.py new file mode 100644 index 00000000..84e63a6f --- /dev/null +++ b/tests/test_server_representations.py @@ -0,0 +1,62 @@ +from labthings.server import representations +from flask import Flask, Response +import pytest + + +@pytest.fixture +def labthings_json_encoder(): + return representations.LabThingsJSONEncoder + + +def test_encoder_default_exception(labthings_json_encoder): + with pytest.raises(TypeError): + labthings_json_encoder().default("") + + +def test_encode_json(labthings_json_encoder): + data = { + "a": "String", + "b": 5, + "c": [10, 20, 30, 40, 50], + "d": {"a": "String", "b": 5, "c": [10, 20, 30, 40, 50]}, + } + assert ( + representations.encode_json(data, encoder=labthings_json_encoder) + == '{"a": "String", "b": 5, "c": [10, 20, 30, 40, 50], "d": {"a": "String", "b": 5, "c": [10, 20, 30, 40, 50]}}\n' + ) + + +def test_output_json(app_ctx): + data = { + "a": "String", + "b": 5, + "c": [10, 20, 30, 40, 50], + "d": {"a": "String", "b": 5, "c": [10, 20, 30, 40, 50]}, + } + + with app_ctx.test_request_context(): + response = representations.output_json(data, 200) + assert isinstance(response, Response) + assert response.status_code == 200 + assert ( + response.data + == b'{"a": "String", "b": 5, "c": [10, 20, 30, 40, 50], "d": {"a": "String", "b": 5, "c": [10, 20, 30, 40, 50]}}\n' + ) + + +def test_pretty_output_json(app_ctx_debug): + data = { + "a": "String", + "b": 5, + "c": [10, 20, 30, 40, 50], + "d": {"a": "String", "b": 5, "c": [10, 20, 30, 40, 50]}, + } + + with app_ctx_debug.test_request_context(): + response = representations.output_json(data, 200) + assert isinstance(response, Response) + assert response.status_code == 200 + assert ( + response.data + == b'{\n "a": "String",\n "b": 5,\n "c": [\n 10,\n 20,\n 30,\n 40,\n 50\n ],\n "d": {\n "a": "String",\n "b": 5,\n "c": [\n 10,\n 20,\n 30,\n 40,\n 50\n ]\n }\n}\n' + ) diff --git a/tests/test_server_schema.py b/tests/test_server_schema.py new file mode 100644 index 00000000..3bb6fea6 --- /dev/null +++ b/tests/test_server_schema.py @@ -0,0 +1,80 @@ +from labthings.server import schema +from labthings.server import fields + +from labthings.core.tasks.thread import TaskThread +from labthings.server.extensions import BaseExtension + + +def test_schema_json(app_ctx): + test_schema = schema.Schema.from_dict({"i": fields.Int(), "s": fields.String(),})() + + obj = type("obj", (object,), {"i": 5, "s": "string"}) + + with app_ctx.test_request_context(): + assert test_schema.jsonify(obj).data == b'{"i":5,"s":"string"}\n' + + +def test_schema_many(app_ctx): + test_schema = schema.Schema.from_dict({"i": fields.Int(), "s": fields.String(),})( + many=True + ) + + obj1 = type("obj1", (object,), {"i": 5, "s": "string1"}) + obj2 = type("obj2", (object,), {"i": 5, "s": "string2"}) + objs = [obj1, obj2] + + with app_ctx.test_request_context(): + assert ( + test_schema.jsonify(objs).data + == b'[{"i":5,"s":"string1"},{"i":5,"s":"string2"}]\n' + ) + + +def test_schema_json_many(app_ctx): + test_schema = schema.Schema.from_dict({"i": fields.Int(), "s": fields.String(),})() + + obj1 = type("obj1", (object,), {"i": 5, "s": "string1"}) + obj2 = type("obj2", (object,), {"i": 5, "s": "string2"}) + objs = [obj1, obj2] + + with app_ctx.test_request_context(): + assert ( + test_schema.jsonify(objs, many=True).data + == b'[{"i":5,"s":"string1"},{"i":5,"s":"string2"}]\n' + ) + + +def test_field_schema(app_ctx): + test_schema = schema.FieldSchema(fields.String()) + + assert test_schema.serialize(5) == "5" + assert test_schema.dump(5) == "5" + assert test_schema.deserialize("string") == "string" + assert test_schema.jsonify(5).data == b'"5"\n' + + +def test_task_schema(app_ctx): + test_schema = schema.TaskSchema() + test_task_thread = TaskThread() + + with app_ctx.test_request_context(): + d = test_schema.dump(test_task_thread) + assert isinstance(d, dict) + assert "data" in d + assert "links" in d + assert isinstance(d.get("links"), dict) + assert "self" in d.get("links") + assert d.get("function") == "None(args=(), kwargs={})" + + +def test_extension_schema(app_ctx): + test_schema = schema.ExtensionSchema() + test_extension = BaseExtension("org.labthings.tests.extension") + + with app_ctx.test_request_context(): + d = test_schema.dump(test_extension) + assert isinstance(d, dict) + assert "pythonName" in d + assert d.get("pythonName") == "org.labthings.tests.extension" + assert "links" in d + assert isinstance(d.get("links"), dict) diff --git a/tests/test_server_sockets.py b/tests/test_server_sockets.py new file mode 100644 index 00000000..91b5e673 --- /dev/null +++ b/tests/test_server_sockets.py @@ -0,0 +1,181 @@ +from labthings.server.sockets import base, gevent as gsocket + +import json +from flask import Blueprint +from werkzeug.routing import Map + + +def test_socket_subscriber_property_notify(view_cls, fake_websocket): + setattr(view_cls, "endpoint", "index") + ws = fake_websocket("", recieve_once=True) + sub = base.SocketSubscriber(ws) + + sub.property_notify(view_cls) + assert json.loads(ws.response) == { + "messageType": "propertyStatus", + "data": {"index": "GET"}, + } + + +def test_socket_subscriber_property_notify_empty_view(flask_view_cls, fake_websocket): + ws = fake_websocket("", recieve_once=True) + sub = base.SocketSubscriber(ws) + + sub.property_notify(flask_view_cls) + assert json.loads(ws.response) == { + "messageType": "propertyStatus", + "data": {flask_view_cls.__name__: None}, + } + + +def test_socket_subscriber_event_notify(fake_websocket): + ws = fake_websocket("", recieve_once=True) + sub = base.SocketSubscriber(ws) + + data = {"key": "value"} + + sub.event_notify(data) + assert json.loads(ws.response) == {"messageType": "event", "data": data} + + +def test_sockets_flask_init(app): + original_wsgi_app = app.wsgi_app + socket = gsocket.Sockets(app) + assert socket + # Check new wsgi_app + assert isinstance(app.wsgi_app, gsocket.SocketMiddleware) + # Check "fallback" wsgi_app. This should be the original app.wsgi_app + assert app.wsgi_app.wsgi_app == original_wsgi_app + + +def test_sockets_flask_delayed_init(app): + original_wsgi_app = app.wsgi_app + socket = gsocket.Sockets() + socket.init_app(app) + assert socket + # Check new wsgi_app + assert isinstance(app.wsgi_app, gsocket.SocketMiddleware) + # Check "fallback" wsgi_app. This should be the original app.wsgi_app + assert app.wsgi_app.wsgi_app == original_wsgi_app + + +def test_sockets_flask_route(app): + socket = gsocket.Sockets(app) + + @socket.route("/ws") + def ws_view_func(ws): + pass + + # Assert ws_view_func was added to the Sockets URL map + passed = False + for rule in socket.url_map.iter_rules(): + if rule.endpoint == ws_view_func: + passed = True + assert passed + + +def test_sockets_flask_blueprint(app): + socket = gsocket.Sockets(app) + + bp = Blueprint("blueprint", __name__) + + @bp.route("/ws") + def ws_view_func(ws): + pass + + socket.register_blueprint(bp, url_prefix="/") + + # Assert ws_view_func was added to the Sockets URL map + passed = False + for rule in socket.url_map.iter_rules(): + if rule.endpoint == ws_view_func: + passed = True + assert passed + + # Test re-register same blueprint (should pass) + socket.register_blueprint(bp, url_prefix="/") + + +def test_socket_middleware_http(app, client): + socket = gsocket.Sockets(app) + + @socket.route("/") + def ws_view_func(ws): + ws.send("WS") + + @app.route("/") + def http_view_func(): + return "GET" + + # Assert ws_view_func was added to the Sockets URL map + with client as c: + assert c.get("/").data == b"GET" + + +def test_socket_middleware_ws(app, ws_client): + socket = gsocket.Sockets(app) + + @socket.route("/") + def ws_view_func(ws): + msg = ws.recieve() + ws.send(msg) + + @app.route("/") + def http_view_func(): + return "GET" + + # Assert ws_view_func was added to the Sockets URL map + with ws_client as c: + assert c.connect("/", message="hello") == ["hello"] + + +def test_socket_middleware_add_view(app, ws_client): + socket = gsocket.Sockets(app) + + def ws_view_func(ws): + msg = ws.recieve() + ws.send(msg) + + socket.add_view("/", ws_view_func) + + # Assert ws_view_func was added to the Sockets URL map + with ws_client as c: + assert c.connect("/", message="hello") == ["hello"] + + +def test_socket_middleware_http_fallback(app, ws_client): + gsocket.Sockets(app) + + @app.route("/") + def http_view_func(): + return "GET" + + # Assert ws_view_func was added to the Sockets URL map + with ws_client as c: + assert c.get("/").data == b"GET" + + +def test_socket_middleware_ws_http_cookie(app, ws_client): + socket = gsocket.Sockets(app) + + @socket.route("/") + def ws_view_func(ws): + msg = ws.recieve() + ws.send(msg) + + # Assert ws_view_func was added to the Sockets URL map + with ws_client as c: + c.environ_base["HTTP_COOKIE"] = {"key": "value"} + assert c.connect("/", message="hello") == ["hello"] + + +def test_socket_handler_loop(fake_websocket): + ws = fake_websocket("hello", recieve_once=True) + + gsocket.socket_handler_loop(ws) + + +### Will need regular updating as new message handlers are added +def test_process_socket_message(): + assert base.process_socket_message("message") is None + assert base.process_socket_message(None) is None diff --git a/tests/test_server_spec_apispec.py b/tests/test_server_spec_apispec.py new file mode 100644 index 00000000..723fb3fb --- /dev/null +++ b/tests/test_server_spec_apispec.py @@ -0,0 +1,238 @@ +import pytest + +from labthings.server.spec import apispec +from labthings.server.view import View + +from labthings.server import fields + + +def test_method_to_apispec_operation_no_spec(spec): + class Index(View): + def get(self): + return "GET" + + def post(self): + return "POST" + + assert apispec.method_to_apispec_operation(Index.get, spec) == { + "responses": {200: {"description": "OK"}} + } + + +def test_method_to_apispec_operation_params(spec): + class Index(View): + def get(self): + return "GET" + + Index.get.__apispec__ = {"_params": {"integer": fields.Int()}} + + assert apispec.method_to_apispec_operation(Index.get, spec) == { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "integer": {"type": "integer", "format": "int32"} + }, + } + } + } + }, + "responses": {200: {"description": "OK"}}, + } + + +def test_method_to_apispec_operation_schema(spec): + class Index(View): + def get(self): + return "GET" + + Index.get.__apispec__ = {"_schema": {200: {"integer": fields.Int()}}} + + assert apispec.method_to_apispec_operation(Index.get, spec) == { + "responses": { + 200: { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "integer": {"type": "integer", "format": "int32"} + }, + } + } + }, + } + } + } + + +def test_method_to_apispec_operation_extra_fields(spec): + class Index(View): + def get(self): + return "GET" + + Index.get.__apispec__ = {"summary": "A summary"} + + assert apispec.method_to_apispec_operation(Index.get, spec) == { + "summary": "A summary", + "responses": {200: {"description": "OK"}}, + } + + +def test_view_to_apispec_operations(spec): + class Index(View): + def get(self): + return "GET" + + def post(self): + return "POST" + + assert apispec.view_to_apispec_operations(Index, spec) == { + "post": { + "description": None, + "summary": None, + "tags": set(), + "responses": {200: {"description": "OK"}}, + }, + "get": { + "description": None, + "summary": None, + "tags": set(), + "responses": {200: {"description": "OK"}}, + }, + } + + +def test_rule_to_apispec_path(app, spec): + class Index(View): + def get(self): + return "GET" + + def post(self): + return "POST" + + app.add_url_rule("/path", view_func=Index.as_view("index")) + rule = app.url_map._rules_by_endpoint["index"][0] + assert apispec.rule_to_apispec_path(rule, Index, spec) == { + "path": "/path", + "operations": { + "get": { + "description": None, + "summary": None, + "tags": set(), + "responses": {200: {"description": "OK"}}, + }, + "post": { + "description": None, + "summary": None, + "tags": set(), + "responses": {200: {"description": "OK"}}, + }, + }, + "description": None, + "summary": None, + "tags": set(), + } + + +def test_rule_to_apispec_path_params(app, spec): + class Index(View): + def get(self): + return "GET" + + def post(self): + return "POST" + + app.add_url_rule("/path//", view_func=Index.as_view("index")) + rule = app.url_map._rules_by_endpoint["index"][0] + assert apispec.rule_to_apispec_path(rule, Index, spec) == { + "path": "/path/{id}/", + "operations": { + "get": { + "description": None, + "summary": None, + "tags": set(), + "responses": {200: {"description": "OK"}}, + "parameters": [ + { + "in": "path", + "name": "id", + "required": True, + "schema": {"type": "string"}, + } + ], + }, + "post": { + "description": None, + "summary": None, + "tags": set(), + "responses": {200: {"description": "OK"}}, + "parameters": [ + { + "in": "path", + "name": "id", + "required": True, + "schema": {"type": "string"}, + } + ], + }, + }, + "description": None, + "summary": None, + "tags": set(), + } + + +def test_rule_to_apispec_path_extra_class_params(app, spec): + class Index(View): + def get(self): + return "GET" + + Index.__apispec__ = {"summary": "A class summary"} + + app.add_url_rule("/path", view_func=Index.as_view("index")) + rule = app.url_map._rules_by_endpoint["index"][0] + + assert apispec.rule_to_apispec_path(rule, Index, spec) == { + "path": "/path", + "operations": { + "get": { + "description": None, + "summary": "A class summary", + "tags": set(), + "responses": {200: {"description": "OK"}}, + } + }, + "description": None, + "summary": "A class summary", + "tags": set(), + } + + +def test_rule_to_apispec_path_extra_method_params(app, spec): + class Index(View): + def get(self): + return "GET" + + Index.get.__apispec__ = {"summary": "A GET summary"} + + app.add_url_rule("/path", view_func=Index.as_view("index")) + rule = app.url_map._rules_by_endpoint["index"][0] + + assert apispec.rule_to_apispec_path(rule, Index, spec) == { + "path": "/path", + "operations": { + "get": { + "description": None, + "summary": "A GET summary", + "tags": set(), + "responses": {200: {"description": "OK"}}, + } + }, + "description": None, + "summary": None, + "tags": set(), + } diff --git a/tests/test_server_spec_paths.py b/tests/test_server_spec_paths.py new file mode 100644 index 00000000..9a4ca52b --- /dev/null +++ b/tests/test_server_spec_paths.py @@ -0,0 +1,73 @@ +import pytest + +from labthings.server.spec import paths + + +def make_rule(app, path, **kwargs): + @app.route(path, **kwargs) + def view(): + pass + + return app.url_map._rules_by_endpoint["view"][0] + + +def make_param(in_location="path", **kwargs): + ret = {"in": in_location, "required": True} + ret.update(kwargs) + return ret + + +def test_rule_to_path(app): + rule = make_rule(app, "/path//") + assert paths.rule_to_path(rule) == "/path/{id}/" + + +def test_rule_to_param(app): + rule = make_rule(app, "/path//") + assert paths.rule_to_params(rule) == [ + {"in": "path", "name": "id", "required": True, "schema": {"type": "string"}} + ] + + +def test_rule_to_param_typed(app): + rule = make_rule(app, "/path//") + assert paths.rule_to_params(rule) == [ + { + "in": "path", + "name": "id", + "required": True, + "schema": {"type": "integer"}, + "format": "int32", + } + ] + + +def test_rule_to_param_typed_default(app): + rule = make_rule(app, "/path//", defaults={"id": 1}) + assert paths.rule_to_params(rule) == [ + { + "in": "path", + "name": "id", + "required": True, + "default": 1, + "schema": {"type": "integer"}, + "format": "int32", + } + ] + + +def test_rule_to_param_overrides(app): + rule = make_rule(app, "/path//") + overrides = {"override_key": {"in": "header", "name": "header_param"}} + assert paths.rule_to_params(rule, overrides=overrides) == [ + {"in": "path", "name": "id", "required": True, "schema": {"type": "string"}}, + *overrides.values(), + ] + + +def test_rule_to_param_overrides_invalid(app): + rule = make_rule(app, "/path//") + overrides = {"override_key": {"in": "invalid", "name": "header_param"}} + assert paths.rule_to_params(rule, overrides=overrides) == [ + {"in": "path", "name": "id", "required": True, "schema": {"type": "string"}} + ] diff --git a/tests/test_server_spec_td.py b/tests/test_server_spec_td.py new file mode 100644 index 00000000..57e51aa9 --- /dev/null +++ b/tests/test_server_spec_td.py @@ -0,0 +1,191 @@ +import pytest + +import os +import json +import jsonschema +from labthings.server import fields +from labthings.server.view import View +from labthings.server.spec import td + + +@pytest.fixture +def thing_description(thing): + return thing.thing_description + + +def validate_thing_description(thing_description, app_ctx, schemas_path): + schema = json.load(open(os.path.join(schemas_path, "w3_wot_td_v1.json"), "r")) + jsonschema.Draft7Validator.check_schema(schema) + + with app_ctx.test_request_context(): + td_json = thing_description.to_dict() + assert td_json + + jsonschema.validate(instance=td_json, schema=schema) + + +def test_find_schema_for_view_readonly(): + class ViewClass: + def get(self): + pass + + ViewClass.get.__apispec__ = {"_schema": {200: "schema"}} + assert td.find_schema_for_view(ViewClass) == "schema" + + +def test_find_schema_for_view_writeonly_post(): + class ViewClass: + def post(self): + pass + + ViewClass.post.__apispec__ = {"_params": "params"} + assert td.find_schema_for_view(ViewClass) == "params" + + +def test_find_schema_for_view_writeonly_put(): + class ViewClass: + def put(self): + pass + + ViewClass.put.__apispec__ = {"_params": "params"} + assert td.find_schema_for_view(ViewClass) == "params" + + +def test_find_schema_for_view_none(): + class ViewClass: + pass + + assert td.find_schema_for_view(ViewClass) == {} + + +def test_td_init(thing_description, app_ctx, schemas_path): + assert thing_description + + validate_thing_description(thing_description, app_ctx, schemas_path) + + +def test_td_add_link(thing_description, view_cls, app_ctx, schemas_path): + thing_description.add_link(view_cls, "rel") + assert { + "rel": "rel", + "view": view_cls, + "params": {}, + "kwargs": {}, + } in thing_description._links + + validate_thing_description(thing_description, app_ctx, schemas_path) + + +def test_td_add_link_options(thing_description, view_cls): + thing_description.add_link( + view_cls, "rel", kwargs={"kwarg": "kvalue"}, params={"param": "pvalue"} + ) + assert { + "rel": "rel", + "view": view_cls, + "params": {"param": "pvalue"}, + "kwargs": {"kwarg": "kvalue"}, + } in thing_description._links + + +def test_td_links(thing_description, app_ctx, view_cls): + thing_description.add_link( + view_cls, "rel", kwargs={"kwarg": "kvalue"}, params={"param": "pvalue"} + ) + + with app_ctx.test_request_context(): + assert {"rel": "rel", "href": "", "kwarg": "kvalue"} in ( + thing_description.links + ) + + +def test_td_action(app, thing_description, view_cls, app_ctx, schemas_path): + app.add_url_rule("/", view_func=view_cls.as_view("index")) + rules = app.url_map._rules_by_endpoint["index"] + + thing_description.action(rules, view_cls) + + with app_ctx.test_request_context(): + assert "index" in thing_description.to_dict().get("actions") + validate_thing_description(thing_description, app_ctx, schemas_path) + + +def test_td_action_with_schema(app, thing_description, view_cls, app_ctx, schemas_path): + view_cls.post.__apispec__ = {"_params": {"integer": fields.Int()}} + + app.add_url_rule("/", view_func=view_cls.as_view("index")) + rules = app.url_map._rules_by_endpoint["index"] + + thing_description.action(rules, view_cls) + + with app_ctx.test_request_context(): + assert "index" in thing_description.to_dict().get("actions") + assert thing_description.to_dict().get("actions").get("index").get("input") == { + "type": "object", + "properties": {"integer": {"type": "integer", "format": "int32"}}, + } + validate_thing_description(thing_description, app_ctx, schemas_path) + + +def test_td_property(app, thing_description, app_ctx, schemas_path): + class Index(View): + def get(self): + return "GET" + + app.add_url_rule("/", view_func=Index.as_view("index")) + rules = app.url_map._rules_by_endpoint["index"] + + thing_description.property(rules, Index) + + with app_ctx.test_request_context(): + assert "index" in thing_description.to_dict().get("properties") + validate_thing_description(thing_description, app_ctx, schemas_path) + + +def test_td_property_with_schema(app, thing_description, app_ctx, schemas_path): + class Index(View): + def get(self): + return "GET" + + Index.__apispec__ = {"_propertySchema": {"integer": fields.Int()}} + + app.add_url_rule("/", view_func=Index.as_view("index")) + rules = app.url_map._rules_by_endpoint["index"] + + thing_description.property(rules, Index) + + with app_ctx.test_request_context(): + assert "index" in thing_description.to_dict().get("properties") + validate_thing_description(thing_description, app_ctx, schemas_path) + + +def test_td_property_with_url_param(app, thing_description, app_ctx, schemas_path): + class Index(View): + def get(self): + return "GET" + + app.add_url_rule("/path//", view_func=Index.as_view("index")) + rules = app.url_map._rules_by_endpoint["index"] + + thing_description.property(rules, Index) + + with app_ctx.test_request_context(): + assert "index" in thing_description.to_dict().get("properties") + validate_thing_description(thing_description, app_ctx, schemas_path) + + +def test_td_property_write_only(app, thing_description, app_ctx, schemas_path): + class Index(View): + def post(self): + return "POST" + + Index.__apispec__ = {"_propertySchema": fields.Int()} + + app.add_url_rule("/", view_func=Index.as_view("index")) + rules = app.url_map._rules_by_endpoint["index"] + + thing_description.property(rules, Index) + + with app_ctx.test_request_context(): + assert "index" in thing_description.to_dict().get("properties") + validate_thing_description(thing_description, app_ctx, schemas_path) diff --git a/tests/test_server_spec_utilities.py b/tests/test_server_spec_utilities.py new file mode 100644 index 00000000..2b8fdd35 --- /dev/null +++ b/tests/test_server_spec_utilities.py @@ -0,0 +1,184 @@ +from labthings.server.spec import utilities +import json +from marshmallow import fields +import pytest + + +def test_initial_update_spec(view_cls): + initial_spec = {"key": "value"} + utilities.update_spec(view_cls, initial_spec) + + assert view_cls.__apispec__ == initial_spec + + +def test_update_spec(view_cls): + initial_spec = {"key": {"subkey": "value"}} + utilities.update_spec(view_cls, initial_spec) + + new_spec = {"key": {"new_subkey": "new_value"}} + utilities.update_spec(view_cls, new_spec) + + assert view_cls.__apispec__ == { + "key": {"subkey": "value", "new_subkey": "new_value"} + } + + +def test_get_spec(view_cls): + assert utilities.get_spec(None) == {} + assert utilities.get_spec(view_cls) == {} + + initial_spec = {"key": {"subkey": "value"}} + view_cls.__apispec__ = initial_spec + + assert utilities.get_spec(view_cls) == initial_spec + + +def test_get_topmost_spec_attr(view_cls): + assert not utilities.get_topmost_spec_attr(view_cls, "key") + + # Root value missing, fall back to GET + view_cls.get.__apispec__ = {"key": "get_value"} + assert utilities.get_topmost_spec_attr(view_cls, "key") == "get_value" + + # Root value present, return root value + view_cls.__apispec__ = {"key": "class_value"} + assert utilities.get_topmost_spec_attr(view_cls, "key") == "class_value" + + +def test_convert_schema_none(spec): + assert not utilities.convert_schema(None, spec) + + +def test_convert_schema_schema(spec): + from marshmallow import Schema + + schema = Schema() + schema.integer = fields.Int() + assert utilities.convert_schema(schema, spec) is schema + + +def test_convert_schema_map(spec): + schema = {"integer": fields.Int()} + assert utilities.convert_schema(schema, spec) == { + "type": "object", + "properties": {"integer": {"type": "integer", "format": "int32"}}, + } + + +def test_convert_schema_field(spec): + schema = fields.Int() + assert utilities.convert_schema(schema, spec) == { + "type": "integer", + "format": "int32", + } + + +def test_convert_schema_invalid(spec): + schema = object() + + with pytest.raises(TypeError): + utilities.convert_schema(schema, spec) + + +def test_map_to_schema_nested(spec): + schema = {"submap": {"integer": fields.Int()}} + + assert utilities.map_to_properties(schema, spec) == { + "type": "object", + "properties": { + "submap": { + "type": "object", + "properties": {"integer": {"type": "integer", "format": "int32"}}, + } + }, + } + + +def test_map_to_schema_json(spec): + schema = {"key": "value"} + + assert utilities.map_to_properties(schema, spec) == { + "type": "object", + "properties": {"key": "value"}, + } + + +def test_schema_to_json(spec): + from marshmallow import Schema + + UserSchema = Schema.from_dict({"name": fields.Str(), "email": fields.Email()}) + + assert utilities.schema_to_json(UserSchema(), spec) == { + "type": "object", + "properties": { + "name": {"type": "string"}, + "email": {"type": "string", "format": "email"}, + }, + } + + +def test_schema_to_json_json_in(spec): + from marshmallow import Schema + + input_dict = { + "type": "object", + "properties": { + "name": {"type": "string"}, + "email": {"type": "string", "format": "email"}, + }, + } + + assert utilities.schema_to_json(input_dict, spec) == input_dict + + +def test_recursive_expand_refs(spec): + from marshmallow import Schema + + UserSchema = Schema.from_dict({"name": fields.Str(), "email": fields.Email()}) + TestParentSchema = Schema.from_dict({"author": fields.Nested(UserSchema)}) + + assert utilities.schema_to_json(TestParentSchema(), spec) == { + "type": "object", + "properties": { + "author": { + "type": "object", + "properties": { + "email": {"type": "string", "format": "email"}, + "name": {"type": "string"}, + }, + } + }, + } + + +def test_recursive_expand_refs_schema_in(spec): + from marshmallow import Schema + + UserSchema = Schema.from_dict({"name": fields.Str(), "email": fields.Email()}) + + user_schema_instance = UserSchema() + assert ( + utilities.recursive_expand_refs(user_schema_instance, spec) + == user_schema_instance + ) + + +### TODO: Test expand_refs + + +def test_expand_refs_no_refs(spec): + input_dict = { + "type": "object", + "properties": { + "name": {"type": "string"}, + "email": {"type": "string", "format": "email"}, + }, + } + + assert utilities.expand_refs(input_dict, spec) == input_dict + + +def test_expand_refs_missing_schema(spec): + input_dict = {"$ref": "MissingRef"} + + assert utilities.expand_refs(input_dict, spec) == input_dict diff --git a/tests/test_server_types.py b/tests/test_server_types.py index c3bc1861..eee59284 100644 --- a/tests/test_server_types.py +++ b/tests/test_server_types.py @@ -1,4 +1,5 @@ from labthings.server import types, fields +from labthings.server.types.registry import TypeRegistry, _field_factory import pytest from fractions import Fraction @@ -31,8 +32,19 @@ def types_dict(): return d, s -def test_make_primative(): - assert types.make_primative(Fraction(5, 2)) == 2.5 +def test_make_primitive(): + import numpy + + generic_object = object() + + assert types.make_primitive(Fraction(5, 2)) == 2.5 + assert types.make_primitive(numpy.array([1, 2, 3])) == [1, 2, 3] + assert types.make_primitive(numpy.int16(10)) == 10 + + assert type(types.make_primitive(generic_object)) == str + assert types.make_primitive(generic_object).startswith("