Skip to content

Commit 3693c16

Browse files
authored
Gevent-based websocket support (#23)
* Added method to return current GET response * Split out function to encode JSON with current Flask encoder settings * Added basic property subscriptions via websocket * Run gevent monkey patch on server import * Added default eventlet support * Updated to default install eventlet * Moved monkey patches to server submodules * Only let eventlet websockets handle connections requesting an upgrade * Fixed websocket route URL * Added eventlet server debug and log options * Reverted to gevent default (OFM live stream breaks with eventlet) * Moved monkey patch back to server top level * Better handle requests without websocket upgrade * Better handle NULL websocket messages * Removed default websocket echo * Removed eventlet dependency
1 parent 028e21c commit 3693c16

File tree

15 files changed

+341
-139
lines changed

15 files changed

+341
-139
lines changed

examples/builder.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ def cleanup():
1616
# Create LabThings Flask app
1717
app, labthing = create_app(
1818
__name__,
19+
prefix="/api",
1920
title="My Lab Device API",
2021
description="Test LabThing-based API",
2122
version="0.1.0",
@@ -46,5 +47,8 @@ def cleanup():
4647
if __name__ == "__main__":
4748
from labthings.server.wsgi import Server
4849

50+
logger = logging.getLogger()
51+
logger.setLevel(logging.DEBUG)
52+
4953
server = Server(app)
50-
server.run(host="0.0.0.0", port=5000, debug=True)
54+
server.run(host="0.0.0.0", port=5000, debug=False)

labthings/server/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,11 @@
1+
import logging
2+
13
EXTENSION_NAME = "flask-labthings"
4+
5+
# Monkey patching is bad and should never be done
6+
# import eventlet
7+
# eventlet.monkey_patch()
8+
9+
from gevent import monkey
10+
11+
monkey.patch_all()

labthings/server/decorators.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from webargs import flaskparser
22
from functools import wraps, update_wrapper
33
from flask import make_response, abort, request
4+
from werkzeug.wrappers import Response as ResponseBase
45
from http import HTTPStatus
56
from marshmallow.exceptions import ValidationError
67
from collections import Mapping
@@ -9,6 +10,7 @@
910
from .schema import TaskSchema, Schema, FieldSchema
1011
from .fields import Field
1112
from .view import View
13+
from .find import current_labthing
1214

1315
import logging
1416

@@ -102,7 +104,7 @@ def ThingAction(viewcls: View):
102104
Returns:
103105
View: View class with Action spec tags
104106
"""
105-
# Pass params to call function attribute for external access
107+
# Update Views API spec
106108
update_spec(viewcls, {"tags": ["actions"]})
107109
update_spec(viewcls, {"_groups": ["actions"]})
108110
return viewcls
@@ -120,7 +122,28 @@ def ThingProperty(viewcls):
120122
Returns:
121123
View: View class with Property spec tags
122124
"""
123-
# Pass params to call function attribute for external access
125+
126+
def property_notify(func):
127+
@wraps(func)
128+
def wrapped(*args, **kwargs):
129+
# Call the update function first to update property value
130+
original_response = func(*args, **kwargs)
131+
132+
# Once updated, then notify all subscribers
133+
subscribers = getattr(current_labthing(), "subscribers", [])
134+
for sub in subscribers:
135+
sub.property_notify(viewcls)
136+
return original_response
137+
138+
return wrapped
139+
140+
if hasattr(viewcls, "post") and callable(viewcls.post):
141+
viewcls.post = property_notify(viewcls.post)
142+
143+
if hasattr(viewcls, "put") and callable(viewcls.put):
144+
viewcls.put = property_notify(viewcls.put)
145+
146+
# Update Views API spec
124147
update_spec(viewcls, {"tags": ["properties"]})
125148
update_spec(viewcls, {"_groups": ["properties"]})
126149
return viewcls

labthings/server/labthing.py

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from .spec.utilities import get_spec
1111
from .spec.td import ThingDescription
1212
from .decorators import tag
13-
from .sockets import Sockets
13+
from .sockets import Sockets, SocketSubscriber, socket_handler_loop
1414

1515
from .views.extensions import ExtensionList
1616
from .views.tasks import TaskList, TaskView
@@ -38,6 +38,10 @@ def __init__(
3838
self.extensions = {}
3939

4040
self.views = []
41+
self._property_views = {}
42+
self._action_views = {}
43+
44+
self.subscribers = set()
4145

4246
self.endpoints = set()
4347

@@ -130,12 +134,20 @@ def _create_base_routes(self):
130134
self.add_view(TaskView, "/tasks/<task_id>", endpoint=TASK_ENDPOINT)
131135

132136
def _create_base_sockets(self):
133-
self.sockets.add_url_rule("/", self._socket_handler)
137+
self.sockets.add_url_rule(f"{self.url_prefix}", self._socket_handler)
134138

135139
def _socket_handler(self, ws):
136-
while not ws.closed:
137-
message = ws.receive()
138-
ws.send("Web sockets not yet implemented")
140+
# Create a socket subscriber
141+
wssub = SocketSubscriber(ws)
142+
self.subscribers.add(wssub)
143+
logging.info(f"Added subscriber {wssub}")
144+
logging.debug(list(self.subscribers))
145+
# Start the socket connection handler loop
146+
socket_handler_loop(ws)
147+
# Remove the subscriber once the loop returns
148+
self.subscribers.remove(wssub)
149+
logging.info(f"Removed subscriber {wssub}")
150+
logging.debug(list(self.subscribers))
139151

140152
# Device stuff
141153

@@ -277,8 +289,10 @@ def _register_view(self, app, view, *urls, endpoint=None, **kwargs):
277289
view_groups = view_spec.get("_groups", {})
278290
if "actions" in view_groups:
279291
self.thing_description.action(flask_rules, view)
292+
self._action_views[view.endpoint] = view
280293
if "properties" in view_groups:
281294
self.thing_description.property(flask_rules, view)
295+
self._property_views[view.endpoint] = view
282296

283297
# Utilities
284298

labthings/server/representations.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
from ..core.utilities import PY3
55

66

7-
def output_json(data, code, headers=None):
8-
"""Makes a Flask response with a JSON encoded body"""
7+
def encode_json(data):
8+
"""Makes JSON encoded data using the current Flask apps JSON settings"""
99

1010
settings = current_app.config.get("LABTHINGS_JSON", {})
1111
encoder = current_app.json_encoder
@@ -21,6 +21,14 @@ def output_json(data, code, headers=None):
2121
# see https://github.com/mitsuhiko/flask/pull/1262
2222
dumped = dumps(data, cls=encoder, **settings) + "\n"
2323

24+
return dumped
25+
26+
27+
def output_json(data, code, headers=None):
28+
"""Makes a Flask response with a JSON encoded body"""
29+
30+
dumped = encode_json(data) + "\n"
31+
2432
resp = make_response(dumped, code)
2533
resp.headers.extend(headers or {})
2634
return resp

labthings/server/sockets.py

Lines changed: 0 additions & 119 deletions
This file was deleted.

labthings/server/sockets/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
from .base import SocketSubscriber
2+
from .gevent import Sockets, socket_handler_loop

labthings/server/sockets/base.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
# -*- coding: utf-8 -*-
2+
3+
from werkzeug.routing import Map, Rule
4+
from werkzeug.exceptions import NotFound
5+
from werkzeug.http import parse_cookie
6+
from flask import request, current_app
7+
import logging
8+
from abc import ABC, abstractmethod
9+
10+
from ..representations import encode_json
11+
12+
13+
class SocketSubscriber:
14+
def __init__(self, ws):
15+
self.ws = ws
16+
17+
def property_notify(self, viewcls):
18+
if hasattr(viewcls, "get_value") and callable(viewcls.get_value):
19+
property_value = viewcls().get_value()
20+
else:
21+
property_value = None
22+
23+
property_name = str(getattr(viewcls, "endpoint", "unknown"))
24+
25+
response = encode_json(
26+
{"messageType": "propertyStatus", "data": {property_name: property_value},}
27+
)
28+
29+
self.ws.send(response)
30+
31+
32+
class BaseSockets(ABC):
33+
def __init__(self, app=None):
34+
#: Compatibility with 'Flask' application.
35+
#: The :class:`~werkzeug.routing.Map` for this instance. You can use
36+
#: this to change the routing converters after the class was created
37+
#: but before any routes are connected.
38+
self.url_map = Map()
39+
40+
#: Compatibility with 'Flask' application.
41+
#: All the attached blueprints in a dictionary by name. Blueprints
42+
#: can be attached multiple times so this dictionary does not tell
43+
#: you how often they got attached.
44+
self.blueprints = {}
45+
self._blueprint_order = []
46+
47+
if app:
48+
self.init_app(app)
49+
50+
@abstractmethod
51+
def init_app(self, app):
52+
pass
53+
54+
def route(self, rule, **options):
55+
def decorator(view_func):
56+
options.pop("endpoint", None)
57+
self.add_url_rule(rule, view_func, **options)
58+
return view_func
59+
60+
return decorator
61+
62+
def add_url_rule(self, rule, view_func, **options):
63+
self.url_map.add(Rule(rule, endpoint=view_func))
64+
65+
def register_blueprint(self, blueprint, **options):
66+
"""
67+
Registers a blueprint for web sockets like for 'Flask' application.
68+
Decorator :meth:`~flask.app.setupmethod` is not applied, because it
69+
requires ``debug`` and ``_got_first_request`` attributes to be defined.
70+
"""
71+
first_registration = False
72+
73+
if blueprint.name in self.blueprints:
74+
assert self.blueprints[blueprint.name] is blueprint, (
75+
"A blueprint's name collision occurred between %r and "
76+
'%r. Both share the same name "%s". Blueprints that '
77+
"are created on the fly need unique names."
78+
% (blueprint, self.blueprints[blueprint.name], blueprint.name)
79+
)
80+
else:
81+
self.blueprints[blueprint.name] = blueprint
82+
self._blueprint_order.append(blueprint)
83+
first_registration = True
84+
85+
blueprint.register(self, options, first_registration)
86+
87+
88+
def process_socket_message(message: str):
89+
if message:
90+
# return f"Recieved: {message}"
91+
return None
92+
else:
93+
return None

0 commit comments

Comments
 (0)