nucosObs 0.4.18__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nucosObs/__init__.py +110 -0
- nucosObs/__main__.py +72 -0
- nucosObs/aiohttpWebsocketInterface.py +265 -0
- nucosObs/observable.py +40 -0
- nucosObs/observer.py +253 -0
- nucosObs/stdinInterface.py +52 -0
- nucosObs/twoWayInterface.py +57 -0
- nucosObs/version.py +1 -0
- nucosObs/websocketInterface.py +154 -0
- nucosobs-0.4.18.dist-info/METADATA +236 -0
- nucosobs-0.4.18.dist-info/RECORD +14 -0
- nucosobs-0.4.18.dist-info/WHEEL +5 -0
- nucosobs-0.4.18.dist-info/licenses/LICENSE.txt +9 -0
- nucosobs-0.4.18.dist-info/top_level.txt +1 -0
nucosObs/__init__.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Helper functions and globals used by ``nucosObs``."""
|
|
2
|
+
|
|
3
|
+
import asyncio as aio
|
|
4
|
+
from .version import version as __version__
|
|
5
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Runtime:
|
|
9
|
+
"""Own the event loop and mutable state for an observer application."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, loop=None):
|
|
12
|
+
self.loop = loop or aio.new_event_loop()
|
|
13
|
+
self.pool = ThreadPoolExecutor(4)
|
|
14
|
+
self.allObs = []
|
|
15
|
+
self.allObservables = []
|
|
16
|
+
self.debug = [False]
|
|
17
|
+
self._tasks = set()
|
|
18
|
+
self._shutdown = False
|
|
19
|
+
|
|
20
|
+
def create_task(self, coroutine):
|
|
21
|
+
"""Create and track work owned by this runtime."""
|
|
22
|
+
task = self.loop.create_task(coroutine)
|
|
23
|
+
self._tasks.add(task)
|
|
24
|
+
task.add_done_callback(self._tasks.discard)
|
|
25
|
+
return task
|
|
26
|
+
|
|
27
|
+
async def shutdown(self):
|
|
28
|
+
"""Request observer shutdown and return a structured state report."""
|
|
29
|
+
open_observers = [observer for observer in self.allObs if not observer.stop]
|
|
30
|
+
pending_tasks = [task for task in self._tasks if not task.done()]
|
|
31
|
+
report = {
|
|
32
|
+
"already_shutdown": self._shutdown,
|
|
33
|
+
"observers": {
|
|
34
|
+
"total": len(self.allObs),
|
|
35
|
+
"open": len(open_observers),
|
|
36
|
+
},
|
|
37
|
+
"tasks": {"pending": len(pending_tasks), "cancelled": 0},
|
|
38
|
+
}
|
|
39
|
+
if self._shutdown:
|
|
40
|
+
return report
|
|
41
|
+
|
|
42
|
+
self._shutdown = True
|
|
43
|
+
for observer in self.allObs:
|
|
44
|
+
observer.stop = True
|
|
45
|
+
observer.stopSchedule()
|
|
46
|
+
for observable in self.allObservables:
|
|
47
|
+
await observable.put({"action": "stop_observer"})
|
|
48
|
+
for task in pending_tasks:
|
|
49
|
+
task.cancel()
|
|
50
|
+
if pending_tasks:
|
|
51
|
+
await aio.gather(*pending_tasks, return_exceptions=True)
|
|
52
|
+
report["tasks"]["cancelled"] = len(pending_tasks)
|
|
53
|
+
return report
|
|
54
|
+
|
|
55
|
+
def main_loop(self, ui, test=False):
|
|
56
|
+
"""Run this runtime's observers and optional UI coroutines."""
|
|
57
|
+
_run_main_loop(self, ui, test)
|
|
58
|
+
|
|
59
|
+
def close(self):
|
|
60
|
+
"""Release this runtime's worker pool and event loop."""
|
|
61
|
+
self.pool.shutdown(wait=False)
|
|
62
|
+
if not self.loop.is_closed():
|
|
63
|
+
self.loop.close()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
pool = ThreadPoolExecutor(4)
|
|
67
|
+
allObs = []
|
|
68
|
+
allObservables = []
|
|
69
|
+
|
|
70
|
+
# Create a default event loop on import so that modules relying on the loop
|
|
71
|
+
# do not trigger a deprecation warning with ``get_event_loop``.
|
|
72
|
+
loop = aio.new_event_loop()
|
|
73
|
+
aio.set_event_loop(loop)
|
|
74
|
+
debug = [False]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def get_all_pending_futures(ui=[]):
|
|
78
|
+
"""Return coroutines for all registered observers and schedules."""
|
|
79
|
+
obs = [o.observe for o in allObs]
|
|
80
|
+
schedules = [o.scheduleLoop
|
|
81
|
+
for o in allObs if o.schedule_task is not None]
|
|
82
|
+
return [*ui, *obs, *schedules]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _run_main_loop(runtime, ui, test=False):
|
|
86
|
+
"""Run the event loop with all observers and optional UI coroutines."""
|
|
87
|
+
# the workers should be closed first
|
|
88
|
+
obs = [o.observe() for o in runtime.allObs]
|
|
89
|
+
if runtime.debug[-1]:
|
|
90
|
+
print([o.name for o in runtime.allObs])
|
|
91
|
+
schedules = [o.scheduleLoop()
|
|
92
|
+
for o in runtime.allObs if o.schedule_task is not None]
|
|
93
|
+
|
|
94
|
+
async def run_all():
|
|
95
|
+
await aio.gather(*ui, *obs, *schedules)
|
|
96
|
+
|
|
97
|
+
runtime.loop.run_until_complete(run_all())
|
|
98
|
+
if runtime.debug[-1] and not test:
|
|
99
|
+
print("try to close loop")
|
|
100
|
+
if not test:
|
|
101
|
+
runtime.loop.close()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def main_loop(ui, test=False):
|
|
105
|
+
"""Run the default module runtime with observers and UI coroutines."""
|
|
106
|
+
_run_main_loop(__import__(__name__), ui, test)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# from nucosObs.observable import Observable
|
|
110
|
+
# from nucosObs.observer import BroadcastObserver
|
nucosObs/__main__.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Command-line diagnostics for nucosObs installations."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import platform
|
|
6
|
+
from importlib import metadata
|
|
7
|
+
|
|
8
|
+
import nucosObs
|
|
9
|
+
from nucosObs.version import version
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _dependency_version(name):
|
|
13
|
+
try:
|
|
14
|
+
return metadata.version(name)
|
|
15
|
+
except metadata.PackageNotFoundError:
|
|
16
|
+
return "not installed"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def diagnostics():
|
|
20
|
+
"""Return the installed package and default runtime diagnostic state."""
|
|
21
|
+
return {
|
|
22
|
+
"package": "nucosObs",
|
|
23
|
+
"version": version,
|
|
24
|
+
"python": {
|
|
25
|
+
"installed": platform.python_version(),
|
|
26
|
+
"supported": ">=3.11",
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"aiohttp": _dependency_version("aiohttp"),
|
|
30
|
+
"websockets": _dependency_version("websockets"),
|
|
31
|
+
},
|
|
32
|
+
"runtime": {
|
|
33
|
+
"debug_enabled": bool(nucosObs.debug[-1]),
|
|
34
|
+
"loop_closed": nucosObs.loop.is_closed(),
|
|
35
|
+
"observables": len(nucosObs.allObservables),
|
|
36
|
+
"observers": len(nucosObs.allObs),
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def main():
|
|
42
|
+
"""Print package diagnostics for support and development workflows."""
|
|
43
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
44
|
+
parser.add_argument("--json", action="store_true", help="print JSON output")
|
|
45
|
+
args = parser.parse_args()
|
|
46
|
+
report = diagnostics()
|
|
47
|
+
if args.json:
|
|
48
|
+
print(json.dumps(report, sort_keys=True))
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
print(f"{report['package']} {report['version']}")
|
|
52
|
+
print(
|
|
53
|
+
"Python: "
|
|
54
|
+
f"{report['python']['installed']} "
|
|
55
|
+
f"(supported {report['python']['supported']})"
|
|
56
|
+
)
|
|
57
|
+
print(
|
|
58
|
+
"Dependencies: "
|
|
59
|
+
f"aiohttp {report['dependencies']['aiohttp']}, "
|
|
60
|
+
f"websockets {report['dependencies']['websockets']}"
|
|
61
|
+
)
|
|
62
|
+
print(
|
|
63
|
+
"Runtime: "
|
|
64
|
+
f"observers={report['runtime']['observers']}, "
|
|
65
|
+
f"observables={report['runtime']['observables']}, "
|
|
66
|
+
f"loop_closed={report['runtime']['loop_closed']}, "
|
|
67
|
+
f"debug_enabled={report['runtime']['debug_enabled']}"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
if __name__ == "__main__":
|
|
72
|
+
main()
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""Websocket interface implementation using ``aiohttp``."""
|
|
2
|
+
|
|
3
|
+
import asyncio as aio
|
|
4
|
+
import inspect
|
|
5
|
+
import secrets
|
|
6
|
+
from aiohttp import web
|
|
7
|
+
import aiohttp
|
|
8
|
+
try:
|
|
9
|
+
import simplejson as json
|
|
10
|
+
except:
|
|
11
|
+
import json
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
from nucosCR import random, hexdigest_n
|
|
15
|
+
isCR = True
|
|
16
|
+
except:
|
|
17
|
+
import random
|
|
18
|
+
isCR = False
|
|
19
|
+
|
|
20
|
+
from nucosObs import loop, debug
|
|
21
|
+
from nucosObs.observer import broadcast
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _token(length):
|
|
25
|
+
if isCR:
|
|
26
|
+
return random(length).decode()
|
|
27
|
+
return secrets.token_urlsafe(length)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class AiohttpWebsocketInterface(object):
|
|
31
|
+
"""A websocket interface based on ``aiohttp``."""
|
|
32
|
+
def __init__(self, app, broker, doAuth=False, closeOnClientQuit=False,
|
|
33
|
+
authenticator=None,onCloseCallback=None, heartbeat=None,
|
|
34
|
+
closeSanely=None,
|
|
35
|
+
receive_timeout=None, sslClient=None, sslServer=None,
|
|
36
|
+
route="/ws", backend="default", on_error=None):
|
|
37
|
+
"""Create the interface and register a websocket route."""
|
|
38
|
+
self.app = app
|
|
39
|
+
self.onCloseCallback = onCloseCallback
|
|
40
|
+
self.backend = backend
|
|
41
|
+
self.ws = {}
|
|
42
|
+
self.doAuth = doAuth
|
|
43
|
+
self.closeSanely = closeSanely
|
|
44
|
+
self.broker = broker
|
|
45
|
+
self.server = None
|
|
46
|
+
self.authenticator = authenticator
|
|
47
|
+
self.nonce = {}
|
|
48
|
+
self.isAuthenticated = {}
|
|
49
|
+
self.connectedUser = {}
|
|
50
|
+
self.isRefused = []
|
|
51
|
+
self.closeOnClientQuit = closeOnClientQuit
|
|
52
|
+
self.sslClient = sslClient
|
|
53
|
+
self.sslServer = sslServer
|
|
54
|
+
self.client_session = None
|
|
55
|
+
self.approved = []
|
|
56
|
+
self.receive_timeout = receive_timeout
|
|
57
|
+
self.heartbeat = heartbeat
|
|
58
|
+
self.ids = []
|
|
59
|
+
self.id_0 = None
|
|
60
|
+
self.on_error = on_error
|
|
61
|
+
app.router.add_route('GET', route, self.handler)
|
|
62
|
+
|
|
63
|
+
async def _report_error(self, context, error):
|
|
64
|
+
if self.on_error is not None:
|
|
65
|
+
result = self.on_error(context, error)
|
|
66
|
+
if inspect.isawaitable(result):
|
|
67
|
+
await result
|
|
68
|
+
|
|
69
|
+
async def send(self, msg, user):
|
|
70
|
+
"""Send ``msg`` to the websocket connection belonging to ``user``."""
|
|
71
|
+
id_ = self.connectedUser.get(user)
|
|
72
|
+
if id_ is None:
|
|
73
|
+
return
|
|
74
|
+
else:
|
|
75
|
+
try:
|
|
76
|
+
await self.ws[id_].send_str(msg)
|
|
77
|
+
except Exception as error:
|
|
78
|
+
await self._report_error("send", error)
|
|
79
|
+
|
|
80
|
+
async def send_by_id(self, msg, id_):
|
|
81
|
+
"""
|
|
82
|
+
"""
|
|
83
|
+
if id_ in self.ws:
|
|
84
|
+
await self.ws[id_].send_str(msg)
|
|
85
|
+
|
|
86
|
+
async def send_by_client(self, msg, client):
|
|
87
|
+
"""
|
|
88
|
+
client is the numbering
|
|
89
|
+
wait until connected
|
|
90
|
+
"""
|
|
91
|
+
while True:
|
|
92
|
+
try:
|
|
93
|
+
id_ = self.ids[client]
|
|
94
|
+
break
|
|
95
|
+
except:
|
|
96
|
+
# print("SEND failed", self.ids, client, msg)
|
|
97
|
+
await aio.sleep(0.2)
|
|
98
|
+
await self.ws[id_].send_str(msg)
|
|
99
|
+
async def broadcast(self, msg):
|
|
100
|
+
"""Send ``msg`` to all connected websocket clients."""
|
|
101
|
+
for id_, ws in self.ws.items():
|
|
102
|
+
try:
|
|
103
|
+
await ws.send_str(msg)
|
|
104
|
+
except Exception as error:
|
|
105
|
+
await self._report_error("broadcast", error)
|
|
106
|
+
|
|
107
|
+
async def connect(self, host, port):
|
|
108
|
+
"""Connect to a remote websocket server."""
|
|
109
|
+
if debug[-1]:
|
|
110
|
+
print("try to start client")
|
|
111
|
+
# self.server = await websockets.connect(self.handler, ip, port)
|
|
112
|
+
if self.sslClient:
|
|
113
|
+
protocol = "wss"
|
|
114
|
+
else:
|
|
115
|
+
protocol = "ws"
|
|
116
|
+
self.client_session = aiohttp.ClientSession()
|
|
117
|
+
options = {}
|
|
118
|
+
if self.sslClient is not None:
|
|
119
|
+
options["ssl"] = self.sslClient
|
|
120
|
+
websocket = await self.client_session.ws_connect(
|
|
121
|
+
'%s://%s:%s' % (protocol, host, str(port)), **options
|
|
122
|
+
)
|
|
123
|
+
self.ws['client'] = websocket
|
|
124
|
+
await self.listener(websocket, 'client')
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
async def handler(self, request):
|
|
128
|
+
"""Handle incoming websocket upgrade requests."""
|
|
129
|
+
ws = web.WebSocketResponse(heartbeat=self.heartbeat)
|
|
130
|
+
await ws.prepare(request)
|
|
131
|
+
id_ = ws.headers.get("Sec-Websocket-Accept")
|
|
132
|
+
self.ws.update({id_: ws})
|
|
133
|
+
self.id_0 = id_
|
|
134
|
+
self.ids.append(id_) # store the first connection for easier reference
|
|
135
|
+
if debug[-1]:
|
|
136
|
+
print("Partner connected")
|
|
137
|
+
print(self.ws)
|
|
138
|
+
if self.doAuth:
|
|
139
|
+
self.nonce[id_] = _token(24)
|
|
140
|
+
context = {"name": "doAuth",
|
|
141
|
+
"args": {"nonce": self.nonce[id_], "id": id_},
|
|
142
|
+
"action": "authenticate",
|
|
143
|
+
"backend": self.backend}
|
|
144
|
+
await ws.send_str(json.dumps(context)) #or send_bytes ??
|
|
145
|
+
try:
|
|
146
|
+
await self.listener(ws, id_)
|
|
147
|
+
except aio.TimeoutError:
|
|
148
|
+
if debug[-1]:
|
|
149
|
+
print("timeout....")
|
|
150
|
+
self.remove_connection(id_)
|
|
151
|
+
await ws.close()
|
|
152
|
+
# NOTE next line is mandatory for preventing a closed websocket to raise exception
|
|
153
|
+
return ws
|
|
154
|
+
|
|
155
|
+
async def shutdown(self):
|
|
156
|
+
"""Close all websocket connections and notify observers."""
|
|
157
|
+
if debug[-1]:
|
|
158
|
+
print("in shutdown process ...")
|
|
159
|
+
await broadcast.put({"name": "broadcast", "args": [{"action": "stop_observer"}]})
|
|
160
|
+
for websocket in list(self.ws.values()):
|
|
161
|
+
await websocket.close()
|
|
162
|
+
if self.client_session is not None and not self.client_session.closed:
|
|
163
|
+
await self.client_session.close()
|
|
164
|
+
|
|
165
|
+
async def _closeSanely_(self, id_old, user):
|
|
166
|
+
"""
|
|
167
|
+
protects the hard shutdown if the same user is connected once again
|
|
168
|
+
"""
|
|
169
|
+
if self.closeSanely:
|
|
170
|
+
try:
|
|
171
|
+
await self.closeSanely(user, id_old)
|
|
172
|
+
except Exception as error:
|
|
173
|
+
await self._report_error("close_sanely", error)
|
|
174
|
+
if debug[-1]:
|
|
175
|
+
print("closeSanely callback failed:", error)
|
|
176
|
+
await aio.sleep(3.0)
|
|
177
|
+
if id_old in self.ws:
|
|
178
|
+
await self.ws[id_old].close()
|
|
179
|
+
self.remove_connection(id_old)
|
|
180
|
+
|
|
181
|
+
async def listener(self, ws, id_):
|
|
182
|
+
"""Listen for messages on ``ws`` and forward them to the broker."""
|
|
183
|
+
user = "unknown"
|
|
184
|
+
while True:
|
|
185
|
+
# async for msg in ws: ---> replaced by ...
|
|
186
|
+
# if no message arrives after lately 20 secs the connection is closed
|
|
187
|
+
msg = await ws.receive(timeout=self.receive_timeout)
|
|
188
|
+
if msg.type == aiohttp.WSMsgType.text:
|
|
189
|
+
data = msg.data
|
|
190
|
+
if id_ not in self.isAuthenticated and self.doAuth:
|
|
191
|
+
id_out, user = await self.authenticator.startAuth(data, ws, self.nonce[id_])
|
|
192
|
+
if id_out is not None and id_out == id_:
|
|
193
|
+
self.isAuthenticated.update({id_: user})
|
|
194
|
+
if user in self.connectedUser:
|
|
195
|
+
id_old = self.connectedUser.pop(user)
|
|
196
|
+
self.isAuthenticated.pop(id_old)
|
|
197
|
+
try:
|
|
198
|
+
aio.ensure_future(self._closeSanely_(id_old, user))
|
|
199
|
+
except:
|
|
200
|
+
pass
|
|
201
|
+
finally:
|
|
202
|
+
if debug[-1]:
|
|
203
|
+
print("closed one pending connection of user %s" % user)
|
|
204
|
+
self.connectedUser.update({user: id_})
|
|
205
|
+
else:
|
|
206
|
+
self.connectedUser.update({user: id_})
|
|
207
|
+
# print(self.connectedUser, self.ws)
|
|
208
|
+
else:
|
|
209
|
+
await self._report_error(
|
|
210
|
+
"authentication", PermissionError("authentication rejected")
|
|
211
|
+
)
|
|
212
|
+
await self.ws[id_].close()
|
|
213
|
+
break
|
|
214
|
+
else:
|
|
215
|
+
await self.broker.put(data)
|
|
216
|
+
elif msg.type == aiohttp.WSMsgType.CLOSED:
|
|
217
|
+
print(f"closed by client {user}")
|
|
218
|
+
await self.ws[id_].close()
|
|
219
|
+
break
|
|
220
|
+
elif msg.type == aiohttp.WSMsgType.ERROR:
|
|
221
|
+
print(f"error by client {user}")
|
|
222
|
+
await self.ws[id_].close()
|
|
223
|
+
break
|
|
224
|
+
else:
|
|
225
|
+
if debug[-1]:
|
|
226
|
+
print(f"an unknown text message arrived {msg.type} by {user}")
|
|
227
|
+
await self.ws[id_].close()
|
|
228
|
+
break
|
|
229
|
+
# print("out of order.........")
|
|
230
|
+
if id_ == "client":
|
|
231
|
+
await self.shutdown()
|
|
232
|
+
else:
|
|
233
|
+
self.remove_connection(id_)
|
|
234
|
+
if self.closeOnClientQuit:
|
|
235
|
+
if debug[-1]:
|
|
236
|
+
print("client died ...")
|
|
237
|
+
if len(self.ws) == 0:
|
|
238
|
+
await self.broker.put("client exit")
|
|
239
|
+
await self.shutdown()
|
|
240
|
+
if self.onCloseCallback:
|
|
241
|
+
try:
|
|
242
|
+
await self.onCloseCallback(user)
|
|
243
|
+
except Exception as error:
|
|
244
|
+
await self._report_error("on_close", error)
|
|
245
|
+
if debug[-1]:
|
|
246
|
+
print("close callback failed:", error)
|
|
247
|
+
if debug[-1]:
|
|
248
|
+
print("--- connection of %s stopped " % user)
|
|
249
|
+
#if self.onCloseCallback:
|
|
250
|
+
# await self.onCloseCallback(user)
|
|
251
|
+
|
|
252
|
+
def remove_connection(self, id_):
|
|
253
|
+
"""Remove a connection from the internal registry."""
|
|
254
|
+
self.ws.pop(id_, None)
|
|
255
|
+
if id_ in self.ids:
|
|
256
|
+
self.ids.remove(id_)
|
|
257
|
+
if id_ in self.isAuthenticated:
|
|
258
|
+
user = self.isAuthenticated.pop(id_)
|
|
259
|
+
if self.connectedUser.get(user) == id_:
|
|
260
|
+
self.connectedUser.pop(user)
|
|
261
|
+
if debug[-1]:
|
|
262
|
+
print("after client left:")
|
|
263
|
+
print("user...",self.connectedUser)
|
|
264
|
+
print("ws.....",self.ws)
|
|
265
|
+
|
nucosObs/observable.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Observable helper used by observers to share events."""
|
|
2
|
+
|
|
3
|
+
import asyncio as aio
|
|
4
|
+
import nucosObs
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class NoConcurrentObserver(Exception):
|
|
8
|
+
def __init__(self, message):
|
|
9
|
+
self.message = message
|
|
10
|
+
|
|
11
|
+
class Observable():
|
|
12
|
+
"""
|
|
13
|
+
The observable can be observed by one or many observers. Observers are registered in their init.
|
|
14
|
+
The events are produced in tasks which are then put into the async loop from the beginning on.
|
|
15
|
+
|
|
16
|
+
"""
|
|
17
|
+
def __init__(self, runtime=None):
|
|
18
|
+
self.runtime = runtime or nucosObs
|
|
19
|
+
self.loop = self.runtime.loop
|
|
20
|
+
self.q = {}
|
|
21
|
+
self.runtime.allObservables.append(self)
|
|
22
|
+
|
|
23
|
+
def register(self, observer, concurrent=[]):
|
|
24
|
+
if not concurrent:
|
|
25
|
+
self.q.update({observer.name: aio.Queue()})
|
|
26
|
+
observer._queue = self.q[observer.name]
|
|
27
|
+
else:
|
|
28
|
+
concurrentQueues = []
|
|
29
|
+
for c in concurrent:
|
|
30
|
+
if c in self.q:
|
|
31
|
+
concurrentQueues.append(self.q[c])
|
|
32
|
+
if concurrentQueues:
|
|
33
|
+
observer._queue = concurrentQueues[0]
|
|
34
|
+
else:
|
|
35
|
+
raise NoConcurrentObserver("No known observers %s" % concurrent)
|
|
36
|
+
|
|
37
|
+
async def put(self, event):
|
|
38
|
+
for q in self.q.values():
|
|
39
|
+
await q.put(event)
|
|
40
|
+
|
nucosObs/observer.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Base classes and helpers for creating observers."""
|
|
2
|
+
|
|
3
|
+
import asyncio as aio
|
|
4
|
+
import inspect
|
|
5
|
+
|
|
6
|
+
import nucosObs
|
|
7
|
+
from nucosObs.observable import Observable
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def inThread(callback=False):
|
|
11
|
+
def wrapper(func):
|
|
12
|
+
"""
|
|
13
|
+
Use this decorator to indicate that the function is not-async and time consuming and should be run in a separate thread.
|
|
14
|
+
The observer awaits the result, other observers are running normally.
|
|
15
|
+
|
|
16
|
+
:param callback: True if a callback is defined, default is False
|
|
17
|
+
:type callback: bool
|
|
18
|
+
"""
|
|
19
|
+
func.inThread = True
|
|
20
|
+
func.callback = callback
|
|
21
|
+
return func
|
|
22
|
+
return wrapper
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class NoCallbackException(Exception):
|
|
26
|
+
"""
|
|
27
|
+
Exception which is raised, if a callback is expected but not asigned to the callbacks dict.
|
|
28
|
+
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, message):
|
|
32
|
+
self.message = message
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Observer():
|
|
36
|
+
"""
|
|
37
|
+
The callback class which reacts on observables. An Observer must register on observables.
|
|
38
|
+
Many Observers can register on one observable.
|
|
39
|
+
One Observer can register only on one observable and not many.
|
|
40
|
+
|
|
41
|
+
Concurrent observers are possible. Just add other observers by name in the parameter list concurrent.
|
|
42
|
+
|
|
43
|
+
Subclass Observer and add any async function which is then executed when invoked in the observables.
|
|
44
|
+
The parameters for that call must be operated in the parse function.
|
|
45
|
+
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, name, observable, concurrent=[], runtime=None, on_error=None):
|
|
49
|
+
"""
|
|
50
|
+
:param name: the name of the observer
|
|
51
|
+
:type name: str
|
|
52
|
+
:param observable: the observable to listen on
|
|
53
|
+
:type observable: observable
|
|
54
|
+
:param concurrent: all other concurrent observers (or just one of them, they share all on _queue)
|
|
55
|
+
:type concurrent: list
|
|
56
|
+
"""
|
|
57
|
+
self.runtime = runtime or observable.runtime
|
|
58
|
+
if observable.runtime is not self.runtime:
|
|
59
|
+
raise ValueError("observer and observable must share a runtime")
|
|
60
|
+
self.name = name
|
|
61
|
+
self._queue = None
|
|
62
|
+
self.loop = self.runtime.loop
|
|
63
|
+
self.runtime.allObs.append(self)
|
|
64
|
+
observable.register(self, concurrent)
|
|
65
|
+
self.callbacks = {}
|
|
66
|
+
self.schedule_time = 1.0
|
|
67
|
+
self.schedule_task = None
|
|
68
|
+
self.schedule_args = []
|
|
69
|
+
self.schedule_kwargs = {}
|
|
70
|
+
self.stopTimeFraction = 10 # MEANS IN A 10TH FRACTION CAN BES THE LOOP STOPPED
|
|
71
|
+
self.stop = False
|
|
72
|
+
self.stopScheduleLoop = False
|
|
73
|
+
self._bridge_ = {}
|
|
74
|
+
self.on_error = on_error
|
|
75
|
+
|
|
76
|
+
async def _report_error(self, context, error):
|
|
77
|
+
if self.on_error is not None:
|
|
78
|
+
result = self.on_error(context, error)
|
|
79
|
+
if inspect.isawaitable(result):
|
|
80
|
+
await result
|
|
81
|
+
|
|
82
|
+
def scheduleRegular(self, method, t, *schedule_args, **schedule_kwargs):
|
|
83
|
+
self.schedule_task = method
|
|
84
|
+
self.schedule_args = schedule_args
|
|
85
|
+
self.schedule_kwargs = schedule_kwargs
|
|
86
|
+
self.schedule_time = t
|
|
87
|
+
# self.stopTimeFraction = 5
|
|
88
|
+
self.stopScheduleLoop = False
|
|
89
|
+
|
|
90
|
+
async def shutdown(self):
|
|
91
|
+
if self.runtime.debug[-1]:
|
|
92
|
+
print("shutdown now...")
|
|
93
|
+
self.stop = True
|
|
94
|
+
for observable in self.runtime.allObservables:
|
|
95
|
+
await observable.put({"action": "stop_observer"})
|
|
96
|
+
|
|
97
|
+
async def scheduleOnce(self, method, t, *args, **kwargs):
|
|
98
|
+
await aio.sleep(t)
|
|
99
|
+
await method(*args, **kwargs)
|
|
100
|
+
|
|
101
|
+
def parse(self, item):
|
|
102
|
+
"""
|
|
103
|
+
Dict parse logic + str parse logic, overwrite if necessary
|
|
104
|
+
|
|
105
|
+
a message is of the form {"name": function-name, "args": [A,B,C,...] }
|
|
106
|
+
or "function-name A B C"
|
|
107
|
+
|
|
108
|
+
"""
|
|
109
|
+
if isinstance(item, dict):
|
|
110
|
+
if "name" in item:
|
|
111
|
+
fct = item["name"]
|
|
112
|
+
else:
|
|
113
|
+
fct = None
|
|
114
|
+
if "args" in item:
|
|
115
|
+
args = item["args"]
|
|
116
|
+
else:
|
|
117
|
+
args = []
|
|
118
|
+
if hasattr(self, fct):
|
|
119
|
+
method = getattr(self, fct)
|
|
120
|
+
if hasattr(method, '__call__'):
|
|
121
|
+
return True, method, args
|
|
122
|
+
else:
|
|
123
|
+
return False, None, None
|
|
124
|
+
else:
|
|
125
|
+
return False, None, None
|
|
126
|
+
elif isinstance(item, str):
|
|
127
|
+
items = item.split(" ")
|
|
128
|
+
fct, args = items[0], items[1:]
|
|
129
|
+
|
|
130
|
+
if hasattr(self, fct):
|
|
131
|
+
# print(fct, args)
|
|
132
|
+
method = getattr(self, fct)
|
|
133
|
+
if hasattr(method, '__call__'):
|
|
134
|
+
return True, method, args
|
|
135
|
+
else:
|
|
136
|
+
return False, None, None
|
|
137
|
+
else:
|
|
138
|
+
return False, None, None
|
|
139
|
+
else:
|
|
140
|
+
return False, None, None
|
|
141
|
+
|
|
142
|
+
def startSchedule(self):
|
|
143
|
+
# print("START schedule")
|
|
144
|
+
self.stopScheduleLoop = False
|
|
145
|
+
if hasattr(self.runtime, "create_task"):
|
|
146
|
+
self.runtime.create_task(self.scheduleLoop())
|
|
147
|
+
else:
|
|
148
|
+
self.loop.create_task(self.scheduleLoop())
|
|
149
|
+
|
|
150
|
+
def stopSchedule(self):
|
|
151
|
+
self.stopScheduleLoop = True
|
|
152
|
+
|
|
153
|
+
async def scheduleLoop(self):
|
|
154
|
+
self.stop = False
|
|
155
|
+
while not self.stop and not self.stopScheduleLoop:
|
|
156
|
+
n = 0
|
|
157
|
+
while n < self.stopTimeFraction:
|
|
158
|
+
n += 1
|
|
159
|
+
await aio.sleep(self.schedule_time / self.stopTimeFraction)
|
|
160
|
+
if self.stop:
|
|
161
|
+
break
|
|
162
|
+
if self.schedule_task:
|
|
163
|
+
await self.schedule_task(*self.schedule_args, **self.schedule_kwargs)
|
|
164
|
+
if self.runtime.debug[-1]:
|
|
165
|
+
print("leave scheduleLoop")
|
|
166
|
+
|
|
167
|
+
async def observe(self):
|
|
168
|
+
"""
|
|
169
|
+
Do not overwrite the main observe logic, better overwrite the parse method for incoming string-messages.
|
|
170
|
+
|
|
171
|
+
"""
|
|
172
|
+
self.stop = False
|
|
173
|
+
while not self.stop:
|
|
174
|
+
if self._queue is None:
|
|
175
|
+
break
|
|
176
|
+
item = await self._queue.get()
|
|
177
|
+
if self.runtime.debug[-1]:
|
|
178
|
+
print("observer %s received %s, type %s" %
|
|
179
|
+
(self.name, item, type(item)))
|
|
180
|
+
try:
|
|
181
|
+
isCallable, method, args = self.parse(item)
|
|
182
|
+
except Exception as error:
|
|
183
|
+
await self._report_error("parse", error)
|
|
184
|
+
if self.runtime.debug[-1]:
|
|
185
|
+
print("parse failed: %s" % item)
|
|
186
|
+
isCallable = False
|
|
187
|
+
if isCallable:
|
|
188
|
+
try:
|
|
189
|
+
if hasattr(method, "inThread"):
|
|
190
|
+
await aio.get_running_loop().run_in_executor(
|
|
191
|
+
self.runtime.pool, method, *args
|
|
192
|
+
)
|
|
193
|
+
else:
|
|
194
|
+
await method(*args)
|
|
195
|
+
if hasattr(method, "callback") and method.callback:
|
|
196
|
+
if method in self.callbacks:
|
|
197
|
+
await self.callbacks[method]()
|
|
198
|
+
else:
|
|
199
|
+
raise NoCallbackException(
|
|
200
|
+
"No callback known of method %s" % method)
|
|
201
|
+
except Exception as error:
|
|
202
|
+
await self._report_error("handler", error)
|
|
203
|
+
raise
|
|
204
|
+
elif isinstance(item, dict) and "action" in item:
|
|
205
|
+
if self.runtime.debug[-1]:
|
|
206
|
+
print("....", item)
|
|
207
|
+
if item["action"] == "stop_observer":
|
|
208
|
+
self.stop = True
|
|
209
|
+
break
|
|
210
|
+
elif isinstance(item, str) and "stop_observer" in item:
|
|
211
|
+
# print(".....", isCallable, method, args, item)
|
|
212
|
+
self.stop = True
|
|
213
|
+
break
|
|
214
|
+
else:
|
|
215
|
+
if self.runtime.debug[-1]:
|
|
216
|
+
print("swallowed: %s %s" % (self.name, item))
|
|
217
|
+
if self.runtime.debug[-1]:
|
|
218
|
+
print("--- Observer: %s stopped %s" % (self.name, self.stop))
|
|
219
|
+
|
|
220
|
+
def set_bridge_method(self, method_name, method_hook):
|
|
221
|
+
"""
|
|
222
|
+
Defines bridge methods which are just follow-ups to communicate with ApplicationSessions
|
|
223
|
+
|
|
224
|
+
"""
|
|
225
|
+
self._bridge_.update({method_name: method_hook})
|
|
226
|
+
|
|
227
|
+
async def bridge(self, method, *args):
|
|
228
|
+
if self.runtime.debug[-1]:
|
|
229
|
+
print("bridge call %s" % method)
|
|
230
|
+
if method not in self._bridge_:
|
|
231
|
+
return
|
|
232
|
+
else:
|
|
233
|
+
await self._bridge_[method](*args)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class BroadcastObserver(Observer):
|
|
237
|
+
def __init__(self, name, observable, concurrent=[], runtime=None, on_error=None):
|
|
238
|
+
super(BroadcastObserver, self).__init__(name, observable, concurrent, runtime, on_error)
|
|
239
|
+
self.obs = self.runtime.allObservables
|
|
240
|
+
|
|
241
|
+
async def broadcast(self, msg):
|
|
242
|
+
if self.runtime.debug[-1]:
|
|
243
|
+
print("broadcast ...", msg, type(msg))
|
|
244
|
+
if "action" in msg:
|
|
245
|
+
if self.runtime.debug[-1] and msg["action"] == "stop_observer":
|
|
246
|
+
print("shut down all observers ...")
|
|
247
|
+
for o in self.obs:
|
|
248
|
+
await o.put(msg)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
broadcast = Observable()
|
|
252
|
+
broadcastObserver = BroadcastObserver("broadcastObserver", broadcast)
|
|
253
|
+
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Simple stdin based interface to feed observables."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import asyncio as aio
|
|
5
|
+
|
|
6
|
+
import nucosObs
|
|
7
|
+
from nucosObs import debug
|
|
8
|
+
from nucosObs.observer import broadcast
|
|
9
|
+
|
|
10
|
+
class StdinInterface(object):
|
|
11
|
+
"""Interface reading commands from ``stdin``."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, observable, input_stream=None, loop=None):
|
|
14
|
+
"""Create the interface and attach ``observable`` for output."""
|
|
15
|
+
self.observable = observable
|
|
16
|
+
self.input_stream = input_stream or sys.stdin
|
|
17
|
+
self.loop = loop or getattr(observable, "loop", nucosObs.loop)
|
|
18
|
+
self.q = aio.Queue()
|
|
19
|
+
try:
|
|
20
|
+
self.loop.add_reader(self.input_stream, self.got_input)
|
|
21
|
+
except (OSError, ValueError, NotImplementedError):
|
|
22
|
+
pass
|
|
23
|
+
self.stop = False
|
|
24
|
+
|
|
25
|
+
def got_input(self):
|
|
26
|
+
"""Callback for the event loop when input is available."""
|
|
27
|
+
self.loop.create_task(self.q.put(self.input_stream.readline()))
|
|
28
|
+
|
|
29
|
+
async def get_ui(self):
|
|
30
|
+
"""Coroutine processing the input queue and dispatching commands."""
|
|
31
|
+
out = []
|
|
32
|
+
while not self.stop:
|
|
33
|
+
out = (await self.q.get()).strip()
|
|
34
|
+
if "leave_in" in out:
|
|
35
|
+
self.stop = True
|
|
36
|
+
t = float(out.split()[-1])
|
|
37
|
+
await aio.sleep(t)
|
|
38
|
+
await self.observable.put({"name": "shutdown", "args": []})
|
|
39
|
+
break
|
|
40
|
+
elif out.endswith('x'):
|
|
41
|
+
self.stop = True
|
|
42
|
+
await self.observable.put({"name": "shutdown", "args": []})
|
|
43
|
+
break
|
|
44
|
+
elif out.endswith('say'):
|
|
45
|
+
await self.observable.put({"name": "say", "args": []})
|
|
46
|
+
elif 'fact' in out:
|
|
47
|
+
await self.observable.put({"name": "fact", "args": [out.split(" ")[-1]]})
|
|
48
|
+
else:
|
|
49
|
+
await self.observable.put(out)
|
|
50
|
+
if debug[-1]:
|
|
51
|
+
print("Stdin-Interface stopped")
|
|
52
|
+
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Interface sending and receiving directives between observables."""
|
|
2
|
+
|
|
3
|
+
import asyncio as aio
|
|
4
|
+
|
|
5
|
+
import nucosObs
|
|
6
|
+
from nucosObs import debug
|
|
7
|
+
from nucosObs.observer import broadcast
|
|
8
|
+
|
|
9
|
+
class TwoWayInterface(object):
|
|
10
|
+
"""Interface that forwards messages between multiple observables."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, observables_dict, send_all=False):
|
|
13
|
+
"""Create interface with mapping of names to observables."""
|
|
14
|
+
self.loop = nucosObs.loop
|
|
15
|
+
# Remove deprecated loop parameter when creating the queue
|
|
16
|
+
self.q = aio.Queue()
|
|
17
|
+
self.observables_dict = observables_dict
|
|
18
|
+
self.stop = False
|
|
19
|
+
self.send_all = send_all
|
|
20
|
+
|
|
21
|
+
async def put(self, txt):
|
|
22
|
+
"""Put a new directive into the internal queue."""
|
|
23
|
+
await self.q.put(txt)
|
|
24
|
+
|
|
25
|
+
async def get_ui(self):
|
|
26
|
+
"""Process directives from :func:`put` until a stop command arrives."""
|
|
27
|
+
self.stop = False
|
|
28
|
+
while not self.stop:
|
|
29
|
+
directive = dict(await self.q.get())
|
|
30
|
+
if debug[-1]:
|
|
31
|
+
print("interface received", directive)
|
|
32
|
+
if "action" in directive:
|
|
33
|
+
action = directive["action"]
|
|
34
|
+
if "waitTime" in action:
|
|
35
|
+
t = float(action.split()[-1])
|
|
36
|
+
await aio.sleep(t)
|
|
37
|
+
continue
|
|
38
|
+
if action.endswith('stop interface'):
|
|
39
|
+
self.stop = True
|
|
40
|
+
await broadcast.put({"name": "broadcast", "args": [{"action": "stop_observer"}]})
|
|
41
|
+
break
|
|
42
|
+
if "leave_in" in action:
|
|
43
|
+
self.stop = True
|
|
44
|
+
t = float(action.split()[-1])
|
|
45
|
+
await aio.sleep(t)
|
|
46
|
+
break
|
|
47
|
+
if "obs" in directive:
|
|
48
|
+
nameObs = directive.pop("obs")
|
|
49
|
+
if nameObs in self.observables_dict:
|
|
50
|
+
await self.observables_dict[nameObs].put(directive)
|
|
51
|
+
elif self.send_all:
|
|
52
|
+
for observable in self.observables_dict.values():
|
|
53
|
+
await observable.put(dict(directive))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
if debug[-1]:
|
|
57
|
+
print("--- Interface stopped")
|
nucosObs/version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
version='0.4.18'
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Basic ``websockets`` based interface for observers."""
|
|
2
|
+
|
|
3
|
+
import websockets
|
|
4
|
+
import asyncio as aio
|
|
5
|
+
import inspect
|
|
6
|
+
import secrets
|
|
7
|
+
try:
|
|
8
|
+
import simplejson as json
|
|
9
|
+
except:
|
|
10
|
+
import json
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
from nucosCR import random, hexdigest_n
|
|
14
|
+
isCR = True
|
|
15
|
+
except:
|
|
16
|
+
import random
|
|
17
|
+
isCR = False
|
|
18
|
+
|
|
19
|
+
from nucosObs import loop, debug
|
|
20
|
+
from nucosObs.observer import broadcast
|
|
21
|
+
# debug.append(True)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _token(length):
|
|
25
|
+
if isCR:
|
|
26
|
+
return random(length).decode()
|
|
27
|
+
return secrets.token_urlsafe(length)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class WebsocketInterface(object):
|
|
31
|
+
"""Simple websocket server/client using the ``websockets`` package."""
|
|
32
|
+
|
|
33
|
+
def __init__(self,
|
|
34
|
+
broker,
|
|
35
|
+
doAuth=False,
|
|
36
|
+
closeOnClientQuit=False,
|
|
37
|
+
authenticator=None,
|
|
38
|
+
sslClient=None,
|
|
39
|
+
sslServer=None,
|
|
40
|
+
on_error=None):
|
|
41
|
+
"""Initialize the interface and optionally enable authentication."""
|
|
42
|
+
self.ws = {}
|
|
43
|
+
self.doAuth = doAuth
|
|
44
|
+
self.broker = broker
|
|
45
|
+
self.server = None
|
|
46
|
+
self.authenticator = authenticator
|
|
47
|
+
self.nonce = {}
|
|
48
|
+
self.isAuthenticated = {}
|
|
49
|
+
self.isRefused = []
|
|
50
|
+
self.closeOnClientQuit = closeOnClientQuit
|
|
51
|
+
self.sslClient = sslClient
|
|
52
|
+
self.sslServer = sslServer
|
|
53
|
+
self.approved = []
|
|
54
|
+
self.on_error = on_error
|
|
55
|
+
|
|
56
|
+
async def _report_error(self, context, error):
|
|
57
|
+
if self.on_error is not None:
|
|
58
|
+
result = self.on_error(context, error)
|
|
59
|
+
if inspect.isawaitable(result):
|
|
60
|
+
await result
|
|
61
|
+
|
|
62
|
+
async def broadcast(self, msg, client=None):
|
|
63
|
+
"""Broadcast ``msg`` to all clients or to ``client`` if given."""
|
|
64
|
+
for i, antenna in enumerate(self.ws.values()):
|
|
65
|
+
if client is None or i == client:
|
|
66
|
+
try:
|
|
67
|
+
await antenna.send(msg)
|
|
68
|
+
except Exception as error:
|
|
69
|
+
await self._report_error("broadcast", error)
|
|
70
|
+
|
|
71
|
+
async def connect(self, host, port):
|
|
72
|
+
"""Connect as a client to ``host`` and ``port``."""
|
|
73
|
+
if debug[-1]:
|
|
74
|
+
print("try to start client")
|
|
75
|
+
# self.server = await websockets.connect(self.handler, ip, port)
|
|
76
|
+
if self.sslClient:
|
|
77
|
+
protocol = "wss"
|
|
78
|
+
else:
|
|
79
|
+
protocol = "ws"
|
|
80
|
+
websocket = await websockets.connect('%s://%s:%s/ws' % (protocol, host, str(port)), ssl=self.sslClient)
|
|
81
|
+
self.ws['client'] = websocket
|
|
82
|
+
await self.listener(websocket, 'client')
|
|
83
|
+
|
|
84
|
+
async def serve(self, ip, port):
|
|
85
|
+
"""Start a websocket server bound to ``ip``/``port``."""
|
|
86
|
+
if debug[-1]:
|
|
87
|
+
print("try to start server")
|
|
88
|
+
self.server = await websockets.serve(self.handler, ip, port, ssl=self.sslServer)
|
|
89
|
+
print("started server", self.server)
|
|
90
|
+
|
|
91
|
+
async def handler(self, websocket):
|
|
92
|
+
"""Handle a single websocket connection."""
|
|
93
|
+
id_ = _token(12)
|
|
94
|
+
self.ws[id_] = websocket
|
|
95
|
+
if debug[-1]:
|
|
96
|
+
print("Partner connected")
|
|
97
|
+
if self.doAuth:
|
|
98
|
+
self.nonce[id_] = _token(24)
|
|
99
|
+
context = {"name": "doAuth",
|
|
100
|
+
"args": {"nonce": self.nonce[id_], "id": id_},
|
|
101
|
+
"action": "authenticate"}
|
|
102
|
+
await self.ws[id_].send(json.dumps(context))
|
|
103
|
+
await self.listener(self.ws[id_], id_)
|
|
104
|
+
|
|
105
|
+
async def shutdown(self):
|
|
106
|
+
"""Close all open connections and inform observers."""
|
|
107
|
+
if debug[-1]:
|
|
108
|
+
print("in shutdown process ...")
|
|
109
|
+
await broadcast.put({"name": "broadcast", "args": [{"action": "stop_observer"}]})
|
|
110
|
+
if self.server is not None:
|
|
111
|
+
for k in [x for x in self.ws.keys()]:
|
|
112
|
+
await self.ws[k].close()
|
|
113
|
+
|
|
114
|
+
def remove_connection(self, id_):
|
|
115
|
+
"""Remove all state associated with a websocket connection."""
|
|
116
|
+
self.ws.pop(id_, None)
|
|
117
|
+
self.nonce.pop(id_, None)
|
|
118
|
+
self.isAuthenticated.pop(id_, None)
|
|
119
|
+
|
|
120
|
+
async def listener(self, ws, id_):
|
|
121
|
+
"""Read messages from ``ws`` and route them to the broker."""
|
|
122
|
+
user = "unknown"
|
|
123
|
+
while True:
|
|
124
|
+
try:
|
|
125
|
+
msg = await ws.recv()
|
|
126
|
+
except websockets.ConnectionClosed:
|
|
127
|
+
if id_ == "client":
|
|
128
|
+
await self.shutdown()
|
|
129
|
+
else:
|
|
130
|
+
self.remove_connection(id_)
|
|
131
|
+
if self.closeOnClientQuit:
|
|
132
|
+
if debug[-1]:
|
|
133
|
+
print("client died ...")
|
|
134
|
+
if len(self.ws) == 0:
|
|
135
|
+
await self.broker.put("client exit")
|
|
136
|
+
await self.shutdown()
|
|
137
|
+
break
|
|
138
|
+
|
|
139
|
+
if id_ not in self.isAuthenticated and self.doAuth:
|
|
140
|
+
id_out, user = await self.authenticator.startAuth(msg, ws, self.nonce[id_])
|
|
141
|
+
if id_out is not None and id_out == id_:
|
|
142
|
+
self.isAuthenticated.update({id_: user})
|
|
143
|
+
else:
|
|
144
|
+
await self._report_error(
|
|
145
|
+
"authentication", PermissionError("authentication rejected")
|
|
146
|
+
)
|
|
147
|
+
self.remove_connection(id_)
|
|
148
|
+
await ws.close()
|
|
149
|
+
break
|
|
150
|
+
else:
|
|
151
|
+
await self.broker.put(msg)
|
|
152
|
+
|
|
153
|
+
if debug[-1]:
|
|
154
|
+
print("--- connection of %s stopped " % user)
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nucosObs
|
|
3
|
+
Version: 0.4.18
|
|
4
|
+
Summary: nucosObs - an observer/observable toolbox based on asyncio
|
|
5
|
+
Home-page: https://github.com/DocBO/nucosObs
|
|
6
|
+
Author: Oliver Braun
|
|
7
|
+
Author-email: oliver.braun@nucos.de
|
|
8
|
+
License: MIT
|
|
9
|
+
Project-URL: Source, https://github.com/NuCOS/nucosObs
|
|
10
|
+
Project-URL: Issues, https://github.com/NuCOS/nucosObs/issues
|
|
11
|
+
Project-URL: Changelog, https://github.com/NuCOS/nucosObs/blob/master/docs/CHANGELOG.md
|
|
12
|
+
Project-URL: Security, https://github.com/NuCOS/nucosObs/security/policy
|
|
13
|
+
Keywords: observer observable asyncio
|
|
14
|
+
Classifier: Development Status :: 1 - Planning
|
|
15
|
+
Classifier: Environment :: Console
|
|
16
|
+
Classifier: Natural Language :: English
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE.txt
|
|
24
|
+
Requires-Dist: websockets<17,>=15
|
|
25
|
+
Requires-Dist: aiohttp<4,>=3.12
|
|
26
|
+
Provides-Extra: test
|
|
27
|
+
Requires-Dist: pytest<10,>=9; extra == "test"
|
|
28
|
+
Requires-Dist: pytest-asyncio<2,>=1; extra == "test"
|
|
29
|
+
Requires-Dist: pytest-cov<8,>=7; extra == "test"
|
|
30
|
+
Dynamic: author
|
|
31
|
+
Dynamic: author-email
|
|
32
|
+
Dynamic: classifier
|
|
33
|
+
Dynamic: description
|
|
34
|
+
Dynamic: description-content-type
|
|
35
|
+
Dynamic: home-page
|
|
36
|
+
Dynamic: keywords
|
|
37
|
+
Dynamic: license
|
|
38
|
+
Dynamic: license-file
|
|
39
|
+
Dynamic: project-url
|
|
40
|
+
Dynamic: provides-extra
|
|
41
|
+
Dynamic: requires-dist
|
|
42
|
+
Dynamic: requires-python
|
|
43
|
+
Dynamic: summary
|
|
44
|
+
|
|
45
|
+
# nucosObs
|
|
46
|
+
|
|
47
|
+
[](https://pypi.org/project/nucosObs/)
|
|
48
|
+
[](https://github.com/NuCOS/nucosObs/actions/workflows/tests.yml)
|
|
49
|
+
[](LICENSE.txt)
|
|
50
|
+
|
|
51
|
+
`nucosObs` is an observer-observable framework based on `asyncio`.
|
|
52
|
+
|
|
53
|
+
## Status
|
|
54
|
+
|
|
55
|
+
The project supports Python 3.11 through 3.13. Continuous integration runs the
|
|
56
|
+
full pytest suite and builds a wheel for each supported version.
|
|
57
|
+
|
|
58
|
+
## Install
|
|
59
|
+
```
|
|
60
|
+
pip install nucosObs
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
## Documentation
|
|
65
|
+
|
|
66
|
+
A comprehensive user guide covering beginner to expert workflows is available:
|
|
67
|
+
|
|
68
|
+
➡️ **[User Guide — docs/USER_GUIDE.md](docs/USER_GUIDE.md)**
|
|
69
|
+
|
|
70
|
+
The guide includes tutorials for basic observer patterns, scheduling, threaded
|
|
71
|
+
handlers, websocket interfaces, authentication, multi-tenant runtimes, error
|
|
72
|
+
handling, and production deployments.
|
|
73
|
+
|
|
74
|
+
The project also ships a small but handy toolbox to build applications using
|
|
75
|
+
the observer/observable pattern with ``asyncio``. It contains helper
|
|
76
|
+
classes for observers, observables and a couple of interfaces (stdin,
|
|
77
|
+
websockets and aiohttp based websockets) to communicate with running
|
|
78
|
+
tasks.
|
|
79
|
+
|
|
80
|
+
### Example
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
import asyncio as aio
|
|
84
|
+
|
|
85
|
+
from nucosObs import main_loop
|
|
86
|
+
from nucosObs.observable import Observable
|
|
87
|
+
from nucosObs.observer import Observer
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class HelloObserver(Observer):
|
|
91
|
+
async def say(self):
|
|
92
|
+
print("Hello")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
A = Observable()
|
|
96
|
+
O = HelloObserver("O", A)
|
|
97
|
+
aio.ensure_future(A.put({"name": "say"}))
|
|
98
|
+
main_loop([])
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
See the ``examples`` directory for more advanced usage.
|
|
102
|
+
|
|
103
|
+
## Event Model
|
|
104
|
+
|
|
105
|
+
An observable delivers each event to every registered observer queue. Events
|
|
106
|
+
can be dictionaries or command strings. Dictionary events use this shape:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
{"name": "method_name", "args": ["first argument", "second argument"]}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Observers process regular async handlers sequentially. A
|
|
113
|
+
`{"action": "stop_observer"}` event stops an observer after its active handler
|
|
114
|
+
has completed.
|
|
115
|
+
|
|
116
|
+
## Threaded Handlers
|
|
117
|
+
|
|
118
|
+
Use `@inThread()` for synchronous work that must not block the event loop. The
|
|
119
|
+
handler runs in the runtime's thread pool. With `callback=True`, register an
|
|
120
|
+
async callback for the bound handler in `observer.callbacks`; it runs after the
|
|
121
|
+
threaded method finishes.
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
from nucosObs.observer import Observer, inThread
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class Worker(Observer):
|
|
128
|
+
@inThread()
|
|
129
|
+
def calculate(self, value):
|
|
130
|
+
return value * 2
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Isolated Runtimes
|
|
134
|
+
|
|
135
|
+
The module-level `main_loop()` API remains available for existing programs. For
|
|
136
|
+
multiple applications in one process, create a `Runtime` and pass it to each
|
|
137
|
+
observable and observer. Each runtime owns its event loop, registries, debug
|
|
138
|
+
state, and thread pool.
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
from nucosObs import Runtime
|
|
142
|
+
from nucosObs.observable import Observable
|
|
143
|
+
from nucosObs.observer import Observer
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
runtime = Runtime()
|
|
147
|
+
events = Observable(runtime=runtime)
|
|
148
|
+
worker = Worker("worker", events, runtime=runtime)
|
|
149
|
+
runtime.loop.create_task(events.put({"name": "calculate", "args": [21]}))
|
|
150
|
+
runtime.loop.create_task(events.put({"action": "stop_observer"}))
|
|
151
|
+
runtime.main_loop([])
|
|
152
|
+
runtime.close()
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Websocket Authentication
|
|
156
|
+
|
|
157
|
+
Both websocket interfaces send an authentication challenge when `doAuth=True`.
|
|
158
|
+
The configured authenticator must provide an async
|
|
159
|
+
`startAuth(message, websocket, nonce)` method and return
|
|
160
|
+
`(connection_id, user)`. Returning a matching connection ID accepts the client;
|
|
161
|
+
returning `None` rejects and closes it. Clients may send regular broker messages
|
|
162
|
+
only after successful authentication.
|
|
163
|
+
|
|
164
|
+
## Interfaces
|
|
165
|
+
|
|
166
|
+
An **Interface** is a bridge between the external world and the observable /
|
|
167
|
+
observer system. It reads data from an external source (stdin, a network
|
|
168
|
+
socket, another observable) and puts structured events into an `Observable`,
|
|
169
|
+
which then dispatches them to all registered `Observer` instances.
|
|
170
|
+
|
|
171
|
+
### Supported Interface Types
|
|
172
|
+
|
|
173
|
+
| Interface | Module | Purpose |
|
|
174
|
+
|---|---|---|
|
|
175
|
+
| `StdinInterface` | `nucosObs.stdinInterface` | Reads commands from standard input (terminal) |
|
|
176
|
+
| `TwoWayInterface` | `nucosObs.twoWayInterface` | Routes events between named observables within the same process |
|
|
177
|
+
| `WebsocketInterface` | `nucosObs.websocketInterface` | WebSocket server/client using the [`websockets`](https://pypi.org/project/websockets/) library |
|
|
178
|
+
| `AiohttpWebsocketInterface` | `nucosObs.aiohttpWebsocketInterface` | WebSocket server/client using the [`aiohttp`](https://pypi.org/project/aiohttp/) framework |
|
|
179
|
+
|
|
180
|
+
### Websocket Backend Comparison
|
|
181
|
+
|
|
182
|
+
| Feature | `WebsocketInterface` | `AiohttpWebsocketInterface` |
|
|
183
|
+
|---|---|---|
|
|
184
|
+
| **Library** | `websockets` (lightweight) | `aiohttp` (full web framework) |
|
|
185
|
+
| **Use case** | Dedicated WebSocket server/client | WebSocket alongside an HTTP server |
|
|
186
|
+
| **TLS / SSL** | Yes (`sslServer`, `sslClient`) | Yes (`sslServer`, `sslClient`) |
|
|
187
|
+
| **Authentication** | Yes (`doAuth`) | Yes (`doAuth`) |
|
|
188
|
+
| **Heartbeat / ping** | No | Yes (`heartbeat`) |
|
|
189
|
+
| **Receive timeout** | No | Yes (`receive_timeout`) |
|
|
190
|
+
| **User-aware send** | Manual via connection dict | `send(msg, user)` by username |
|
|
191
|
+
| **Duplicate user handling** | Manual | Built-in via `connectedUser` + `closeSanely` |
|
|
192
|
+
|
|
193
|
+
Use `WebsocketInterface` when you need a simple, standalone WebSocket
|
|
194
|
+
endpoint. Use `AiohttpWebsocketInterface` when you already run an `aiohttp`
|
|
195
|
+
web application, need heartbeats and timeouts, or want user-aware message
|
|
196
|
+
delivery.
|
|
197
|
+
|
|
198
|
+
### Interface Lifecycle
|
|
199
|
+
|
|
200
|
+
1. An interface reads or receives input (stdin line, WebSocket message,
|
|
201
|
+
internal event).
|
|
202
|
+
2. It converts the input into an event (dict or string).
|
|
203
|
+
3. It calls `await observable.put(event)` to dispatch the event.
|
|
204
|
+
4. All observers registered on that observable process the event.
|
|
205
|
+
5. The interface handles shutdown via stop commands or connection closure.
|
|
206
|
+
|
|
207
|
+
## Diagnostics
|
|
208
|
+
|
|
209
|
+
Run `python -m nucosObs` to print the installed package version, dependency
|
|
210
|
+
versions, supported Python range, and default runtime state. Add `--json` for
|
|
211
|
+
machine-readable output suitable for support reports.
|
|
212
|
+
|
|
213
|
+
## License
|
|
214
|
+
|
|
215
|
+
Distributed under the [MIT License](LICENSE.txt).
|
|
216
|
+
|
|
217
|
+
## Platforms
|
|
218
|
+
No specific platform dependency. Python 3.11 or later is required.
|
|
219
|
+
|
|
220
|
+
## Testing
|
|
221
|
+
Install development dependencies with `pip install -r requirements-dev.txt`,
|
|
222
|
+
then run the test suite with `python -m pytest`.
|
|
223
|
+
|
|
224
|
+
The repository runs this command in GitHub Actions on Python 3.11 through 3.13.
|
|
225
|
+
|
|
226
|
+
For a coverage-grounded view of current readiness and compatible next steps,
|
|
227
|
+
see [docs/FINAL_READINESS.md](docs/FINAL_READINESS.md).
|
|
228
|
+
|
|
229
|
+
## Contributing and Support
|
|
230
|
+
|
|
231
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for the development workflow and
|
|
232
|
+
[CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) for community expectations. Report
|
|
233
|
+
security vulnerabilities according to [SECURITY.md](SECURITY.md); for general
|
|
234
|
+
questions or reproducible bugs, open a GitHub issue.
|
|
235
|
+
|
|
236
|
+
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
nucosObs/__init__.py,sha256=RrTJW95-ExDc2amp4Bvc3A-_eBppdZToyLQPfBR3cbk,3658
|
|
2
|
+
nucosObs/__main__.py,sha256=Ww1NzQ4QmphRyi7KPF7Usd3fwR_Z9bTVPjjluAlVVl8,2067
|
|
3
|
+
nucosObs/aiohttpWebsocketInterface.py,sha256=s99sOCd3Xj0Xdr6iqPrQRH7vZha6rVZWtkWeUe_fBcI,10023
|
|
4
|
+
nucosObs/observable.py,sha256=RgCQYa1rtuAXT56ulLsolGPnfX0a24BDFHyNTBtJE04,1267
|
|
5
|
+
nucosObs/observer.py,sha256=yGaJ3hxHw8o-HgGg8Qln9HYj-vuN9B0SPasYkq1tlTI,9049
|
|
6
|
+
nucosObs/stdinInterface.py,sha256=rJVd4uH1eEHGJCUF540qW2j1lzj_YF9xy5yVKBidrcM,1910
|
|
7
|
+
nucosObs/twoWayInterface.py,sha256=mDX45AGVNkfyjOP5lptxT2Ht9ZyqJM902w_SQt3HoV8,2146
|
|
8
|
+
nucosObs/version.py,sha256=0F0B9vPs57KVGdvLhp42jgzlRSLU-rk-Sc1yQgYRFII,17
|
|
9
|
+
nucosObs/websocketInterface.py,sha256=Ri0HtEEm_mRFwgR-iDTBIYMfbNftYbanxz43wF402KY,5392
|
|
10
|
+
nucosobs-0.4.18.dist-info/licenses/LICENSE.txt,sha256=L1fvEaPErlAL_IsISHXztBwujHIDZJAbjUi-Ufqmc78,1103
|
|
11
|
+
nucosobs-0.4.18.dist-info/METADATA,sha256=fKuCpM6RtQ0TS0fkd3soWhi919tmHGMOrOwCoJj6An0,8396
|
|
12
|
+
nucosobs-0.4.18.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
13
|
+
nucosobs-0.4.18.dist-info/top_level.txt,sha256=-LPXaG32QmL2NnSNvZsVGq8B8Rdy8SrsOqMUIr-dyRc,9
|
|
14
|
+
nucosobs-0.4.18.dist-info/RECORD,,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2016-2026 Oliver Braun, Johannes Eckstein
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
nucosObs
|