Skip to content

Commit 21ef3f8

Browse files
authored
Mozilla Web Things format and structure (#81)
* Converted TD to Mozilla schema * Move websocket route to /ws * Half-working newer socket implementation * Enabled url_for for WebSocket views * Updated websocket link rel * Removed debug string * Improved link between extension view endpoints and TD keys * Add explicit endpoint to test * Simplified action and property tagging * Implemented ActionView for automatic background task management * Fixed represent_response * Build using view classes rather than decorators * Update tests * Reduced mock Action time * Fixed Action href * Removed old debug statement * FieldSchema subclasses Schema * Added output schema to action * Simplified example * Let schema handle datetime formatting * Improved get_spec * Simplified APISpec path builder * Renamed view_to_apispec_operations * Fixed operation parameters * Updated apispec tests * Removed old unused SocketMiddleware * Fixed summary builder * Automatically build 201 Action schemas * Fixed error in exception * Removed unused Task argument * Add output and status properties * Include "input" in Action schemas * Send input schema into Action schema generator * Marshal Action responses with the correct schema * Compile generated View specs as they're created * Update view builder tests * Fixed generated action class name * Remove tasks from properties * Fixed generated name * Improved coverage
1 parent 58e0364 commit 21ef3f8

29 files changed

+745
-1029
lines changed

examples/builder.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,6 @@
4444
my_component.average_data,
4545
"/average",
4646
description="Take an averaged measurement",
47-
task=True, # Is the action a long-running task?
4847
safe=True, # Is the state of the Thing unchanged by calling the action?
4948
idempotent=True, # Can the action be called repeatedly with the same result?
5049
)

examples/simple_extensions.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,19 @@
33
import logging
44

55
from labthings.server.quick import create_app
6-
from labthings.server.decorators import ThingProperty, PropertySchema
6+
from labthings.server.decorators import (
7+
ThingProperty,
8+
PropertySchema,
9+
ThingAction,
10+
use_args,
11+
marshal_task,
12+
doc,
13+
)
714
from labthings.server.view import View
815
from labthings.server.find import find_component
916
from labthings.server import fields
1017
from labthings.core.utilities import path_relative_to
18+
from labthings.core.tasks import taskify
1119

1220
from labthings.server.extensions import BaseExtension
1321

@@ -20,6 +28,37 @@
2028
"""
2129

2230

31+
@ThingAction
32+
class ExtensionMeasurementAction(View):
33+
# Expect JSON parameters in the request body.
34+
# Pass to post function as dictionary argument.
35+
@use_args(
36+
{
37+
"averages": fields.Integer(
38+
missing=10,
39+
example=10,
40+
description="Number of data sets to average over",
41+
)
42+
}
43+
)
44+
# Shorthand to say we're always going to return a Task object
45+
@marshal_task
46+
@doc(title="Averaged Measurement")
47+
# Main function to handle POST requests
48+
def post(self, args):
49+
"""Start an averaged measurement"""
50+
51+
# Find our attached component
52+
my_component = find_component("org.labthings.example.mycomponent")
53+
54+
# Get arguments and start a background task
55+
n_averages = args.get("averages")
56+
task = taskify(my_component.average_data)(n_averages)
57+
58+
# Return the task information
59+
return task
60+
61+
2362
def ext_on_register():
2463
logging.info("Extension registered")
2564

@@ -34,6 +73,8 @@ def ext_on_my_component(component):
3473
"org.labthings.examples.extension", static_folder=static_folder
3574
)
3675

76+
example_extension.add_view(ExtensionMeasurementAction, "/measure", endpoint="measure")
77+
3778
example_extension.on_register(ext_on_register)
3879
example_extension.on_component("org.labthings.example.mycomponent", ext_on_my_component)
3980

examples/simple_thing.py

Lines changed: 11 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,11 @@
1414

1515
from labthings.server.quick import create_app
1616
from labthings.server.decorators import (
17-
ThingAction,
18-
ThingProperty,
1917
PropertySchema,
2018
use_args,
21-
marshal_task,
19+
marshal_with,
2220
)
23-
from labthings.server.view import View
21+
from labthings.server.view import View, ActionView, PropertyView
2422
from labthings.server.find import find_component
2523
from labthings.server import fields
2624
from labthings.core.tasks import taskify, update_task_data
@@ -36,9 +34,6 @@
3634

3735
get_ident = get_original("_thread", "get_ident")
3836

39-
print(f"ROOT IDENT")
40-
print(get_ident())
41-
4237

4338
class MyComponent:
4439
def __init__(self):
@@ -69,8 +64,7 @@ def average_data(self, n: int):
6964
logging.warning("Starting an averaged measurement. This may take a while...")
7065
for _ in range(n):
7166
summed_data = [summed_data[i] + el for i, el in enumerate(self.data)]
72-
update_task_data({"data": summed_data})
73-
time.sleep(0.25)
67+
time.sleep(0.1)
7468

7569
summed_data = [i / n for i in summed_data]
7670

@@ -83,8 +77,6 @@ def average_data(self, n: int):
8377
"""
8478

8579

86-
# Register this view as a Thing Property
87-
@ThingProperty
8880
# Define the data we're going to output (get), and what to expect in (post)
8981
@PropertySchema(
9082
fields.Integer(
@@ -95,7 +87,7 @@ def average_data(self, n: int):
9587
description="Value of magic_denoise",
9688
)
9789
)
98-
class DenoiseProperty(View):
90+
class DenoiseProperty(PropertyView):
9991

10092
# Main function to handle GET requests (read)
10193
def get(self):
@@ -123,9 +115,8 @@ def post(self, new_property_value):
123115
"""
124116

125117

126-
@ThingProperty
127118
@PropertySchema(fields.List(fields.Float()))
128-
class QuickDataProperty(View):
119+
class QuickDataProperty(PropertyView):
129120
# Main function to handle GET requests
130121
def get(self):
131122
"""Show the current data value"""
@@ -140,21 +131,20 @@ def get(self):
140131
"""
141132

142133

143-
@ThingAction
144-
class MeasurementAction(View):
134+
class MeasurementAction(ActionView):
145135
# Expect JSON parameters in the request body.
146136
# Pass to post function as dictionary argument.
147137
@use_args(
148138
{
149139
"averages": fields.Integer(
150-
missing=10,
151-
example=10,
140+
missing=20,
141+
example=20,
152142
description="Number of data sets to average over",
153143
)
154144
}
155145
)
156-
# Shorthand to say we're always going to return a Task object
157-
@marshal_task
146+
# Output schema
147+
@marshal_with(fields.List(fields.Number))
158148
# Main function to handle POST requests
159149
def post(self, args):
160150
"""Start an averaged measurement"""
@@ -164,10 +154,9 @@ def post(self, args):
164154

165155
# Get arguments and start a background task
166156
n_averages = args.get("averages")
167-
task = taskify(my_component.average_data)(n_averages)
168157

169158
# Return the task information
170-
return task
159+
return my_component.average_data(n_averages)
171160

172161

173162
# Handle exit cleanup

src/labthings/core/tasks/thread.py

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,9 @@ def __init__(self, target, *args, **kwargs):
4444
logging.debug("No request context to copy")
4545

4646
# Private state properties
47-
self._status: str = "idle" # Task status
47+
self._status: str = "pending" # Task status
4848
self._return_value = None # Return value
49+
self._request_time = datetime.datetime.now()
4950
self._start_time = None # Task start time
5051
self._end_time = None # Task end time
5152

@@ -65,17 +66,12 @@ def ident(self):
6566
return get_ident(self)
6667

6768
@property
68-
def state(self):
69-
return {
70-
"function": self.target_string,
71-
"id": self._ID,
72-
"status": self._status,
73-
"progress": self.progress,
74-
"data": self.data,
75-
"return": self._return_value,
76-
"start_time": self._start_time,
77-
"end_time": self._end_time,
78-
}
69+
def output(self):
70+
return self._return_value
71+
72+
@property
73+
def status(self):
74+
return self._status
7975

8076
def update_progress(self, progress: int):
8177
# Update progress of the task
@@ -102,7 +98,7 @@ def wrapped(*args, **kwargs):
10298
logging.getLogger().addHandler(handler)
10399

104100
self._status = "running"
105-
self._start_time = datetime.datetime.now().strftime("%Y-%m-%d %H-%M-%S")
101+
self._start_time = datetime.datetime.now()
106102
self.started_event.set()
107103
try:
108104
self._return_value = f(*args, **kwargs)
@@ -117,8 +113,9 @@ def wrapped(*args, **kwargs):
117113
logging.error(traceback.format_exc())
118114
self._return_value = str(e)
119115
self._status = "error"
116+
raise e
120117
finally:
121-
self._end_time = datetime.datetime.now().strftime("%Y-%m-%d %H-%M-%S")
118+
self._end_time = datetime.datetime.now()
122119
logging.getLogger().removeHandler(handler) # Stop logging this thread
123120
# If we don't remove the handler, it's a memory leak.
124121

src/labthings/server/decorators.py

Lines changed: 14 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from .spec.utilities import update_spec, tag_spec
1212
from .schema import TaskSchema, Schema, FieldSchema
1313
from .fields import Field
14-
from .view import View
14+
from .view import View, ActionView, PropertyView
1515
from .find import current_labthing
1616
from .utilities import unpack
1717
from .event import PropertyStatusEvent, ActionStatusEvent
@@ -59,10 +59,13 @@ def __init__(self, schema, code=200):
5959
self.schema = schema
6060
self.code = code
6161

62+
# Case of schema as a dictionary
6263
if isinstance(self.schema, Mapping):
6364
self.converter = Schema.from_dict(self.schema)().dump
65+
# Case of schema as a single Field
6466
elif isinstance(self.schema, Field):
6567
self.converter = FieldSchema(self.schema).dump
68+
# Case of schema as a Schema
6669
elif isinstance(self.schema, _Schema):
6770
self.converter = self.schema.dump
6871
else:
@@ -83,33 +86,30 @@ def wrapper(*args, **kwargs):
8386
elif isinstance(resp, tuple):
8487
resp, code, headers = unpack(resp)
8588
return (self.converter(resp), code, headers)
86-
else:
87-
resp, code, headers = resp, 200, {}
88-
return (self.converter(resp), code, headers)
89+
return self.converter(resp)
8990

9091
return wrapper
9192

9293

9394
def marshal_task(f):
9495
"""Decorator to format the response of a View with the standard Task schema"""
9596

97+
logging.warning(
98+
"marshal_task is deprecated. Please use @ThingAction or the ActionView class"
99+
)
100+
96101
# Pass params to call function attribute for external access
97102
update_spec(f, {"responses": {201: {"description": "Task started successfully"}}})
98103
update_spec(f, {"_schema": {201: TaskSchema()}})
99104
# Wrapper function
100105
@wraps(f)
101106
def wrapper(*args, **kwargs):
102107
resp = f(*args, **kwargs)
103-
if isinstance(resp, tuple):
104-
resp, code, headers = unpack(resp)
105-
else:
106-
resp, code, headers = resp, 201, {}
107-
108108
if not isinstance(resp, TaskThread):
109109
raise TypeError(
110110
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__}."
111111
)
112-
return (TaskSchema().dump(resp), code, headers)
112+
return TaskSchema().dump(resp)
113113

114114
return wrapper
115115

@@ -123,7 +123,8 @@ def ThingAction(viewcls: View):
123123
Returns:
124124
View: View class with Action spec tags
125125
"""
126-
# TODO: Handle actionStatus messages
126+
# Set to PropertyView.dispatch_request
127+
viewcls.dispatch_request = ActionView.dispatch_request
127128
# Update Views API spec
128129
tag_spec(viewcls, "actions")
129130
return viewcls
@@ -175,37 +176,8 @@ def ThingProperty(viewcls):
175176
Returns:
176177
View: View class with Property spec tags
177178
"""
178-
179-
def property_notify(func):
180-
@wraps(func)
181-
def wrapped(*args, **kwargs):
182-
# Call the update function first to update property value
183-
original_response = func(*args, **kwargs)
184-
185-
if hasattr(viewcls, "get_value") and callable(viewcls.get_value):
186-
property_value = viewcls().get_value()
187-
else:
188-
property_value = None
189-
190-
property_name = getattr(viewcls, "endpoint", None) or getattr(
191-
viewcls, "__name__", "unknown"
192-
)
193-
194-
if current_labthing():
195-
current_labthing().message(
196-
PropertyStatusEvent(property_name), property_value,
197-
)
198-
199-
return original_response
200-
201-
return wrapped
202-
203-
if hasattr(viewcls, "post") and callable(viewcls.post):
204-
viewcls.post = property_notify(viewcls.post)
205-
206-
if hasattr(viewcls, "put") and callable(viewcls.put):
207-
viewcls.put = property_notify(viewcls.put)
208-
179+
# Set to PropertyView.dispatch_request
180+
viewcls.dispatch_request = PropertyView.dispatch_request
209181
# Update Views API spec
210182
tag_spec(viewcls, "properties")
211183
return viewcls

src/labthings/server/default_views/tasks.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ def get(self):
1515
return tasks.tasks()
1616

1717

18-
@Tag(["properties", "tasks"])
18+
@Tag(["tasks"])
1919
class TaskView(View):
2020
"""
2121
Manage a particular background task.

0 commit comments

Comments
 (0)