Skip to content

Commit 975c5c4

Browse files
authored
Add WebSocket support (#40)
* Add WebSocket support * Upd README
1 parent bc2788e commit 975c5c4

File tree

7 files changed

+356
-107
lines changed

7 files changed

+356
-107
lines changed

README.md

Lines changed: 83 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,14 @@
1212
* [Client](#client)
1313
* [authenticate](#authenticate)
1414
* [close](#close)
15+
* [close_and_wait](#close_and_wait)
1516
* [connect](#connect)
1617
* [connect_pool](#connect_pool)
1718
* [get_default_scope](#get_default_scope)
1819
* [get_event_loop](#get_event_loop)
1920
* [get_rooms](#get_rooms)
2021
* [is_connected](#is_connected)
22+
* [is_websocket](#is_websocket)
2123
* [query](#query)
2224
* [reconnect](#reconnect)
2325
* [run](#run)
@@ -31,6 +33,7 @@
3133
* [emit](#emit)
3234
* [no_join](#no_join)
3335
* [Failed packages](#failed-packages)
36+
* [WebSockets](#websockets)
3437
---------------------------------------
3538

3639
## Installation
@@ -71,8 +74,7 @@ async def hello_world():
7174

7275
finally:
7376
# the will close the client in a nice way
74-
client.close()
75-
await client.wait_closed()
77+
await client.close_and_wait()
7678

7779
# run the hello world example
7880
asyncio.get_event_loop().run_until_complete(hello_world())
@@ -148,6 +150,16 @@ This method will return immediately so the connection may not be
148150
closed yet after a call to `close()`. Use the [wait_closed()](#wait_closed) method
149151
after calling this method if this is required.
150152

153+
### close_and_wait
154+
155+
```python
156+
async Client().close_and_wait() -> None
157+
```
158+
159+
Close and wait for the the connection to be closed.
160+
161+
This is equivalent of combining [close()](#close)) and [wait_closed()](#wait_closed).
162+
151163
### connect
152164

153165
```python
@@ -167,11 +179,12 @@ connection before using the connection.
167179
#### Args
168180

169181
- *host (str)*:
170-
A hostname, IP address, FQDN to connect to.
182+
A hostname, IP address, FQDN or URI _(for WebSockets)_ to connect to.
171183
- *port (int, optional)*:
172184
Integer value between 0 and 65535 and should be the port number
173185
where a ThingsDB node is listening to for client connections.
174-
Defaults to 9200.
186+
Defaults to 9200. For WebSocket connections the port must be
187+
provided with the URI _(see host argument)_.
175188
- *timeout (int, optional)*:
176189
Can be be used to control the maximum time the client will
177190
attempt to create a connection. The timeout may be set to
@@ -207,17 +220,18 @@ to perform the authentication.
207220

208221
```python
209222
await connect_pool([
210-
'node01.local', # address as string
211-
'node02.local', # port will default to 9200
212-
('node03.local', 9201), # ..or with an explicit port
223+
'node01.local', # address or WebSocket URI as string
224+
'node02.local', # port will default to 9200 or ignored for URI
225+
('node03.local', 9201), # ..or with an explicit port (ignored for URI)
213226
], "admin", "pass")
214227
```
215228

216229
#### Args
217230

218231
- *pool (list of addresses)*:
219232
Should be an iterable with node address strings, or tuples
220-
with `address` and `port` combinations in a tuple or list.
233+
with `address` and `port` combinations in a tuple or list. For WebSockets,
234+
the address must be an URI with the port included. (e.g: `"ws://host:9270"`)
221235
- *\*auth (str or (str, str))*:
222236
Argument `auth` can be be either a string with a token or a
223237
tuple with username and password. (the latter may be provided
@@ -282,6 +296,18 @@ Can be used to check if the client is connected.
282296
#### Returns
283297
`True` when the client is connected else `False`.
284298

299+
300+
### is_websocket
301+
302+
```python
303+
Client().is_websocket() -> bool
304+
```
305+
306+
Can be used to check if the client is using a WebSocket connection.
307+
308+
#### Returns
309+
`True` when the client is connected else `False`.
310+
285311
### query
286312

287313
```python
@@ -595,3 +621,52 @@ set_package_fail_file('/tmp/thingsdb-invalid-data.mp')
595621
# When a package is received which fails to unpack, the data from this package
596622
# will be stored to file.
597623
```
624+
625+
626+
## WebSockets
627+
628+
Since ThingsDB 1.6 has received WebSocket support. The Python client is able to use the WebSockets protocol by providing the `host` as URI.
629+
For WebSocket connections,the `port` argument will be ignored and must be specified with the URI instead.
630+
631+
Default the `websockets` package is **not included** when installing this connector.
632+
633+
If you want to use WebSockets, make sure to install the package:
634+
635+
```
636+
pip install websockets
637+
```
638+
639+
For example:
640+
641+
```python
642+
import asyncio
643+
from thingsdb.client import Client
644+
645+
async def hello_world():
646+
client = Client()
647+
648+
# replace `ws://localhost:9270` with your URI
649+
await client.connect('ws://localhost:9270')
650+
651+
# for a secure connection, use wss:// and provide an SSL context, example:
652+
# (ssl can be set either to True or False, or an SSLContext)
653+
#
654+
# await client.connect('wss://localhost:9270', ssl=True)
655+
656+
try:
657+
# replace `admin` and `pass` with your username and password
658+
# or use a valid token string
659+
await client.authenticate('admin', 'pass')
660+
661+
# perform the hello world code...
662+
print(await client.query('''
663+
"Hello World!";
664+
''')
665+
666+
finally:
667+
# the will close the client in a nice way
668+
await client.close_and_wait()
669+
670+
# run the hello world example
671+
asyncio.get_event_loop().run_until_complete(hello_world())
672+
```

requirements.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
11
msgpack>=0.6.2
22
deprecation
3+
# Optional package:
4+
# websockets

test_thingsdb.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,7 @@ async def async_test_playground(self):
2020
self.assertEqual(data, want)
2121

2222
finally:
23-
client.close()
24-
await client.wait_closed()
23+
await client.close_and_wait()
2524

2625
def test_playground(self):
2726
loop = asyncio.get_event_loop()

thingsdb/client/client.py

Lines changed: 50 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from typing import Optional, Union, Any
88
from concurrent.futures import CancelledError
99
from .buildin import Buildin
10-
from .protocol import Proto, Protocol
10+
from .protocol import Proto, Protocol, ProtocolWS
1111
from ..exceptions import NodeError, AuthError
1212
from ..util import strip_code
1313

@@ -85,7 +85,7 @@ def is_connected(self) -> bool:
8585
Returns:
8686
bool: `True` when the client is connected else `False`.
8787
"""
88-
return bool(self._protocol and self._protocol.transport)
88+
return bool(self._protocol and self._protocol.is_connected())
8989

9090
def set_default_scope(self, scope: str) -> None:
9191
"""Set the default scope.
@@ -119,9 +119,9 @@ def close(self) -> None:
119119
closed yet after a call to `close()`. Use the `wait_closed()` method
120120
after calling this method if this is required.
121121
"""
122-
if self._protocol and self._protocol.transport:
123-
self._reconnect = False
124-
self._protocol.transport.close()
122+
self._reconnect = False
123+
if self._protocol:
124+
self._protocol.close()
125125

126126
def connection_info(self) -> str:
127127
"""Returns the current connection info as a string.
@@ -134,7 +134,7 @@ def connection_info(self) -> str:
134134
"""
135135
if not self.is_connected():
136136
return 'disconnected'
137-
socket = self._protocol.transport.get_extra_info('socket', None)
137+
socket = self._protocol.info()
138138
if socket is None:
139139
return 'unknown_addr'
140140
addr, port = socket.getpeername()[:2]
@@ -205,11 +205,13 @@ def connect(
205205
206206
Args:
207207
host (str):
208-
A hostname, IP address, FQDN to connect to.
208+
A hostname, IP address, FQDN or URI (for WebSockets) to connect
209+
to.
209210
port (int, optional):
210211
Integer value between 0 and 65535 and should be the port number
211212
where a ThingsDB node is listening to for client connections.
212-
Defaults to 9200.
213+
Defaults to 9200. For WebSocket connections the port must be
214+
provided with the URI (see host argument).
213215
timeout (int, optional):
214216
Can be be used to control the maximum time the client will
215217
attempt to create a connection. The timeout may be set to
@@ -250,8 +252,19 @@ async def wait_closed(self) -> None:
250252
Can be used after calling the `close()` method to determine when the
251253
connection is actually closed.
252254
"""
253-
if self._protocol and self._protocol.close_future:
254-
await self._protocol.close_future
255+
if self._protocol and self._protocol.is_closing():
256+
await self._protocol.wait_closed()
257+
258+
async def close_and_wait(self) -> None:
259+
"""Close and wait for the connection to be closed.
260+
261+
This is equivalent to calling close() and await wait_closed()
262+
"""
263+
if self._protocol:
264+
await self._protocol.close_and_wait()
265+
266+
def is_websocket(self) -> bool:
267+
return self._protocol.__class__ is ProtocolWS
255268

256269
async def authenticate(
257270
self,
@@ -538,20 +551,32 @@ def _auth_check(auth):
538551
)
539552
return auth
540553

554+
@staticmethod
555+
def _is_websocket_host(host):
556+
return host.startswith('ws://') or host.startswith('wss://')
557+
541558
async def _connect(self, timeout=5):
542559
host, port = self._pool[self._pool_idx]
543560
try:
544-
conn = self._loop.create_connection(
545-
lambda: Protocol(
561+
if self._is_websocket_host(host):
562+
conn = ProtocolWS(
546563
on_connection_lost=self._on_connection_lost,
547-
on_event=self._on_event,
548-
loop=self._loop),
549-
host=host,
550-
port=port,
551-
ssl=self._ssl)
552-
_, self._protocol = await asyncio.wait_for(
553-
conn,
554-
timeout=timeout)
564+
on_event=self._on_event).connect(uri=host, ssl=self._ssl)
565+
self._protocol = await asyncio.wait_for(
566+
conn,
567+
timeout=timeout)
568+
else:
569+
conn = self._loop.create_connection(
570+
lambda: Protocol(
571+
on_connection_lost=self._on_connection_lost,
572+
on_event=self._on_event,
573+
loop=self._loop),
574+
host=host,
575+
port=port,
576+
ssl=self._ssl)
577+
_, self._protocol = await asyncio.wait_for(
578+
conn,
579+
timeout=timeout)
555580
finally:
556581
self._pool_idx += 1
557582
self._pool_idx %= len(self._pool)
@@ -614,15 +639,17 @@ async def _reconnect_loop(self):
614639
await self._authenticate(timeout=5)
615640
await self._rejoin()
616641
except Exception as e:
642+
name = host if self._is_websocket_host(host) else \
643+
f'{host}:{port}'
617644
logging.error(
618-
f'Connecting to {host}:{port} failed: '
645+
f'Connecting to {name} failed: '
619646
f'{e}({e.__class__.__name__}), '
620647
f'Try next connect in {wait_time} seconds'
621648
)
622649
else:
623-
if protocol and protocol.transport:
650+
if protocol and protocol.is_connected():
624651
# make sure the `old` connection will be dropped
625-
self._loop.call_later(10.0, protocol.transport.close)
652+
self._loop.call_later(10.0, protocol.close)
626653
break
627654

628655
await asyncio.sleep(wait_time)

thingsdb/client/package.py

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -33,26 +33,39 @@ def __init__(self, barray: bytearray) -> None:
3333
self.total = self.__class__.st_package.size + self.length
3434
self.data = None
3535

36+
def _handle_fail_file(self, message: bytes):
37+
if _fail_file:
38+
try:
39+
with open(_fail_file, 'wb') as f:
40+
f.write(
41+
message[self.__class__.st_package.size:self.total])
42+
except Exception:
43+
logging.exception('')
44+
else:
45+
logging.warning(
46+
f'Wrote the content from {self} to `{_fail_file}`')
47+
3648
def extract_data_from(self, barray: bytearray) -> None:
3749
try:
3850
self.data = msgpack.unpackb(
3951
barray[self.__class__.st_package.size:self.total],
4052
raw=False) \
4153
if self.length else None
4254
except Exception as e:
43-
if _fail_file:
44-
try:
45-
with open(_fail_file, 'wb') as f:
46-
f.write(
47-
barray[self.__class__.st_package.size:self.total])
48-
except Exception:
49-
logging.exception('')
50-
else:
51-
logging.warning(
52-
f'Wrote the content from {self} to `{_fail_file}`')
55+
self._handle_fail_file(barray)
5356
raise e
5457
finally:
5558
del barray[:self.total]
5659

60+
def read_data_from(self, message: bytes) -> None:
61+
try:
62+
self.data = msgpack.unpackb(
63+
message[self.__class__.st_package.size:self.total],
64+
raw=False) \
65+
if self.length else None
66+
except Exception as e:
67+
self._handle_fail_file(message)
68+
raise e
69+
5770
def __repr__(self) -> str:
5871
return '<id: {0.pid} size: {0.length} tp: {0.tp}>'.format(self)

0 commit comments

Comments
 (0)