From e3fae1d45d6c7488e4048f6b5f04e909da7c1adb Mon Sep 17 00:00:00 2001 From: "Joel T. Collins" Date: Tue, 11 Feb 2020 13:13:23 +0000 Subject: [PATCH] Deepsource fixes (#19) * Ignore all virtual envs * Fixed PYL-W0102 * State test_patterns * Fix BAN-B101 * Fixed FLK-E501 regression * Skip PYL-E1111 * Skip PYL-W0703 * Fix PYL-W0404 * Fix PYL-W0621 * Clean up unused imports * Fix FLK-E266 * Better variable names * Better global variable naming * Fixed docstring utilities * Fixed FLK-E501 * Cleaned up some trailing whitespace * Fixed PYL-R1705 * Fixed FLK-W291 * Started adding missing docstrings * Removed blank line whitespace * Fix FLK-D200 * Fixed OrderedDict import * Started fixing PYL-W0212 * Created FieldSchema for single-field data validation * Silence PYL-W0212 (no alternative found) * Added a note on our _get_current_object use * Fixed some more PYL-W0212 * Fixed FLK-W293 * Fixed PYL-W0611 * Added dependency_file_paths --- .deepsource.toml | 11 +++ .gitignore | 4 +- examples/nested_thing.py | 23 +++--- examples/properties_dictionary.py | 11 +-- examples/simple_thing.py | 14 ++-- labthings/core/lock.py | 11 +-- labthings/core/tasks/pool.py | 51 +++++++++--- labthings/core/tasks/thread.py | 17 +++- labthings/core/utilities.py | 113 +++++++++++++++++++++------ labthings/server/decorators.py | 8 +- labthings/server/extensions.py | 4 +- labthings/server/find.py | 5 +- labthings/server/labthing.py | 23 +++--- labthings/server/quick.py | 5 ++ labthings/server/schema.py | 44 +++++++++++ labthings/server/spec/paths.py | 4 +- labthings/server/spec/utilities.py | 20 +++-- labthings/server/types.py | 5 +- labthings/server/view.py | 10 ++- labthings/server/views/extensions.py | 10 +-- tests/test_core_utilities.py | 7 +- tests/test_server_types.py | 6 +- 22 files changed, 294 insertions(+), 112 deletions(-) diff --git a/.deepsource.toml b/.deepsource.toml index 25bc3d76..bbae29e9 100644 --- a/.deepsource.toml +++ b/.deepsource.toml @@ -1,8 +1,19 @@ version = 1 +# Glob pattern of the test files +test_patterns = [ + "tests/**", + "test_*.py" +] + [[analyzers]] name = "python" enabled = true +dependency_file_paths = [ + "poetry.lock", + "pyproject.toml" +] + [analyzers.meta] runtime_version = "3.x.x" diff --git a/.gitignore b/.gitignore index 21f0c0a9..38b3e274 100644 --- a/.gitignore +++ b/.gitignore @@ -102,8 +102,8 @@ celerybeat.pid *.sage.py # Environments -.env -.venv +.env* +.venv* env/ venv/ ENV/ diff --git a/examples/nested_thing.py b/examples/nested_thing.py index acc332e0..5f9719b4 100644 --- a/examples/nested_thing.py +++ b/examples/nested_thing.py @@ -17,9 +17,9 @@ class DataSet: - def __init__(self, xs, ys): - self.xs = xs - self.ys = ys + def __init__(self, x_values, y_values): + self.xs = x_values + self.ys = y_values class DataSetSchema(Schema): @@ -29,19 +29,19 @@ class DataSetSchema(Schema): class MyComponent: def __init__(self): - self.id = uuid.uuid4() + self.id = uuid.uuid4() # skipcq: PYL-C0103 self.x_range = range(-100, 100) self.magic_denoise = 200 - def noisy_pdf(self, x, mu=0.0, sigma=25.0): + def noisy_pdf(self, x_value, mu=0.0, sigma=25.0): """ Generate a noisy gaussian function (to act as some pretend data) Our noise is inversely proportional to self.magic_denoise """ - x = float(x - mu) / sigma + x_value = float(x_value - mu) / sigma return ( - math.exp(-x * x / 2.0) / math.sqrt(2.0 * math.pi) / sigma + math.exp(-x_value * x_value / 2.0) / math.sqrt(2.0 * math.pi) / sigma + (1 / self.magic_denoise) * random.random() ) @@ -60,12 +60,15 @@ class MyComponentSchema(Schema): """ -Create a view to view and change our magic_denoise value, and register is as a Thing property +Create a view to view and change our magic_denoise value, +and register is as a Thing property """ -@ThingProperty # Register this view as a Thing Property -@PropertySchema( # Define the data we're going to output (get), and what to expect in (post) +# Register this view as a Thing Property +@ThingProperty +# Define the data we're going to output (get), and what to expect in (post) +@PropertySchema( fields.Integer( required=True, example=200, diff --git a/examples/properties_dictionary.py b/examples/properties_dictionary.py index 6abf0dc7..38e17e6c 100644 --- a/examples/properties_dictionary.py +++ b/examples/properties_dictionary.py @@ -46,7 +46,8 @@ def get_state_schema(self): """ -Create a view to view and change our magic_denoise value, and register is as a Thing property +Create a view to view and change our magic_denoise value, +and register is as a Thing property """ @@ -59,18 +60,18 @@ def get(self): """Show the current magic_denoise value""" # When a GET request is made, we'll find our attached component - my_component = find_component("org.labthings.example.mycomponent") - return my_component.get_state() + found_my_component = find_component("org.labthings.example.mycomponent") + return found_my_component.get_state() # Main function to handle PUT requests (update) def put(self, new_property_value): """Change the current magic_denoise value""" # Find our attached component - my_component = find_component("org.labthings.example.mycomponent") + found_my_component = find_component("org.labthings.example.mycomponent") # Apply the new value - return my_component.set_state(new_property_value) + return found_my_component.set_state(new_property_value) # Create LabThings Flask app diff --git a/examples/simple_thing.py b/examples/simple_thing.py index 2d099663..090f1a90 100644 --- a/examples/simple_thing.py +++ b/examples/simple_thing.py @@ -8,9 +8,7 @@ ThingProperty, PropertySchema, use_args, - use_body, marshal_task, - marshal_with, ) from labthings.server.view import View from labthings.server.find import find_component @@ -64,12 +62,15 @@ def average_data(self, n: int): """ -Create a view to view and change our magic_denoise value, and register is as a Thing property +Create a view to view and change our magic_denoise value, +and register is as a Thing property """ -@ThingProperty # Register this view as a Thing Property -@PropertySchema( # Define the data we're going to output (get), and what to expect in (post) +# Register this view as a Thing Property +@ThingProperty +# Define the data we're going to output (get), and what to expect in (post) +@PropertySchema( fields.Integer( required=True, example=200, @@ -125,7 +126,8 @@ def get(self): @ThingAction class MeasurementAction(View): - # Expect JSON parameters in the request body. Pass to post function as dictionary argument. + # Expect JSON parameters in the request body. + # Pass to post function as dictionary argument. @use_args( { "averages": fields.Integer( diff --git a/labthings/core/lock.py b/labthings/core/lock.py index df0351c9..a42b7af8 100644 --- a/labthings/core/lock.py +++ b/labthings/core/lock.py @@ -5,14 +5,15 @@ class StrictLock(object): """ - Class that behaves like a Python RLock, but with stricter timeout conditions and custom exceptions. + Class that behaves like a Python RLock, + but with stricter timeout conditions and custom exceptions. Args: - timeout (int): Time, in seconds, lock acquisition will wait before raising an exception + timeout (int): Time in seconds acquisition will wait before raising an exception Attributes: _lock (:py:class:`threading.RLock`): Parent RLock object - timeout (int): Time, in seconds, lock acquisition will wait before raising an exception + timeout (int): Time in seconds acquisition will wait before raising an exception """ def __init__(self, timeout=1): @@ -46,11 +47,11 @@ class CompositeLock(object): Args: locks (list): List of parent RLock objects - timeout (int): Time, in seconds, lock acquisition will wait before raising an exception + timeout (int): Time in seconds acquisition will wait before raising an exception Attributes: locks (list): List of parent RLock objects - timeout (int): Time, in seconds, lock acquisition will wait before raising an exception + timeout (int): Time in seconds acquisition will wait before raising an exception """ def __init__(self, locks, timeout=1): diff --git a/labthings/core/tasks/pool.py b/labthings/core/tasks/pool.py index 6c152fa5..8406edbe 100644 --- a/labthings/core/tasks/pool.py +++ b/labthings/core/tasks/pool.py @@ -63,8 +63,8 @@ def tasks(): Returns: list: List of tasks in default taskmaster """ - global _default_task_master - return _default_task_master.tasks + global DEFAULT_TASK_MASTER + return DEFAULT_TASK_MASTER.tasks def dict(): @@ -73,8 +73,8 @@ def dict(): Returns: dict: Dictionary of tasks in default taskmaster """ - global _default_task_master - return _default_task_master.dict + global DEFAULT_TASK_MASTER + return DEFAULT_TASK_MASTER.dict def states(): @@ -83,24 +83,37 @@ def states(): Returns: dict: Dictionary of task states in default taskmaster """ - global _default_task_master - return _default_task_master.states + global DEFAULT_TASK_MASTER + return DEFAULT_TASK_MASTER.states def cleanup_tasks(): - global _default_task_master - return _default_task_master.cleanup() + """Remove all finished tasks from the task list""" + global DEFAULT_TASK_MASTER + return DEFAULT_TASK_MASTER.cleanup() def remove_task(task_id: str): - global _default_task_master - return _default_task_master.remove(task_id) + """Remove a particular task from the task list + + Arguments: + task_id {str} -- ID of the target task + """ + global DEFAULT_TASK_MASTER + return DEFAULT_TASK_MASTER.remove(task_id) # Operations on the current task def current_task(): + """Return the Task instance in which the caller is currently running. + + If this function is called from outside a Task thread, it will return None. + + Returns: + TaskThread -- Currently running Task thread. + """ current_task_thread = threading.current_thread() if not isinstance(current_task_thread, TaskThread): return None @@ -108,6 +121,13 @@ def current_task(): def update_task_progress(progress: int): + """Update the progress of the Task in which the caller is currently running. + + If this function is called from outside a Task thread, it will do nothing. + + Arguments: + progress {int} -- Current progress, in percent (0-100) + """ if current_task(): current_task().update_progress(progress) else: @@ -115,6 +135,13 @@ def update_task_progress(progress: int): def update_task_data(data: dict): + """Update the data of the Task in which the caller is currently running. + + If this function is called from outside a Task thread, it will do nothing. + + Arguments: + data {dict} -- Additional data to merge with the Task data + """ if current_task(): current_task().update_data(data) else: @@ -132,7 +159,7 @@ def taskify(f): @wraps(f) def wrapped(*args, **kwargs): - task = _default_task_master.new( + task = DEFAULT_TASK_MASTER.new( f, *args, **kwargs ) # Append to parent object's task list task.start() # Start the function @@ -142,4 +169,4 @@ def wrapped(*args, **kwargs): # Create our default, protected, module-level task pool -_default_task_master = TaskMaster() +DEFAULT_TASK_MASTER = TaskMaster() diff --git a/labthings/core/tasks/thread.py b/labthings/core/tasks/thread.py index d3b06c34..43d85e8a 100644 --- a/labthings/core/tasks/thread.py +++ b/labthings/core/tasks/thread.py @@ -14,7 +14,7 @@ class ThreadTerminationError(SystemExit): class TaskThread(threading.Thread): - def __init__(self, target=None, name=None, args=(), kwargs={}, daemon=True): + def __init__(self, target=None, name=None, args=None, kwargs=None, daemon=True): threading.Thread.__init__( self, group=None, @@ -24,6 +24,11 @@ def __init__(self, target=None, name=None, args=(), kwargs={}, daemon=True): kwargs=kwargs, daemon=daemon, ) + # Handle arguments + if args is None: + args = () + if kwargs is None: + kwargs = {} # A UUID for the TaskThread (not the same as the threading.Thread ident) self._ID = uuid.uuid4() # Task ID @@ -96,7 +101,7 @@ def wrapped(*args, **kwargs): try: self._return_value = f(*args, **kwargs) self._status = "success" - except Exception as e: + except Exception as e: # skipcq: PYL-W0703 logging.error(e) logging.error(traceback.format_exc()) self._return_value = str(e) @@ -133,7 +138,11 @@ def wait(self): def async_raise(self, exc_type): """Raise an exception in this thread.""" # Should only be called on a started thread, so raise otherwise. - assert self.ident is not None, "Only started threads have thread identifier" + 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(): @@ -166,7 +175,7 @@ def _is_thread_proc_running(self): Returns: bool: If _thread_proc is currently running """ - could_acquire = self._running_lock.acquire(0) + could_acquire = self._running_lock.acquire(False) # skipcq: PYL-E1111 if could_acquire: self._running_lock.release() return False diff --git a/labthings/core/utilities.py b/labthings/core/utilities.py index 0b3f9d9c..bf361f60 100644 --- a/labthings/core/utilities.py +++ b/labthings/core/utilities.py @@ -1,48 +1,73 @@ import collections.abc import re -import base64 import operator import sys from functools import reduce -try: - from collections.abc import OrderedDict -except ImportError: - from collections import OrderedDict - PY3 = sys.version_info > (3,) -def get_docstring(obj): +def get_docstring(obj, remove_newlines=True): + """Return the docstring of an object + + Args: + obj: Any Python object + remove_newlines (bool): Remove newlines from the docstring (default: {True}) + + Returns: + str: Object docstring + """ ds = obj.__doc__ if ds: stripped = [line.strip() for line in ds.splitlines() if line] - return " \n".join(stripped).replace("\n", " ").replace("\r", "") - else: - return "" + if not remove_newlines: + return "\n".join(stripped) + return " ".join(stripped).replace("\n", " ").replace("\r", "") + return "" def get_summary(obj): - return get_docstring(obj).partition("\n")[0].strip() + """Return the first line of the dosctring of an object + + Args: + obj: Any Python object + Returns: + str: First line of object docstring + """ + print(get_docstring(obj, remove_newlines=False)) + return get_docstring(obj, remove_newlines=False).partition("\n")[0].strip() -def rupdate(d, u): - for k, v in u.items(): + +def rupdate(destination_dict, update_dict): + """Recursively update a dictionary + + This will take an "update_dictionary", + and recursively merge it with "destination_dict". + + Args: + destination_dict (dict): Original dictionary + update_dict (dict): New data dictionary + + Returns: + dict: Merged dictionary + """ + for k, v in update_dict.items(): # Merge lists if they're present in both objects if isinstance(v, list): - if not k in d: - d[k] = [] - if isinstance(d[k], list): - d[k].extend(v) + if not k in destination_dict: + destination_dict[k] = [] + if isinstance(destination_dict[k], list): + destination_dict[k].extend(v) # Recursively merge dictionaries if the element is a dictionary elif isinstance(v, collections.abc.Mapping): - if not k in d: - d[k] = {} - d[k] = rupdate(d.get(k, {}), v) + if not k in destination_dict: + destination_dict[k] = {} + destination_dict[k] = rupdate(destination_dict.get(k, {}), v) # If not a list or dictionary, overwrite old value with new value else: - d[k] = v - return d + destination_dict[k] = v + return destination_dict def rapply(data, func, *args, apply_to_iterables=True, **kwargs): @@ -53,6 +78,9 @@ def rapply(data, func, *args, apply_to_iterables=True, **kwargs): data: Input iterable data func: Function to apply to all non-iterable values apply_to_iterables (bool): Apply the function to elements in lists/tuples + + Returns: + dict: Updated dictionary """ # If the object is a dictionary @@ -89,6 +117,23 @@ def set_by_path(root, items, value): def create_from_path(items): + """Create a dictionary from a list of nested keys.RuntimeError + + E.g. ["foo", "bar", "baz"] will become + { + "foo": { + "bar": { + "baz": {} + } + } + } + + Args: + items (list): Key path + + Returns: + dict: Nested dictionary of key path + """ tree_dict = {} for key in reversed(items): tree_dict = {key: tree_dict} @@ -96,14 +141,38 @@ def create_from_path(items): def camel_to_snake(name): + """Convert a CamelCase string into snake_case + + Args: + name (str): CamelCase string + + Returns: + str: snake_case string + """ s1 = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", name) return re.sub("([a-z0-9])([A-Z])", r"\1_\2", s1).lower() def camel_to_spine(name): + """Convert a CamelCase string into spine-case + + Args: + name (str): CamelCase string + + Returns: + str: spine-case string + """ s1 = re.sub("(.)([A-Z][a-z]+)", r"\1-\2", name) return re.sub("([a-z0-9])([A-Z])", r"\1-\2", s1).lower() def snake_to_spine(name): + """Convert a snake_case string into spine-case + + Args: + name (str): snake_case string + + Returns: + str: spine-case string + """ return name.replace("_", "-") diff --git a/labthings/server/decorators.py b/labthings/server/decorators.py index 5cf9d0da..8643cb6a 100644 --- a/labthings/server/decorators.py +++ b/labthings/server/decorators.py @@ -1,12 +1,12 @@ from webargs import flaskparser from functools import wraps, update_wrapper -from flask import make_response, jsonify, abort, request +from flask import make_response, abort, request from http import HTTPStatus from marshmallow.exceptions import ValidationError from collections import Mapping from .spec.utilities import update_spec -from .schema import TaskSchema, Schema +from .schema import TaskSchema, Schema, FieldSchema from .fields import Field import logging @@ -47,7 +47,7 @@ def __init__(self, schema, code=200): if isinstance(self.schema, Mapping): self.converter = Schema.from_dict(self.schema)().jsonify elif isinstance(self.schema, Field): - self.converter = lambda x: jsonify(self.schema._serialize(x, None, None)) + self.converter = FieldSchema(self.schema).jsonify elif isinstance(self.schema, Schema): self.converter = self.schema.jsonify else: @@ -165,7 +165,7 @@ def wrapper(*args, **kwargs): # Serialize data if it exists if data: try: - data = self.schema._deserialize(data, None, None) + data = FieldSchema(self.schema).deserialize(data) except ValidationError as e: logging.error(e) return abort(400) diff --git a/labthings/server/extensions.py b/labthings/server/extensions.py index 93d48093..0f75046b 100644 --- a/labthings/server/extensions.py +++ b/labthings/server/extensions.py @@ -1,6 +1,4 @@ import logging -import collections -import copy import traceback from importlib import util @@ -118,7 +116,7 @@ def find_extensions_in_file(extension_path: str, module_name="extensions") -> li try: spec.loader.exec_module(mod) - except Exception as e: + except Exception as e: # skipcq: PYL-W0703 logging.error( f"Exception in extension path {extension_path}: \n{traceback.format_exc()}" ) diff --git a/labthings/server/find.py b/labthings/server/find.py index b866f992..2e87b2ae 100644 --- a/labthings/server/find.py +++ b/labthings/server/find.py @@ -5,7 +5,10 @@ def current_labthing(): - app = current_app._get_current_object() + # We use _get_current_object so that Task threads can still + # reach the Flask app object. Just using current_app returns + # a wrapper, which breaks it's use in Task threads + app = current_app._get_current_object() # skipcq: PYL-W0212 if not app: return None logging.debug("Active app extensions:") diff --git a/labthings/server/labthing.py b/labthings/server/labthing.py index d07bc331..cf2085ec 100644 --- a/labthings/server/labthing.py +++ b/labthings/server/labthing.py @@ -88,7 +88,7 @@ def version(self, version: str): self._version = version self.spec.version = version - ### Flask stuff + # Flask stuff def init_app(self, app): app.teardown_appcontext(self.teardown) @@ -120,12 +120,12 @@ def _create_base_routes(self): self.add_view(TaskList, "/tasks", endpoint=TASK_LIST_ENDPOINT) self.add_view(TaskView, "/tasks/", endpoint=TASK_ENDPOINT) - ### Device stuff + # Device stuff def add_component(self, device_object, device_name: str): self.components[device_name] = device_object - ### Extension stuff + # Extension stuff def register_extension(self, extension_object): if isinstance(extension_object, BaseExtension): @@ -141,7 +141,7 @@ def register_extension(self, extension_object): **extension_view["kwargs"], ) - ### Resource stuff + # Resource stuff def _complete_url(self, url_part, registration_prefix): """This method is used to defer the construction of the final url in @@ -203,7 +203,8 @@ def _register_view(self, app, view, *urls, endpoint=None, **kwargs): 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 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." @@ -221,7 +222,9 @@ def _register_view(self, app, view, *urls, endpoint=None, **kwargs): # Add the url to the application or blueprint app.add_url_rule(rule, view_func=resource_func, **kwargs) - flask_rules = app.url_map._rules_by_endpoint.get(endpoint) + # 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 + 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)) @@ -233,7 +236,7 @@ def _register_view(self, app, view, *urls, endpoint=None, **kwargs): if "properties" in view_groups: self.thing_description.property(flask_rules, view) - ### Utilities + # Utilities def url_for(self, view, **values): """Generates a URL to the given resource. @@ -244,10 +247,12 @@ def url_for(self, view, **values): def owns_endpoint(self, endpoint): return endpoint in self.endpoints - def add_root_link(self, view, title, kwargs={}): + def add_root_link(self, view, title, kwargs=None): + if kwargs is None: + kwargs = {} self.custom_root_links[title] = (view, kwargs) - ### Description + # Description def rootrep(self): """ Root representation diff --git a/labthings/server/quick.py b/labthings/server/quick.py index 2f9f5ed4..1df93993 100644 --- a/labthings/server/quick.py +++ b/labthings/server/quick.py @@ -15,6 +15,11 @@ def create_app( handle_cors: bool = True, flask_kwargs: dict = {}, ): + # Handle arguments + if flask_kwargs is None: + flask_kwargs = {} + + # Create Flask app app = Flask(import_name, **flask_kwargs) app.url_map.strict_slashes = False diff --git a/labthings/server/schema.py b/labthings/server/schema.py index 7a8166e7..a44b68e7 100644 --- a/labthings/server/schema.py +++ b/labthings/server/schema.py @@ -43,6 +43,50 @@ def jsonify(self, obj, many=sentinel, *args, **kwargs): return jsonify(data, *args, **kwargs) +class FieldSchema: + """ + "Virtual schema" for handling individual fields treated as schemas. + + For example, when serializing/deserializing individual values that are not + attributes of an object. + """ + + def __init__(self, field: fields.Field): + """Create a converter for data of the field type + + Args: + field (Field): Marshmallow Field type of data + """ + self.field = field + + def deserialize(self, value): + return self.field.deserialize(value) + + def serialize(self, value): + """Serialize a value to Field type + + Args: + value: Data to serialize + + Returns: + Serialized data + """ + obj = type("obj", (object,), {"value": value}) + + return self.field.serialize("value", obj) + + def jsonify(self, value): + """Serialize a value to JSON + + Args: + value: Data to serialize + + Returns: + Serialized JSON data + """ + return jsonify(self.serialize(value)) + + class TaskSchema(Schema): _ID = fields.String(data_key="id") target_string = fields.String(data_key="function") diff --git a/labthings/server/spec/paths.py b/labthings/server/spec/paths.py index 86a59003..fc1faabb 100644 --- a/labthings/server/spec/paths.py +++ b/labthings/server/spec/paths.py @@ -37,7 +37,9 @@ def rule_to_params(rule, overrides=None): def argument_to_param(argument, rule, override=None): param = {"in": "path", "name": argument, "required": True} type_, format_ = CONVERTER_MAPPING.get( - type(rule._converters[argument]), DEFAULT_TYPE + # skipcq: PYL-W0212 + type(rule._converters[argument]), + DEFAULT_TYPE, ) param["schema"] = {} param["schema"]["type"] = type_ diff --git a/labthings/server/spec/utilities.py b/labthings/server/spec/utilities.py index 692be56e..b659154c 100644 --- a/labthings/server/spec/utilities.py +++ b/labthings/server/spec/utilities.py @@ -41,13 +41,15 @@ def convert_schema(schema, spec: APISpec): return field_to_property(schema, spec) else: raise TypeError( - f"Unsupported schema type {schema}. Ensure schema is a Schema class, or dictionary of Field objects" + f"Unsupported schema type {schema}. " + "Ensure schema is a Schema class, or dictionary of Field objects" ) def map_to_properties(schema, spec: APISpec): """ - Recursively convert any dictionary-like map of Marshmallow fields into a dictionary describing it's JSON schema + Recursively convert any dictionary-like map of Marshmallow fields + into a dictionary describing it's JSON schema """ marshmallow_plugin = next( plugin for plugin in spec.plugins if isinstance(plugin, MarshmallowPlugin) @@ -81,8 +83,9 @@ def field_to_property(field, spec: APISpec): def schema_to_json(schema, spec: APISpec): """ Convert any Marshmallow schema stright to a fully expanded JSON schema. - This should not be used when generating APISpec documentation, otherwise schemas wont - be listed in the "schemas" list. This is used, for example, in the Thing Description. + This should not be used when generating APISpec documentation, + otherwise schemas wont be listed in the "schemas" list. + This is used, for example, in the Thing Description. """ if isinstance(schema, BaseSchema): @@ -119,15 +122,18 @@ def expand_refs(schema_dict, spec: APISpec): """ Expand out all schema $ref values where possible. - Uses the $ref value to look up a particular schema in `spec.components._schemas` + Uses the $ref value to look up a particular schema in spec schemas """ if "$ref" not in schema_dict: return schema_dict name = schema_dict.get("$ref").split("/")[-1] - if name in spec.components._schemas: - schema_dict.update(spec.components._schemas.get(name)) + # Get the list of all schemas registered with APISpec + spec_schemas = spec.to_dict().get("components", {}).get("schemas", {}) + + if name in spec_schemas: + schema_dict.update(spec_schemas.get(name)) del schema_dict["$ref"] return schema_dict diff --git a/labthings/server/types.py b/labthings/server/types.py index 5531513c..7ec77aaf 100644 --- a/labthings/server/types.py +++ b/labthings/server/types.py @@ -1,5 +1,6 @@ # Marshmallow fields to JSON schema types -# Note: We shouldn't ever need to use this directly. We should go via the apispec converter +# 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 @@ -105,4 +106,4 @@ def data_dict_to_schema(data_dict): # TODO: Deserialiser with inverse defaults -# TODO: Option to switch to .npy serialisation/deserialisation (or look for a better common array format) +# TODO: Option to switch to .npy serialisation/deserialisation (see OFM server) diff --git a/labthings/server/view.py b/labthings/server/view.py index b58ebba4..17c67bf7 100644 --- a/labthings/server/view.py +++ b/labthings/server/view.py @@ -1,8 +1,9 @@ from flask.views import MethodView from flask import request from werkzeug.wrappers import Response as ResponseBase +from werkzeug.exceptions import MethodNotAllowed -from labthings.core.utilities import OrderedDict +from collections import OrderedDict from labthings.server.utilities import unpack from labthings.server.representations import DEFAULT_REPRESENTATIONS @@ -10,8 +11,8 @@ class View(MethodView): """ - A LabThing Resource class should make use of functions get(), put(), post(), and delete() - corresponding to HTTP methods. + A LabThing Resource class should make use of functions + get(), put(), post(), and delete(), corresponding to HTTP methods. These functions will allow for automated documentation generation """ @@ -45,7 +46,8 @@ def dispatch_request(self, *args, **kwargs): if meth is None and request.method == "HEAD": meth = getattr(self, "get", None) - assert meth is not None, "Unimplemented method %r" % request.method + if meth is None: + raise MethodNotAllowed(f"Unimplemented method {request.method}") # Generate basic response resp = meth(*args, **kwargs) diff --git a/labthings/server/views/extensions.py b/labthings/server/views/extensions.py index d3bf9f09..f20dc0b3 100644 --- a/labthings/server/views/extensions.py +++ b/labthings/server/views/extensions.py @@ -15,13 +15,9 @@ class ExtensionList(View): @marshal_with(ExtensionSchema(many=True)) def get(self): """ - Return the current Extension forms - - Returns an array of present Extension forms (describing Extension user interfaces.) - Please note, this is *not* a list of all enabled Extensions, only those with associated - user interface forms. - - A complete list of enabled Extensions can be found in the microscope state. + List enabled extensions. + Returns a list of Extension representations, including basic documentation. + Describes server methods, web views, and other relevant Lab Things metadata. """ return registered_extensions().values() diff --git a/tests/test_core_utilities.py b/tests/test_core_utilities.py index d4cd1a3a..bed294d9 100644 --- a/tests/test_core_utilities.py +++ b/tests/test_core_utilities.py @@ -26,12 +26,11 @@ def class_method_no_docstring(self): def test_get_docstring(example_class): assert ( utilities.get_docstring(example_class) - == "First line of class docstring. \nSecond line of class docstring. \n" + == "First line of class docstring. Second line of class docstring. " ) - assert ( - utilities.get_docstring(example_class.class_method) - == "First line of class method docstring. \nSecond line of class method docstring. \n" + assert utilities.get_docstring(example_class.class_method) == ( + "First line of class method docstring. Second line of class method docstring. " ) assert utilities.get_docstring(example_class.class_method_no_docstring) == "" diff --git a/tests/test_server_types.py b/tests/test_server_types.py index 1e4724f2..c3bc1861 100644 --- a/tests/test_server_types.py +++ b/tests/test_server_types.py @@ -32,19 +32,16 @@ def types_dict(): def test_make_primative(): - from fractions import Fraction - assert types.make_primative(Fraction(5, 2)) == 2.5 def test_value_to_field(): - from labthings.server import fields - # Test arrays of data d1 = [1, 2, 3] gen_field = types.value_to_field(d1) expected_field = fields.List(fields.Int()) + # skipcq: PYL-W0212 assert gen_field._serialize(d1, None, None) == expected_field._serialize( d1, None, None ) @@ -54,6 +51,7 @@ def test_value_to_field(): gen_field_2 = types.value_to_field(d2) expected_field_2 = fields.String(example="String") + # skipcq: PYL-W0212 assert gen_field_2._serialize(d2, None, None) == expected_field_2._serialize( d2, None, None )