From 7332dc27bf4f3683525e70ec413ff6048ecdd5f6 Mon Sep 17 00:00:00 2001 From: VisualDust Date: Fri, 24 Nov 2023 22:51:42 +0800 Subject: [PATCH 1/9] minor edits --- neetbox/daemon/readme.md | 3 +++ neetbox/daemon/server/_server.py | 16 +++++++++++----- tests/client/readme.md | 3 +++ tests/client/test.py | 26 ++++++++++++++++++++++++++ 4 files changed, 43 insertions(+), 5 deletions(-) create mode 100644 tests/client/readme.md create mode 100644 tests/client/test.py diff --git a/neetbox/daemon/readme.md b/neetbox/daemon/readme.md index d977b70e..c22c2b3f 100644 --- a/neetbox/daemon/readme.md +++ b/neetbox/daemon/readme.md @@ -3,10 +3,13 @@ ## How to run server only at neetbox project root: + ```bash python neetbox/daemon/server/_server.py ``` +script above should launch a server in debug mode on `localhost:5000`, it wont read the port in `neetbox.toml`. a swegger UI is provided at [localhost:5000/docs](http://127.0.0.1:5000/docs) in debug mode. + ## WS message standard websocke messages are described in json. There is a dataclass representing websocket message: diff --git a/neetbox/daemon/server/_server.py b/neetbox/daemon/server/_server.py index 49a8fedf..08617413 100644 --- a/neetbox/daemon/server/_server.py +++ b/neetbox/daemon/server/_server.py @@ -17,8 +17,8 @@ __DAEMON_SHUTDOWN_IF_NO_UPLOAD_TIMEOUT_SEC = 60 * 60 * 12 # 12 Hours __COUNT_DOWN = __DAEMON_SHUTDOWN_IF_NO_UPLOAD_TIMEOUT_SEC -__DAEMON_NAME = "NEETBOX DAEMON" -setproctitle.setproctitle(__DAEMON_NAME) +__PROC_NAME = "NEETBOX SERVER" +setproctitle.setproctitle(__PROC_NAME) FRONTEND_API_ROOT = "/web" CLIENT_API_ROOT = "/cli" @@ -69,11 +69,13 @@ def ws_send(self): # =============================================================== if debug: + print("Running with debug, using APIFlask") from apiflask import APIFlask - app = APIFlask(__name__) + app = APIFlask(__PROC_NAME) else: - app = Flask(__name__) + print("Running in production mode, escaping APIFlask") + app = Flask(__PROC_NAME) # websocket server ws_server = WebsocketServer(port=cfg["port"] + 1) __BRIDGES = {} # manage connections @@ -292,7 +294,11 @@ def _count_down_thread(): count_down_thread.start() ws_server.run_forever(threaded=True) - app.run(host="0.0.0.0", port=cfg["port"], debug=debug) + + if debug: + app.run(debug=debug) # run apiflask on localhost:5000 + else: # run production mode on configured port + app.run(host="0.0.0.0", port=cfg["port"], debug=debug) if __name__ == "__main__": diff --git a/tests/client/readme.md b/tests/client/readme.md new file mode 100644 index 00000000..e04c557d --- /dev/null +++ b/tests/client/readme.md @@ -0,0 +1,3 @@ +# What is this + +this is a single file simulation representing a common case of usage of neetbox. Edit `neetbox.toml` and run `test.py` performs a general test on some neetbox behaviors. diff --git a/tests/client/test.py b/tests/client/test.py new file mode 100644 index 00000000..2ebc69d5 --- /dev/null +++ b/tests/client/test.py @@ -0,0 +1,26 @@ +import pytest + +pytest.skip(allow_module_level=True) + +from random import random +from time import sleep + +from neetbox.integrations.environment import hardware, platform +from neetbox.logging import logger +from neetbox.pipeline import listen, watch + + +@watch("train", initiative=True) +def train(epoch): + loss, acc = random(), random() + return {"loss": loss, "acc": acc} + + +@listen("train") +def print_to_console(metrix): + logger.log(f"metrix from train: {metrix}") + + +for i in range(99999): + sleep(1) + train(i) From 2621cf117366ede1ffc3288bc5e76e866f480d47 Mon Sep 17 00:00:00 2001 From: VisualDust Date: Fri, 24 Nov 2023 22:53:19 +0800 Subject: [PATCH 2/9] minor edits --- neetbox/daemon/readme.md | 2 ++ neetbox/daemon/server/_server.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/neetbox/daemon/readme.md b/neetbox/daemon/readme.md index c22c2b3f..8242c873 100644 --- a/neetbox/daemon/readme.md +++ b/neetbox/daemon/readme.md @@ -10,6 +10,8 @@ python neetbox/daemon/server/_server.py script above should launch a server in debug mode on `localhost:5000`, it wont read the port in `neetbox.toml`. a swegger UI is provided at [localhost:5000/docs](http://127.0.0.1:5000/docs) in debug mode. +websocket server should run on port `5001`. + ## WS message standard websocke messages are described in json. There is a dataclass representing websocket message: diff --git a/neetbox/daemon/server/_server.py b/neetbox/daemon/server/_server.py index 08617413..58d23325 100644 --- a/neetbox/daemon/server/_server.py +++ b/neetbox/daemon/server/_server.py @@ -305,7 +305,7 @@ def _count_down_thread(): cfg = { "enable": True, "host": "localhost", - "port": 20202, + "port": 5000, "displayName": None, "allowIpython": False, "mute": True, From ff706ed7a4708c77def4b4b778becca80c7122f5 Mon Sep 17 00:00:00 2001 From: VisualDust Date: Fri, 24 Nov 2023 22:56:09 +0800 Subject: [PATCH 3/9] minor edits --- neetbox/daemon/readme.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/neetbox/daemon/readme.md b/neetbox/daemon/readme.md index 8242c873..4037e42b 100644 --- a/neetbox/daemon/readme.md +++ b/neetbox/daemon/readme.md @@ -1,6 +1,6 @@ # DAEMON readme -## How to run server only +## How to test neetbox server at neetbox project root: @@ -8,11 +8,16 @@ at neetbox project root: python neetbox/daemon/server/_server.py ``` -script above should launch a server in debug mode on `localhost:5000`, it wont read the port in `neetbox.toml`. a swegger UI is provided at [localhost:5000/docs](http://127.0.0.1:5000/docs) in debug mode. +script above should launch a server in debug mode on `localhost:5000`, it wont read the port in `neetbox.toml`. a swegger UI is provided at [localhost:5000/docs](http://127.0.0.1:5000/docs) in debug mode. websocket server should run on port `5001`. -websocket server should run on port `5001`. +If you want to simulate a basic neetbox client sending message to server, at neetbox project root: +```bash +cd tests/client +python test.py +``` +script above should launch a simple case of neetbox project with some logs and status sending to server. -## WS message standard +## Websocket message standard websocke messages are described in json. There is a dataclass representing websocket message: From 93b5e6a37b0f54859cad18f1406774beee471346 Mon Sep 17 00:00:00 2001 From: VisualDust Date: Fri, 24 Nov 2023 23:45:56 +0800 Subject: [PATCH 4/9] edited readme --- README.md | 29 ++++++- doc/docs/develop/daemon/index.md | 140 +++++++++++++++++++++++++++++++ doc/docs/guide/index.md | 6 -- doc/static/img/readme.png | Bin 0 -> 82959 bytes neetbox/daemon/readme.md | 6 +- 5 files changed, 172 insertions(+), 9 deletions(-) create mode 100644 doc/docs/develop/daemon/index.md create mode 100644 doc/static/img/readme.png diff --git a/README.md b/README.md index 185388b0..8e6552e5 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,34 @@ [![wakatime](https://wakatime.com/badge/user/b93a26b6-8ea1-44ef-99ed-bcb6e2c732f1/project/8f99904d-dbb1-49e4-814d-8d18bf1e6d1c.svg)](https://wakatime.com/badge/user/b93a26b6-8ea1-44ef-99ed-bcb6e2c732f1/project/8f99904d-dbb1-49e4-814d-8d18bf1e6d1c) -~~一个自产自销的仓库~~ Logging/Debugging/Tracing/Managing/Facilitating your deep learning projects +![](doc\static\img\readme.png) -A small part of the documentation at [neetbox.550w.host](https://neetbox.550w.host). (We are not ready for the doc yet) +## docs & quick start + +Logging/Debugging/Tracing/Managing/Facilitating your deep learning projects. A small part of the documentation at [neetbox.550w.host](https://neetbox.550w.host). (We are not ready for the doc yet) + +## installation + +```bash +pip install neetbox +``` + +## use neetbox in your project + +in your project folder: +``` +neet init +``` +neetbox cli generates a config file for your project named `neetbox.toml` + +in your code: +```python +import neetbox +``` + +## usage examples + +[how to guides](todo) provides easy examples of basic neetbox funcionalities. ## Star History diff --git a/doc/docs/develop/daemon/index.md b/doc/docs/develop/daemon/index.md new file mode 100644 index 00000000..a26d1a6c --- /dev/null +++ b/doc/docs/develop/daemon/index.md @@ -0,0 +1,140 @@ +# Testing DAEMON + +NEETBOX daemon consists of client side and server side. While client side syncs status of running project and platform information including hardware, server side provides apis for status monitoring and websocket forcasting between client and frontends. + +Basically neetbox will also launch a backend on localhost when a project launching configured with daemon server address at localhost. The server will run in background without any output, and you may want to run a server with output for debug purposes. + +## How to test neetbox server + +at neetbox project root: + +```bash +python neetbox/daemon/server/_server.py +``` + +script above should launch a server in debug mode on `localhost:5000`, it wont read the port in `neetbox.toml`. a swegger UI is provided at [localhost:5000/docs](http://127.0.0.1:5000/docs) in debug mode. websocket server should run on port `5001`. + +If you want to simulate a basic neetbox client sending message to server, at neetbox project root: +```bash +cd tests/client +python test.py +``` +script above should launch a simple case of neetbox project with some logs and status sending to server. + +## Websocket message standard + +websocke messages are described in json. There is a dataclass representing websocket message: + +```python +@dataclass +class WsMsg: + event_type: str + payload: Any + event_id: int = -1 + + def json(self): + return { + EVENT_TYPE_NAME_KEY: self.event_type, + EVENT_ID_NAME_KEY: self.event_id, + PAYLOAD_NAME_KEY: self.payload, + } +``` + +```json +{ + "event-type" : ..., + "payload" : ..., + "event-id" : ... +} +``` + +| key | value type | description | +| :--------: | :--------: | :----------------------------------------------------: | +| event-type | string | indicate type of data in payload | +| payload | string | actual data | +| event-id | int | for events who need ack. default -1 means no event id. | + +## Event types + +the table is increasing. a frequent check would keep you up to date. + +| event-type | accepting direction | means | +| :--------: | :---------------------------: | :----------------------------------------------------------: | +| handshake | cli <--> server <--> frontend | string in `payload` indicate connection type ('cli'/'web') | +| log | cli -> server -> frontend | `payload` contains log data | +| action | cli <- server <- frontend | `payload` contains action trigger | +| ack | cli <--> server <--> frontend | `payload` contains ack, and `event-id` should be a valid key | + +## Examples of websocket data + +### handshake + +for instance, frontend connected to server. frontend should report connection type immediately by sending: + +```json +{ + "event-type": "handshake", + "name": "project name", + "payload": { + "who": "web" + }, + "event-id": X +} +``` + +where `event-id` is used to send ack to the starter of the connection, it should be a random int value. + +### cli sending log to frontend + +cli sents log(s) via websocket, server will receives and broadcast this message to related frontends. cli should send: + +```json +{ + "event-type": "log", + "name": "project name", + "payload": { + "log" : {...json representing log data...} + }, + "event-id": -1 +} +``` + +where `event-id` is a useless segment, leave it default. it's okay if nobody receives log. + +### frontend(s) querys action to cli + +frontend send action request to server, and server will forwards the message to cli. frontend should send: + +```json +{ + "event-type" : "action", + "name": "project name", + "payload" : { + "action" : {...json representing action trigger...} + }, + "event-id" : x +} +``` + +front may want to know the result of action. for example, whether the action was invoked successfully. therefore, `event-id` is necessary for cli to shape a ack response. + +### cli acks frontend action query + +cli execute action query(s) from frontend, and gives response by sending ack: + +```json +{ + "event-type" : "ack", + "name": "project name", + "payload" : { + "action" : {...json representing action result...} + }, + "event-id" : x +} +``` + +where `event-id` is same as received action query. + +--- + +Those are only examples. use them wisely. diff --git a/doc/docs/guide/index.md b/doc/docs/guide/index.md index 7672f702..543e5ece 100644 --- a/doc/docs/guide/index.md +++ b/doc/docs/guide/index.md @@ -10,12 +10,6 @@ sidebar_position: 1 pip install neetbox ``` -If you want to enable torch-related feature, please try below: - -```bash -pip install neetbox[torch] -``` - Since NEETBOX is under ~~heavy~~ development, it's better to forcely reinstall the newest version: ```bash diff --git a/doc/static/img/readme.png b/doc/static/img/readme.png new file mode 100644 index 0000000000000000000000000000000000000000..1fb99c58d39ba9e54f5cf0902fba3efe2e1f39d8 GIT binary patch literal 82959 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%0DRl3D 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 H{ri6dOFcXy literal 0 HcmV?d00001 diff --git a/neetbox/daemon/readme.md b/neetbox/daemon/readme.md index 4037e42b..b7123a25 100644 --- a/neetbox/daemon/readme.md +++ b/neetbox/daemon/readme.md @@ -1,4 +1,8 @@ -# DAEMON readme +# Testing DAEMON + +NEETBOX daemon consists of client side and server side. While client side syncs status of running project and platform information including hardware, server side provides apis for status monitoring and websocket forcasting between client and frontends. + +Basically neetbox will also launch a backend on localhost when a project launching configured with daemon server address at localhost. The server will run in background without any output, and you may want to run a server with output for debug purposes. ## How to test neetbox server From 4b0b2ee7062d68f3359f6f52530e98b4d9bb52b3 Mon Sep 17 00:00:00 2001 From: Gavin Gong Date: Fri, 24 Nov 2023 23:47:20 +0800 Subject: [PATCH 5/9] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8e6552e5..291a2724 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![wakatime](https://wakatime.com/badge/user/b93a26b6-8ea1-44ef-99ed-bcb6e2c732f1/project/8f99904d-dbb1-49e4-814d-8d18bf1e6d1c.svg)](https://wakatime.com/badge/user/b93a26b6-8ea1-44ef-99ed-bcb6e2c732f1/project/8f99904d-dbb1-49e4-814d-8d18bf1e6d1c) -![](doc\static\img\readme.png) +![](./doc/static/img/readme.png) ## docs & quick start From 5ca3904b9160b671098632ffc327132a8bdddca3 Mon Sep 17 00:00:00 2001 From: visualDust Date: Sat, 25 Nov 2023 11:12:58 +0800 Subject: [PATCH 6/9] fixed port already in use when launch _server.py solely --- neetbox/__init__.py | 25 +++---- neetbox/daemon/__init__.py | 4 +- neetbox/daemon/_agent.py | 71 ------------------- neetbox/daemon/_protocol.py | 27 +++++++ .../client/{_connection.py => _client.py} | 27 +------ neetbox/daemon/client/_client_apis.py | 2 +- .../{_daemon_client.py => _update_thread.py} | 2 +- neetbox/daemon/server/_server.py | 43 ++++------- neetbox/logging/_writer.py | 2 +- pyproject.toml | 1 + 10 files changed, 62 insertions(+), 142 deletions(-) delete mode 100644 neetbox/daemon/_agent.py create mode 100644 neetbox/daemon/_protocol.py rename neetbox/daemon/client/{_connection.py => _client.py} (91%) rename neetbox/daemon/client/{_daemon_client.py => _update_thread.py} (98%) diff --git a/neetbox/__init__.py b/neetbox/__init__.py index 554e74c6..e73a65d7 100644 --- a/neetbox/__init__.py +++ b/neetbox/__init__.py @@ -12,18 +12,6 @@ config_file_name = f"{module}.toml" -def post_init(): - import setproctitle - - project_name = get_module_level_config()["name"] - setproctitle.setproctitle(project_name) - - from neetbox.daemon.client._connection import connection - - # post init ws - connection._init_ws() - - def init(path=None, load=False, **kwargs) -> bool: if path: os.chdir(path=path) @@ -77,11 +65,24 @@ def init(path=None, load=False, **kwargs) -> bool: raise e +def post_init(): + import setproctitle + + project_name = get_module_level_config()["name"] + setproctitle.setproctitle(project_name) + + from neetbox.daemon.client._client import connection + + # post init ws + connection._init_ws() + + is_in_daemon_process = ( "NEETBOX_DAEMON_PROCESS" in os.environ and os.environ["NEETBOX_DAEMON_PROCESS"] == "1" ) # print('prevent_daemon_loading =', is_in_daemon_process) if os.path.isfile(config_file_name) and not is_in_daemon_process: # if in a workspace + # todo check if running in cli mode success = init(load=True) # init from config file if not success: os._exit(255) diff --git a/neetbox/daemon/__init__.py b/neetbox/daemon/__init__.py index d67777f5..3402debf 100644 --- a/neetbox/daemon/__init__.py +++ b/neetbox/daemon/__init__.py @@ -9,8 +9,8 @@ import time from neetbox.daemon.client._action_agent import _NeetActionManager as NeetActionManager -from neetbox.daemon.client._connection import connection -from neetbox.daemon.client._daemon_client import connect_daemon +from neetbox.daemon.client._client import connection +from neetbox.daemon.client._update_thread import connect_daemon from neetbox.daemon.server.daemonable_process import DaemonableProcess from neetbox.logging import logger from neetbox.pipeline import listen, watch diff --git a/neetbox/daemon/_agent.py b/neetbox/daemon/_agent.py deleted file mode 100644 index 09e6ec2f..00000000 --- a/neetbox/daemon/_agent.py +++ /dev/null @@ -1,71 +0,0 @@ -import functools -import inspect -from ast import literal_eval -from threading import Thread -from typing import Callable, Optional - -from neetbox.core import Registry -from neetbox.logging import logger -from neetbox.utils.mvc import Singleton - - -class PackedAction(Callable): - def __init__(self, function: Callable, name=None, **kwargs): - super().__init__(**kwargs) - self.function = function - self.name = name if name else function.__name__ - self.argspec = inspect.getfullargspec(self.function) - - def __call__(self, **argv): - self.function(argv) - - def eval_call(self, params: dict): - eval_params = dict((k, literal_eval(v)) for k, v in params.items()) - return self.function(**eval_params) - - -class _NeetAction(metaclass=Singleton): - __ACTION_POOL: Registry = Registry("__NEET_ACTIONS") - - def register( - self, - *, - name: Optional[str] = None, - ): - return functools.partial(self._register, name=name) - - def _register(self, function: Callable, name: str = None): - packed = PackedAction(function=function, name=name) - _NeetAction.__ACTION_POOL._register(what=packed, name=packed.name, force=True) - return function - - def get_actions(self): - action_names = _NeetAction.__ACTION_POOL.keys() - actions = {} - for n in action_names: - actions[n] = _NeetAction.__ACTION_POOL[n].argspec - return actions - - def eval_call(self, name: str, params: dict): - if name not in _NeetAction.__ACTION_POOL: - logger.err(f"Could not find action with name {name}, action stopped.") - return False - return _NeetAction.__ACTION_POOL[name].eval_call(params) - - -# singleton -neet_action = _NeetAction() - - -# example -if __name__ == "__main__": - - @neet_action.register(name="some") - def some(a, b): - print(a, b) - - print("registered actions:") - print(neet_action.get_actions()) - - print("calling 'some") - neet_action.eval_call("some", {"a": "3", "b": "4"}) diff --git a/neetbox/daemon/_protocol.py b/neetbox/daemon/_protocol.py new file mode 100644 index 00000000..ef1ea930 --- /dev/null +++ b/neetbox/daemon/_protocol.py @@ -0,0 +1,27 @@ +from dataclasses import dataclass +from typing import Any + +FRONTEND_API_ROOT = "/web" +CLIENT_API_ROOT = "/cli" + + +EVENT_TYPE_NAME_KEY = "event-type" +EVENT_ID_NAME_KEY = "event-id" +NAME_NAME_KEY = "name" +PAYLOAD_NAME_KEY = "payload" + + +@dataclass +class WsMsg: + name: str + event_type: str + payload: Any + event_id: int = -1 + + def json(self): + return { + NAME_NAME_KEY: self.name, + EVENT_TYPE_NAME_KEY: self.event_type, + EVENT_ID_NAME_KEY: self.event_id, + PAYLOAD_NAME_KEY: self.payload, + } diff --git a/neetbox/daemon/client/_connection.py b/neetbox/daemon/client/_client.py similarity index 91% rename from neetbox/daemon/client/_connection.py rename to neetbox/daemon/client/_client.py index 42c22211..83bbf62e 100644 --- a/neetbox/daemon/client/_connection.py +++ b/neetbox/daemon/client/_client.py @@ -1,17 +1,15 @@ -import asyncio import functools import json import logging -import time -from dataclasses import dataclass from threading import Thread -from typing import Any, Callable, Optional +from typing import Callable import httpx import websocket from neetbox.config import get_module_level_config from neetbox.core import Registry +from neetbox.daemon._protocol import * from neetbox.daemon.server._server import CLIENT_API_ROOT from neetbox.logging import logger from neetbox.utils.mvc import Singleton @@ -19,27 +17,6 @@ httpx_logger = logging.getLogger("httpx") httpx_logger.setLevel(logging.ERROR) -EVENT_TYPE_NAME_KEY = "event-type" -EVENT_ID_NAME_KEY = "event-id" -NAME_NAME_KEY = "name" -PAYLOAD_NAME_KEY = "payload" - - -@dataclass -class WsMsg: - name: str - event_type: str - payload: Any - event_id: int = -1 - - def json(self): - return { - NAME_NAME_KEY: self.name, - EVENT_TYPE_NAME_KEY: self.event_type, - EVENT_ID_NAME_KEY: self.event_id, - PAYLOAD_NAME_KEY: self.payload, - } - # singleton class ClientConn(metaclass=Singleton): diff --git a/neetbox/daemon/client/_client_apis.py b/neetbox/daemon/client/_client_apis.py index efe1fa15..3f9d917c 100644 --- a/neetbox/daemon/client/_client_apis.py +++ b/neetbox/daemon/client/_client_apis.py @@ -6,7 +6,7 @@ from neetbox.config import get_module_level_config -from neetbox.daemon.client._connection import connection +from neetbox.daemon.client._client import connection from neetbox.logging import logger from neetbox.utils import pkg from neetbox.utils.framing import get_frame_module_traceback diff --git a/neetbox/daemon/client/_daemon_client.py b/neetbox/daemon/client/_update_thread.py similarity index 98% rename from neetbox/daemon/client/_daemon_client.py rename to neetbox/daemon/client/_update_thread.py index 6ffa701e..228c4d71 100644 --- a/neetbox/daemon/client/_daemon_client.py +++ b/neetbox/daemon/client/_update_thread.py @@ -10,7 +10,7 @@ from typing import Union from neetbox.config import get_module_level_config -from neetbox.daemon.client._connection import connection +from neetbox.daemon.client._client import connection from neetbox.daemon.server._server import CLIENT_API_ROOT from neetbox.logging import logger from neetbox.pipeline._signal_and_slot import _update_value_dict diff --git a/neetbox/daemon/server/_server.py b/neetbox/daemon/server/_server.py index 58d23325..24b04b32 100644 --- a/neetbox/daemon/server/_server.py +++ b/neetbox/daemon/server/_server.py @@ -11,8 +11,15 @@ from threading import Thread from typing import Any, Dict, Tuple +if __name__ == "__main__": + import ultraimport # if run server solely, sssssssuse relative import, do not trigger neetbox init + + _protocol = ultraimport("__dir__/../_protocol.py") + from _protocol import * +else: + from neetbox.daemon._protocol import * import setproctitle -from flask import Flask, abort, json, request +from flask import abort, json, request from websocket_server import WebsocketServer __DAEMON_SHUTDOWN_IF_NO_UPLOAD_TIMEOUT_SEC = 60 * 60 * 12 # 12 Hours @@ -20,30 +27,6 @@ __PROC_NAME = "NEETBOX SERVER" setproctitle.setproctitle(__PROC_NAME) -FRONTEND_API_ROOT = "/web" -CLIENT_API_ROOT = "/cli" - -EVENT_TYPE_NAME_KEY = "event-type" -EVENT_ID_NAME_KEY = "event-id" -PAYLOAD_NAME_KEY = "payload" -NAME_NAME_KEY = "name" - - -@dataclass -class WsMsg: - name: str - event_type: str - payload: Any - event_id: int = -1 - - def json(self): - return { - NAME_NAME_KEY: self.name, - EVENT_TYPE_NAME_KEY: self.event_type, - EVENT_ID_NAME_KEY: self.event_id, - PAYLOAD_NAME_KEY: self.payload, - } - def daemon_process(cfg, debug=False): # describe a client @@ -75,7 +58,12 @@ def ws_send(self): app = APIFlask(__PROC_NAME) else: print("Running in production mode, escaping APIFlask") + from flask import Flask + app = Flask(__PROC_NAME) + + # app = APIFlask(__PROC_NAME) + # websocket server ws_server = WebsocketServer(port=cfg["port"] + 1) __BRIDGES = {} # manage connections @@ -295,10 +283,7 @@ def _count_down_thread(): ws_server.run_forever(threaded=True) - if debug: - app.run(debug=debug) # run apiflask on localhost:5000 - else: # run production mode on configured port - app.run(host="0.0.0.0", port=cfg["port"], debug=debug) + app.run(host="0.0.0.0", port=cfg["port"]) if __name__ == "__main__": diff --git a/neetbox/logging/_writer.py b/neetbox/logging/_writer.py index 0db2958e..8f6c69b9 100644 --- a/neetbox/logging/_writer.py +++ b/neetbox/logging/_writer.py @@ -157,7 +157,7 @@ def write(self, raw_log: RawLog): class _WebSocketLogWriter(LogWriter): # class level statics - connection = None # connection should be assigned by neetbox.daemon.client._connection to avoid recursive import + connection = None # connection should be assigned by neetbox.daemon.client._client to avoid recursive import def write(self, raw_log: RawLog): json_data = raw_log.json() diff --git a/pyproject.toml b/pyproject.toml index 46c49a15..b3215bc3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,6 +54,7 @@ black = "^23.9.1" isort = "^5.11.5" pre-commit = "^3" apiflask = "^2.0.2" +ultraimport = "^0.0.7" [tool.poetry.extras] torch = ["torch", "torchvision", "torchaudio"] From fbd26dd28345898b80ad630aa901ab5af237d8b0 Mon Sep 17 00:00:00 2001 From: visualDust Date: Sat, 25 Nov 2023 11:50:30 +0800 Subject: [PATCH 7/9] fixed errors on ws handshake --- neetbox/daemon/client/_client.py | 15 ++++++------ neetbox/daemon/server/_server.py | 42 +++++++++++++++++++------------- 2 files changed, 33 insertions(+), 24 deletions(-) diff --git a/neetbox/daemon/client/_client.py b/neetbox/daemon/client/_client.py index 83bbf62e..98b24d68 100644 --- a/neetbox/daemon/client/_client.py +++ b/neetbox/daemon/client/_client.py @@ -1,6 +1,7 @@ import functools import json import logging +from collections import defaultdict from threading import Thread from typing import Callable @@ -8,7 +9,6 @@ import websocket from neetbox.config import get_module_level_config -from neetbox.core import Registry from neetbox.daemon._protocol import * from neetbox.daemon.server._server import CLIENT_API_ROOT from neetbox.logging import logger @@ -23,7 +23,7 @@ class ClientConn(metaclass=Singleton): http: httpx.Client = None __ws_client: websocket.WebSocketApp = None # _websocket_client - __ws_subscription = Registry("__client_ws_subscription") # { event-type-name : list(Callable)} + __ws_subscription = defaultdict(lambda: []) # default to no subscribers def __init__(self) -> None: def __load_http_client(): @@ -49,7 +49,7 @@ def _init_ws(): # create websocket app logger.log(f"creating websocket connection to {ClientConn.ws_server_addr}") - ws = websocket.WebSocketApp( + ClientConn.wsApp = websocket.WebSocketApp( ClientConn.ws_server_addr, on_open=ClientConn.__on_ws_open, on_message=ClientConn.__on_ws_message, @@ -57,7 +57,7 @@ def _init_ws(): on_close=ClientConn.__on_ws_close, ) - Thread(target=ws.run_forever, kwargs={"reconnect": True}, daemon=True).start() + Thread(target=ClientConn.wsApp.run_forever, kwargs={"reconnect": True}, daemon=True).start() # assign self to websocket log writer from neetbox.logging._writer import _assign_connection_to_WebSocketLogWriter @@ -89,7 +89,7 @@ def __on_ws_close(ws: websocket.WebSocketApp, close_status_code, close_msg): if close_status_code or close_msg: logger.warn(f"ws close status code: {close_status_code}") logger.warn("ws close message: {close_msg}") - ClientConn.__ws_client = None + ClientConn.__ws_client = None def __on_ws_message(ws: websocket.WebSocketApp, msg): """EXAMPLE JSON @@ -99,14 +99,15 @@ def __on_ws_message(ws: websocket.WebSocketApp, msg): "payload": ... } """ + msg = json.loads(msg) # message should be json logger.debug(f"ws received {msg}") - # message should be json + event_type_name = msg[EVENT_TYPE_NAME_KEY] if event_type_name not in ClientConn.__ws_subscription: logger.warn( f"Client received a(n) {event_type_name} event but nobody subscribes it. Ignoring anyway." ) - for subscriber in ClientConn._ws_subscribe[event_type_name]: + for subscriber in ClientConn.__ws_subscription[event_type_name]: try: subscriber(msg) # pass payload message into subscriber except Exception as e: diff --git a/neetbox/daemon/server/_server.py b/neetbox/daemon/server/_server.py index 24b04b32..f730197f 100644 --- a/neetbox/daemon/server/_server.py +++ b/neetbox/daemon/server/_server.py @@ -136,7 +136,6 @@ def handle_ws_disconnect(client, server): def handle_ws_message(client, server: WebsocketServer, message): message = json.loads(message) - print(message) # debug # handle event-type _event_type = message[EVENT_TYPE_NAME_KEY] _payload = message[PAYLOAD_NAME_KEY] @@ -145,17 +144,21 @@ def handle_ws_message(client, server: WebsocketServer, message): if _event_type == "handshake": # handle handshake # assign this client to a Bridge _who = _payload["who"] + print(f"handling handshake for {_who} with name {_project_name}") if _who == "web": # new connection from frontend # check if Bridge with name exist if _project_name not in __BRIDGES: # there is no such bridge server.send_message( client=client, - msg=WsMsg( - event_type="ack", - event_id=_event_id, - payload={"result": "404", "reason": "name not found"}, - ).json(), + msg=json.dumps( + WsMsg( + name=_project_name, + event_type="ack", + event_id=_event_id, + payload={"result": "404", "reason": "name not found"}, + ).json() + ), ) else: # assign web to bridge _target_bridge = __BRIDGES[_project_name] @@ -163,11 +166,14 @@ def handle_ws_message(client, server: WebsocketServer, message): connected_clients[client["id"]] = (_project_name, "web") server.send_message( client=client, - msg=WsMsg( - event_type="ack", - event_id=_event_id, - payload={"result": "200", "reason": "join success"}, - ).json(), + msg=json.dumps( + WsMsg( + name=_project_name, + event_type="ack", + event_id=_event_id, + payload={"result": "200", "reason": "join success"}, + ).json() + ), ) elif _who == "cli": # new connection from cli @@ -179,12 +185,14 @@ def handle_ws_message(client, server: WebsocketServer, message): connected_clients[client["id"]] = (_project_name, "web") server.send_message( client=client, - msg=WsMsg( - name="_project_name", - event_type="ack", - event_id=_event_id, - payload={"result": "200", "reason": "join success"}, - ).json(), + msg=json.dumps( + WsMsg( + name=_project_name, + event_type="ack", + event_id=_event_id, + payload={"result": "200", "reason": "join success"}, + ).json() + ), ) elif _event_type == "log": # handle log From 1504c0aa9e4610f64af1d06dcce9f67953d9efb0 Mon Sep 17 00:00:00 2001 From: VisualDust Date: Sat, 25 Nov 2023 13:47:27 +0800 Subject: [PATCH 8/9] now client side handles handshake --- neetbox/daemon/client/_client.py | 48 +++++++++++++++++--------------- neetbox/daemon/server/_server.py | 12 ++++---- tests/client/test.py | 4 --- 3 files changed, 32 insertions(+), 32 deletions(-) diff --git a/neetbox/daemon/client/_client.py b/neetbox/daemon/client/_client.py index 98b24d68..4134b8c6 100644 --- a/neetbox/daemon/client/_client.py +++ b/neetbox/daemon/client/_client.py @@ -23,7 +23,24 @@ class ClientConn(metaclass=Singleton): http: httpx.Client = None __ws_client: websocket.WebSocketApp = None # _websocket_client - __ws_subscription = defaultdict(lambda: []) # default to no subscribers + __ws_subscription = defaultdict(lambda: {}) # default to no subscribers + + def ws_subscribe(event_type_name: str, name: str = None): + """let a function subscribe to ws messages with event type name. + !!! dfor inner APIs only, do not use this in your code! + !!! developers should contorl blocking on their own functions + + Args: + function (Callable): who is subscribing the event type + event_type_name (str, optional): Which event to listen. Defaults to None. + """ + return functools.partial( + ClientConn._ws_subscribe, event_type_name=event_type_name, name=name + ) + + def _ws_subscribe(function: Callable, event_type_name: str, name=None): + name = name or function.__name__ + ClientConn.__ws_subscription[event_type_name][name] = function def __init__(self) -> None: def __load_http_client(): @@ -78,8 +95,12 @@ def __on_ws_open(ws: websocket.WebSocketApp): default=str, ) ) - logger.ok(f"handshake succeed.") - ClientConn.__ws_client = ws + + @ClientConn.ws_subscribe(event_type_name="handshake") + def _handle_handshake(msg): + assert msg[PAYLOAD_NAME_KEY]["result"] == 200 + logger.ok(f"handshake succeed.") + ClientConn.__ws_client = ws def __on_ws_err(ws: websocket.WebSocketApp, msg): logger.err(f"client websocket encountered {msg}") @@ -107,13 +128,13 @@ def __on_ws_message(ws: websocket.WebSocketApp, msg): logger.warn( f"Client received a(n) {event_type_name} event but nobody subscribes it. Ignoring anyway." ) - for subscriber in ClientConn.__ws_subscription[event_type_name]: + for name, subscriber in ClientConn.__ws_subscription[event_type_name].items(): try: subscriber(msg) # pass payload message into subscriber except Exception as e: # subscriber throws error logger.err( - f"Subscriber {subscriber} crashed on message event {event_type_name}, ignoring." + f"Subscriber {name} crashed on message event {event_type_name}, ignoring." ) def ws_send(event_type: str, payload): @@ -132,23 +153,6 @@ def ws_send(event_type: str, payload): else: logger.debug("ws client not exist, message dropped.") - def ws_subscribe(event_type_name: str): - """let a function subscribe to ws messages with event type name. - !!! dfor inner APIs only, do not use this in your code! - !!! developers should contorl blocking on their own functions - - Args: - function (Callable): who is subscribing the event type - event_type_name (str, optional): Which event to listen. Defaults to None. - """ - return functools.partial(ClientConn._ws_subscribe, event_type_name=event_type_name) - - def _ws_subscribe(function: Callable, event_type_name: str): - if event_type_name not in ClientConn.__ws_subscription: - # create subscriber list for event-type name if not exist - ClientConn.__ws_subscription._register([], event_type_name) - ClientConn.__ws_subscription[event_type_name].append(function) - # singleton ClientConn() # __init__ setup http client only diff --git a/neetbox/daemon/server/_server.py b/neetbox/daemon/server/_server.py index f730197f..d73ad774 100644 --- a/neetbox/daemon/server/_server.py +++ b/neetbox/daemon/server/_server.py @@ -154,9 +154,9 @@ def handle_ws_message(client, server: WebsocketServer, message): msg=json.dumps( WsMsg( name=_project_name, - event_type="ack", + event_type="handshake", event_id=_event_id, - payload={"result": "404", "reason": "name not found"}, + payload={"result": 404, "reason": "name not found"}, ).json() ), ) @@ -169,9 +169,9 @@ def handle_ws_message(client, server: WebsocketServer, message): msg=json.dumps( WsMsg( name=_project_name, - event_type="ack", + event_type="handshake", event_id=_event_id, - payload={"result": "200", "reason": "join success"}, + payload={"result": 200, "reason": "join success"}, ).json() ), ) @@ -188,9 +188,9 @@ def handle_ws_message(client, server: WebsocketServer, message): msg=json.dumps( WsMsg( name=_project_name, - event_type="ack", + event_type="handshake", event_id=_event_id, - payload={"result": "200", "reason": "join success"}, + payload={"result": 200, "reason": "join success"}, ).json() ), ) diff --git a/tests/client/test.py b/tests/client/test.py index 2ebc69d5..9f2863fe 100644 --- a/tests/client/test.py +++ b/tests/client/test.py @@ -1,7 +1,3 @@ -import pytest - -pytest.skip(allow_module_level=True) - from random import random from time import sleep From 9fefd9609d491fcccca81155856605f6887f055a Mon Sep 17 00:00:00 2001 From: VisualDust Date: Sat, 25 Nov 2023 14:40:31 +0800 Subject: [PATCH 9/9] now client answers action query --- neetbox/daemon/client/_action_agent.py | 19 ++++++++++++++++++- neetbox/daemon/client/_client.py | 1 + neetbox/daemon/readme.md | 17 ++++++++++++++--- tests/client/test.py | 24 ++++++++++++++++++++++++ 4 files changed, 57 insertions(+), 4 deletions(-) diff --git a/neetbox/daemon/client/_action_agent.py b/neetbox/daemon/client/_action_agent.py index 25c21eb8..8a257a3c 100644 --- a/neetbox/daemon/client/_action_agent.py +++ b/neetbox/daemon/client/_action_agent.py @@ -5,6 +5,8 @@ from typing import Callable, Optional from neetbox.core import Registry +from neetbox.daemon._protocol import * +from neetbox.daemon.client._client import connection from neetbox.logging import logger from neetbox.pipeline import watch from neetbox.utils.mvc import Singleton @@ -41,7 +43,7 @@ def get_action_dict(): action_names = _NeetActionManager.__ACTION_POOL.keys() for name in action_names: action = _NeetActionManager.__ACTION_POOL[name] - action_dict[name] = action.argspec.args + action_dict[name] = {"args": action.argspec.args, "blocking": action.blocking} return action_dict def eval_call(name: str, params: dict, callback: None): @@ -83,6 +85,21 @@ def _register(function: Callable, name: str = None, blocking: bool = False): return function +@connection.ws_subscribe(event_type_name="action") +def __listen_to_actions(msg): + _payload = msg[PAYLOAD_NAME_KEY] + _event_id = msg[EVENT_ID_NAME_KEY] + _action_name = _payload["name"] + _action_args = _payload["args"] + _NeetActionManager.eval_call( + name=_action_name, + params=_action_args, + callback=lambda x: connection.ws_send( + event_type="action", payload={"name": _action_name, "result": x}, _event_id=_event_id + ), + ) + + # example if __name__ == "__main__": import time diff --git a/neetbox/daemon/client/_client.py b/neetbox/daemon/client/_client.py index 4134b8c6..ca3597d5 100644 --- a/neetbox/daemon/client/_client.py +++ b/neetbox/daemon/client/_client.py @@ -41,6 +41,7 @@ def ws_subscribe(event_type_name: str, name: str = None): def _ws_subscribe(function: Callable, event_type_name: str, name=None): name = name or function.__name__ ClientConn.__ws_subscription[event_type_name][name] = function + logger.debug(f"ws: {name} subscribed to '{event_type_name}") def __init__(self) -> None: def __load_http_client(): diff --git a/neetbox/daemon/readme.md b/neetbox/daemon/readme.md index b7123a25..37cd60d7 100644 --- a/neetbox/daemon/readme.md +++ b/neetbox/daemon/readme.md @@ -15,10 +15,12 @@ python neetbox/daemon/server/_server.py script above should launch a server in debug mode on `localhost:5000`, it wont read the port in `neetbox.toml`. a swegger UI is provided at [localhost:5000/docs](http://127.0.0.1:5000/docs) in debug mode. websocket server should run on port `5001`. If you want to simulate a basic neetbox client sending message to server, at neetbox project root: + ```bash cd tests/client python test.py ``` + script above should launch a simple case of neetbox project with some logs and status sending to server. ## Websocket message standard @@ -110,7 +112,8 @@ frontend send action request to server, and server will forwards the message to "event-type" : "action", "name": "project name", "payload" : { - "action" : {...json representing action trigger...} + "name" : , + "args" : {...arg names and values...} }, "event-id" : x } @@ -124,15 +127,23 @@ cli execute action query(s) from frontend, and gives response by sending ack: ```json { - "event-type" : "ack", + "event-type" : "action", "name": "project name", "payload" : { - "action" : {...json representing action result...} + "name" : , + "result" : }, "event-id" : x } ``` +> CAUTION ! +> +> - frontend should look for list of actions via `/status` api instead of websocket. +> - when **frontend** receive websocket message with `event-type` = `action`, it must be the action result returned from client. +> - when **client** receive websocket message with `event-type` = `action`, it must be the action queried by frontend. +> - only actions with `blocking` = `true` could return result to frontend. + where `event-id` is same as received action query. --- diff --git a/tests/client/test.py b/tests/client/test.py index 9f2863fe..b121f2b7 100644 --- a/tests/client/test.py +++ b/tests/client/test.py @@ -1,6 +1,8 @@ +import os from random import random from time import sleep +from neetbox.daemon import action from neetbox.integrations.environment import hardware, platform from neetbox.logging import logger from neetbox.pipeline import listen, watch @@ -17,6 +19,28 @@ def print_to_console(metrix): logger.log(f"metrix from train: {metrix}") +@action(name="action-1") +def action_1(text): + logger.log(f"action 1 triggered. text = {text}") + + +@action(name="action-2") +def action_2(text1, text2): + logger.log(f"action 2 triggered. text1 = {text1}, text2 = {text2}") + + +@action(name="wait-for-sec", blocking=True) +def action_2(sec): + sec = int(sec) + logger.log(f"wait for {sec} sec.") + + +@action(name="shutdown", blocking=True) +def sys_exit(): + logger.log("shutdown received, shutting down immediately.") + os._exit(0) + + for i in range(99999): sleep(1) train(i)