Skip to content

Commit b945753

Browse files
authored
Merge pull request #43 from labthings/improved-test-coverage
Improved test coverage
2 parents 85ca014 + 8cb6f6f commit b945753

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+3447
-274
lines changed

labthings/core/event.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,15 @@ def wait(self, timeout: int = 5):
3030
self.events[ident] = [Event(), time.time()]
3131

3232
# We have to reimplement event waiting here as we need native thread events to allow gevent context switching
33+
wait_start = time.time()
3334
while not self.events[ident][0].is_set():
34-
gevent.time.sleep(0)
35+
now = time.time()
36+
if now - wait_start > timeout:
37+
return False
38+
gevent.sleep(0)
3539
return True
3640

37-
def set(self):
41+
def set(self, timeout=5):
3842
"""Signal that a new frame is available."""
3943
now = time.time()
4044
remove = None
@@ -47,9 +51,9 @@ def set(self):
4751
else:
4852
# if the client's event is already set, it means the client
4953
# did not process a previous frame
50-
# if the event stays set for more than 5 seconds, then assume
54+
# if the event stays set for more than `timeout` seconds, then assume
5155
# the client is gone and remove it
52-
if now - event[1] > 5:
56+
if now - event[1] >= timeout:
5357
remove = ident
5458
if remove:
5559
del self.events[remove]
@@ -60,4 +64,6 @@ def clear(self):
6064
if ident not in self.events:
6165
logging.error(f"Mismatched ident. Current: {ident}, available:")
6266
logging.error(self.events.keys())
67+
return False
6368
self.events[id(getcurrent())][0].clear()
69+
return True

labthings/core/tasks/thread.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from gevent import Greenlet, GreenletExit
22
from gevent.thread import get_ident
3+
from gevent.event import Event
34
import datetime
45
import logging
56
import traceback
@@ -28,6 +29,9 @@ def __init__(self, target=None, args=None, kwargs=None):
2829
# A UUID for the TaskThread (not the same as the threading.Thread ident)
2930
self._ID = uuid.uuid4() # Task ID
3031

32+
# Event to track if the task has started
33+
self.started_event = Event()
34+
3135
# Make _target, _args, and _kwargs available to the subclass
3236
self._target = target
3337
self._args = args
@@ -96,6 +100,7 @@ def wrapped(*args, **kwargs):
96100

97101
self._status = "running"
98102
self._start_time = datetime.datetime.now().strftime("%Y-%m-%d %H-%M-%S")
103+
self.started_event.set()
99104
try:
100105
self._return_value = f(*args, **kwargs)
101106
self._status = "success"

labthings/server/__init__.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +0,0 @@
1-
import logging
2-
3-
EXTENSION_NAME = "flask-labthings"

labthings/server/decorators.py

Lines changed: 26 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -4,45 +4,29 @@
44
from werkzeug.wrappers import Response as ResponseBase
55
from http import HTTPStatus
66
from marshmallow.exceptions import ValidationError
7-
from collections import Mapping
7+
from collections.abc import Mapping
8+
9+
from marshmallow import Schema as _Schema
810

911
from .spec.utilities import update_spec
1012
from .schema import TaskSchema, Schema, FieldSchema
1113
from .fields import Field
1214
from .view import View
1315
from .find import current_labthing
16+
from .utilities import unpack
1417

1518
from labthings.core.tasks.pool import TaskThread
19+
from labthings.core.utilities import rupdate
1620

1721
import logging
1822

1923
# Useful externals to have included here
2024
from marshmallow import pre_dump, pre_load
2125

2226

23-
def unpack(value):
24-
"""Return a three tuple of data, code, and headers"""
25-
if not isinstance(value, tuple):
26-
return value, 200, {}
27-
28-
try:
29-
data, code, headers = value
30-
return data, code, headers
31-
except ValueError:
32-
pass
33-
34-
try:
35-
data, code = value
36-
return data, code, {}
37-
except ValueError:
38-
pass
39-
40-
return value, 200, {}
41-
42-
4327
class marshal_with:
4428
def __init__(self, schema, code=200):
45-
"""Decorator to format the response of a View with a Marshmallow schema
29+
"""Decorator to format the return of a function with a Marshmallow schema
4630
4731
Args:
4832
schema: Marshmallow schema, field, or dict of Fields, describing
@@ -52,11 +36,11 @@ def __init__(self, schema, code=200):
5236
self.code = code
5337

5438
if isinstance(self.schema, Mapping):
55-
self.converter = Schema.from_dict(self.schema)().jsonify
39+
self.converter = Schema.from_dict(self.schema)().dump
5640
elif isinstance(self.schema, Field):
57-
self.converter = FieldSchema(self.schema).jsonify
58-
elif isinstance(self.schema, Schema):
59-
self.converter = self.schema.jsonify
41+
self.converter = FieldSchema(self.schema).dump
42+
elif isinstance(self.schema, _Schema):
43+
self.converter = self.schema.dump
6044
else:
6145
raise TypeError(
6246
f"Unsupported schema type {type(self.schema)} for marshal_with"
@@ -69,12 +53,15 @@ def __call__(self, f):
6953
@wraps(f)
7054
def wrapper(*args, **kwargs):
7155
resp = f(*args, **kwargs)
72-
if isinstance(resp, tuple):
73-
data, code, headers = unpack(resp)
56+
if isinstance(resp, ResponseBase):
57+
resp.data = self.converter(resp.data)
58+
return resp
59+
elif isinstance(resp, tuple):
60+
resp, code, headers = unpack(resp)
61+
return (self.converter(resp), code, headers)
7462
else:
75-
data, code, headers = resp, 200, {}
76-
77-
return make_response(self.converter(data), code, headers)
63+
resp, code, headers = resp, 200, {}
64+
return (self.converter(resp), code, headers)
7865

7966
return wrapper
8067

@@ -90,15 +77,15 @@ def marshal_task(f):
9077
def wrapper(*args, **kwargs):
9178
resp = f(*args, **kwargs)
9279
if isinstance(resp, tuple):
93-
data, code, headers = unpack(resp)
80+
resp, code, headers = unpack(resp)
9481
else:
95-
data, code, headers = resp, 200, {}
82+
resp, code, headers = resp, 201, {}
9683

97-
if not isinstance(data, TaskThread):
84+
if not isinstance(resp, TaskThread):
9885
raise TypeError(
99-
f"Function {f.__name__} expected to return a TaskThread object, but instead returned a {type(data).__name__}. If it does not return a task, remove the @marshall_task decorator from {f.__name__}."
86+
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__}."
10087
)
101-
return make_response(TaskSchema().jsonify(data), code, headers)
88+
return (TaskSchema().dump(resp), code, headers)
10289

10390
return wrapper
10491

@@ -327,11 +314,12 @@ def __init__(self, code, description=None, mimetype=None, **kwargs):
327314
}
328315

329316
if self.mimetype:
330-
self.response_dict.update(
317+
rupdate(
318+
self.response_dict,
331319
{
332320
"responses": {self.code: {"content": {self.mimetype: {}}}},
333321
"_content_type": self.mimetype,
334-
}
322+
},
335323
)
336324

337325
def __call__(self, f):

labthings/server/default_views/extensions.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,4 @@ def get(self):
1717
Returns a list of Extension representations, including basic documentation.
1818
Describes server methods, web views, and other relevant Lab Things metadata.
1919
"""
20-
return registered_extensions().values()
20+
return registered_extensions().values() or []

labthings/server/extensions.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ def views(self):
6565
def add_view(self, view_class, rule, view_id=None, **kwargs):
6666
# Remove all leading slashes from view route
6767
cleaned_rule = rule
68-
while cleaned_rule[0] == "/":
68+
while cleaned_rule and cleaned_rule[0] == "/":
6969
cleaned_rule = cleaned_rule[1:]
7070

7171
# Expand the rule to include extension name
@@ -141,7 +141,7 @@ def add_method(self, method, method_name):
141141
if not hasattr(self, method_name):
142142
setattr(self, method_name, method)
143143
else:
144-
logging.warning(
144+
raise NameError(
145145
"Unable to bind method to extension. Method name already exists."
146146
)
147147

labthings/server/find.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import logging
22
from flask import current_app
33

4-
from . import EXTENSION_NAME
4+
from .names import EXTENSION_NAME
55

66

77
def current_labthing(app=None):
@@ -19,7 +19,7 @@ def current_labthing(app=None):
1919
logging.debug("Active app extensions:")
2020
logging.debug(app.extensions)
2121
logging.debug("Active labthing:")
22-
logging.debug(app.extensions[EXTENSION_NAME])
22+
logging.debug(app.extensions.get(EXTENSION_NAME))
2323
return app.extensions.get(EXTENSION_NAME, None)
2424

2525

@@ -35,7 +35,8 @@ def registered_extensions(labthing_instance=None):
3535
"""
3636
if not labthing_instance:
3737
labthing_instance = current_labthing()
38-
return labthing_instance.extensions
38+
39+
return getattr(labthing_instance, "extensions", {})
3940

4041

4142
def registered_components(labthing_instance=None):

labthings/server/labthing.py

Lines changed: 17 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,14 @@
22
from apispec import APISpec
33
from apispec.ext.marshmallow import MarshmallowPlugin
44

5-
from . import EXTENSION_NAME # TODO: Move into .names
6-
from .names import TASK_ENDPOINT, TASK_LIST_ENDPOINT, EXTENSION_LIST_ENDPOINT
5+
from .names import (
6+
EXTENSION_NAME,
7+
TASK_ENDPOINT,
8+
TASK_LIST_ENDPOINT,
9+
EXTENSION_LIST_ENDPOINT,
10+
)
711
from .extensions import BaseExtension
8-
from .utilities import description_from_view
12+
from .utilities import description_from_view, clean_url_string
913
from .exceptions import JSONExceptionHandler
1014
from .logging import LabThingLogger
1115
from .representations import LabThingsJSONEncoder
@@ -113,6 +117,8 @@ def version(self, version: str):
113117
# Flask stuff
114118

115119
def init_app(self, app):
120+
self.app = app
121+
116122
app.teardown_appcontext(self.teardown)
117123

118124
# Register Flask extension
@@ -160,7 +166,7 @@ def _create_base_routes(self):
160166
self.add_view(TaskView, "/tasks/<task_id>", endpoint=TASK_ENDPOINT)
161167

162168
def _create_base_sockets(self):
163-
self.sockets.add_url_rule(f"{self.url_prefix}", self._socket_handler)
169+
self.sockets.add_url_rule(self._complete_url("", ""), self._socket_handler)
164170

165171
def _socket_handler(self, ws):
166172
# Create a socket subscriber
@@ -231,8 +237,9 @@ def _complete_url(self, url_part, registration_prefix):
231237
:param registration_prefix: The part of the url contributed by the
232238
blueprint. Generally speaking, BlueprintSetupState.url_prefix
233239
"""
234-
parts = [registration_prefix, self.url_prefix, url_part]
235-
return "".join([part for part in parts if part])
240+
parts = [self.url_prefix, registration_prefix, url_part]
241+
u = "".join([clean_url_string(part) for part in parts if part])
242+
return u if u else "/"
236243

237244
def add_view(self, resource, *urls, endpoint=None, **kwargs):
238245
"""Adds a view to the api.
@@ -280,18 +287,6 @@ def _register_view(self, app, view, *urls, endpoint=None, **kwargs):
280287
resource_class_args = kwargs.pop("resource_class_args", ())
281288
resource_class_kwargs = kwargs.pop("resource_class_kwargs", {})
282289

283-
# NOTE: 'view_functions' is cleaned up from Blueprint class in Flask 1.0
284-
if endpoint in getattr(app, "view_functions", {}):
285-
previous_view_class = app.view_functions[endpoint].__dict__["view_class"]
286-
287-
# If you override the endpoint with a different class,
288-
# avoid the collision by raising an exception
289-
if previous_view_class != view:
290-
raise ValueError(
291-
"This endpoint (%s) is already set to the class %s."
292-
% (endpoint, previous_view_class.__name__)
293-
)
294-
295290
view.endpoint = endpoint
296291
resource_func = view.as_view(
297292
endpoint, *resource_class_args, **resource_class_kwargs
@@ -311,7 +306,7 @@ def _register_view(self, app, view, *urls, endpoint=None, **kwargs):
311306

312307
# Handle resource groups listed in API spec
313308
view_spec = get_spec(view)
314-
view_groups = view_spec.get("_groups", {})
309+
view_groups = view_spec.get("_groups", [])
315310
if "actions" in view_groups:
316311
self.thing_description.action(flask_rules, view)
317312
self._action_views[view.endpoint] = view
@@ -324,7 +319,9 @@ def _register_view(self, app, view, *urls, endpoint=None, **kwargs):
324319
def url_for(self, view, **values):
325320
"""Generates a URL to the given resource.
326321
Works like :func:`flask.url_for`."""
327-
endpoint = view.endpoint
322+
endpoint = getattr(view, "endpoint", None)
323+
if not endpoint:
324+
return ""
328325
# Default to external links
329326
if "_external" not in values:
330327
values["_external"] = True

labthings/server/names.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
TASK_ENDPOINT = "labthing_task"
22
TASK_LIST_ENDPOINT = "labthing_task_list"
33
EXTENSION_LIST_ENDPOINT = "labthing_extension_list"
4+
EXTENSION_NAME = "flask-labthings"

labthings/server/schema.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# -*- coding: utf-8 -*-
22
from flask import jsonify, url_for
3+
from werkzeug.routing import BuildError
34
import marshmallow
45

56
from .names import TASK_ENDPOINT, TASK_LIST_ENDPOINT, EXTENSION_LIST_ENDPOINT
@@ -75,6 +76,9 @@ def serialize(self, value):
7576

7677
return self.field.serialize("value", obj)
7778

79+
def dump(self, value):
80+
return self.serialize(value)
81+
7882
def jsonify(self, value):
7983
"""Serialize a value to JSON
8084
@@ -102,9 +106,13 @@ class TaskSchema(Schema):
102106

103107
@marshmallow.pre_dump
104108
def generate_links(self, data, **kwargs):
109+
try:
110+
url = url_for(TASK_ENDPOINT, task_id=data.id, _external=True)
111+
except BuildError:
112+
url = None
105113
data.links = {
106114
"self": {
107-
"href": url_for(TASK_ENDPOINT, task_id=data.id, _external=True),
115+
"href": url,
108116
"mimetype": "application/json",
109117
**description_from_view(view_class_from_endpoint(TASK_ENDPOINT)),
110118
}
@@ -127,11 +135,13 @@ def generate_links(self, data, **kwargs):
127135
for view_id, view_data in data.views.items():
128136
view_cls = view_data["view"]
129137
view_rule = view_data["rule"]
138+
# Try to build a URL
139+
try:
140+
url = url_for(EXTENSION_LIST_ENDPOINT, _external=True) + view_rule
141+
except BuildError:
142+
url = None
130143
# Make links dictionary if it doesn't yet exist
131-
d[view_id] = {
132-
"href": url_for(EXTENSION_LIST_ENDPOINT, _external=True) + view_rule,
133-
**description_from_view(view_cls),
134-
}
144+
d[view_id] = {"href": url, **description_from_view(view_cls)}
135145

136146
data.links = d
137147

0 commit comments

Comments
 (0)