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 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
+ [![PyPI version](https://img.shields.io/pypi/v/nucosObs.svg)](https://pypi.org/project/nucosObs/)
48
+ [![Tests](https://github.com/NuCOS/nucosObs/actions/workflows/tests.yml/badge.svg)](https://github.com/NuCOS/nucosObs/actions/workflows/tests.yml)
49
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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