oocana 0.15.0__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.
- oocana/__init__.py +7 -0
- oocana/context.py +334 -0
- oocana/data.py +96 -0
- oocana/handle_data.py +55 -0
- oocana/mainframe.py +135 -0
- oocana/preview.py +79 -0
- oocana/schema.py +118 -0
- oocana/service.py +70 -0
- oocana/throtter.py +37 -0
- oocana-0.15.0.dist-info/METADATA +10 -0
- oocana-0.15.0.dist-info/RECORD +13 -0
- oocana-0.15.0.dist-info/WHEEL +4 -0
- oocana-0.15.0.dist-info/entry_points.txt +4 -0
oocana/__init__.py
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
from .data import * # noqa: F403
|
2
|
+
from .context import * # noqa: F403
|
3
|
+
from .service import * # noqa: F403
|
4
|
+
from .handle_data import * # noqa: F403
|
5
|
+
from .preview import * # noqa: F403
|
6
|
+
from .schema import * # noqa: F403
|
7
|
+
from .mainframe import Mainframe as Mainframe # noqa: F403
|
oocana/context.py
ADDED
@@ -0,0 +1,334 @@
|
|
1
|
+
from dataclasses import asdict
|
2
|
+
from json import loads
|
3
|
+
from .data import BlockInfo, StoreKey, JobDict, BlockDict, BinValueDict, VarValueDict
|
4
|
+
from .handle_data import HandleDef
|
5
|
+
from .mainframe import Mainframe
|
6
|
+
from typing import Dict, Any, TypedDict, Optional
|
7
|
+
from base64 import b64encode
|
8
|
+
from io import BytesIO
|
9
|
+
from .throtter import throttle
|
10
|
+
from .preview import PreviewPayload, TablePreviewData, DataFrame, ShapeDataFrame, PartialDataFrame
|
11
|
+
from .data import EXECUTOR_NAME
|
12
|
+
import os.path
|
13
|
+
import logging
|
14
|
+
|
15
|
+
__all__ = ["Context"]
|
16
|
+
|
17
|
+
class OnlyEqualSelf:
|
18
|
+
def __eq__(self, value: object) -> bool:
|
19
|
+
return self is value
|
20
|
+
|
21
|
+
class OOMOL_LLM_ENV(TypedDict):
|
22
|
+
base_url: str
|
23
|
+
api_key: str
|
24
|
+
models: list[str]
|
25
|
+
|
26
|
+
class HostInfo(TypedDict):
|
27
|
+
gpu_vendor: str
|
28
|
+
gpu_renderer: str
|
29
|
+
|
30
|
+
class Context:
|
31
|
+
__inputs: Dict[str, Any]
|
32
|
+
|
33
|
+
__block_info: BlockInfo
|
34
|
+
__outputs_def: Dict[str, HandleDef]
|
35
|
+
__store: Any
|
36
|
+
__is_done: bool = False
|
37
|
+
__keep_alive: OnlyEqualSelf = OnlyEqualSelf()
|
38
|
+
__session_dir: str
|
39
|
+
_logger: Optional[logging.Logger] = None
|
40
|
+
|
41
|
+
def __init__(
|
42
|
+
self, inputs: Dict[str, Any], blockInfo: BlockInfo, mainframe: Mainframe, store, outputs, session_dir: str
|
43
|
+
) -> None:
|
44
|
+
|
45
|
+
self.__block_info = blockInfo
|
46
|
+
|
47
|
+
self.__mainframe = mainframe
|
48
|
+
self.__store = store
|
49
|
+
self.__inputs = inputs
|
50
|
+
|
51
|
+
outputs_defs = {}
|
52
|
+
if outputs is not None:
|
53
|
+
for k, v in outputs.items():
|
54
|
+
outputs_defs[k] = HandleDef(**v)
|
55
|
+
self.__outputs_def = outputs_defs
|
56
|
+
self.__session_dir = session_dir
|
57
|
+
|
58
|
+
@property
|
59
|
+
def logger(self) -> logging.Logger:
|
60
|
+
"""a custom logger for the block, you can use it to log the message to the block log. this logger will report the log by context report_logger api.
|
61
|
+
"""
|
62
|
+
|
63
|
+
# setup after init, so the logger always exists
|
64
|
+
if self._logger is None:
|
65
|
+
raise ValueError("logger is not setup, please setup the logger in the block init function.")
|
66
|
+
return self._logger
|
67
|
+
|
68
|
+
@property
|
69
|
+
def session_dir(self) -> str:
|
70
|
+
"""a temporary directory for the current session, all blocks in the one session will share the same directory.
|
71
|
+
"""
|
72
|
+
return self.__session_dir
|
73
|
+
|
74
|
+
@property
|
75
|
+
def keepAlive(self):
|
76
|
+
return self.__keep_alive
|
77
|
+
|
78
|
+
@property
|
79
|
+
def inputs(self):
|
80
|
+
return self.__inputs
|
81
|
+
|
82
|
+
@property
|
83
|
+
def session_id(self):
|
84
|
+
return self.__block_info.session_id
|
85
|
+
|
86
|
+
@property
|
87
|
+
def job_id(self):
|
88
|
+
return self.__block_info.job_id
|
89
|
+
|
90
|
+
@property
|
91
|
+
def job_info(self) -> JobDict:
|
92
|
+
return self.__block_info.job_info()
|
93
|
+
|
94
|
+
@property
|
95
|
+
def block_info(self) -> BlockDict:
|
96
|
+
return self.__block_info.block_dict()
|
97
|
+
|
98
|
+
@property
|
99
|
+
def node_id(self) -> str:
|
100
|
+
return self.__block_info.stacks[-1].get("node_id", None)
|
101
|
+
|
102
|
+
@property
|
103
|
+
def oomol_llm_env(self) -> OOMOL_LLM_ENV:
|
104
|
+
"""this is a dict contains the oomol llm environment variables
|
105
|
+
"""
|
106
|
+
return {
|
107
|
+
"base_url": os.getenv("OOMOL_LLM_BASE_URL", ""),
|
108
|
+
"api_key": os.getenv("OOMOL_LLM_API_KEY", ""),
|
109
|
+
"models": os.getenv("OOMOL_LLM_MODELS", "").split(","),
|
110
|
+
}
|
111
|
+
|
112
|
+
@property
|
113
|
+
def host_info(self) -> HostInfo:
|
114
|
+
"""this is a dict contains the host information
|
115
|
+
"""
|
116
|
+
return {
|
117
|
+
"gpu_vendor": os.getenv("OOMOL_HOST_GPU_VENDOR", "unknown"),
|
118
|
+
"gpu_renderer": os.getenv("OOMOL_HOST_GPU_RENDERER", "unknown"),
|
119
|
+
}
|
120
|
+
|
121
|
+
def __store_ref(self, handle: str):
|
122
|
+
return StoreKey(
|
123
|
+
executor=EXECUTOR_NAME,
|
124
|
+
handle=handle,
|
125
|
+
job_id=self.job_id,
|
126
|
+
session_id=self.session_id,
|
127
|
+
)
|
128
|
+
|
129
|
+
def __is_basic_type(self, value: Any) -> bool:
|
130
|
+
return isinstance(value, (int, float, str, bool))
|
131
|
+
|
132
|
+
def output(self, key: str, value: Any, done: bool = False):
|
133
|
+
"""
|
134
|
+
output the value to the next block
|
135
|
+
|
136
|
+
key: str, the key of the output, should be defined in the block schema output defs, the field name is handle
|
137
|
+
value: Any, the value of the output
|
138
|
+
"""
|
139
|
+
|
140
|
+
v = value
|
141
|
+
|
142
|
+
if self.__outputs_def is not None:
|
143
|
+
output_def = self.__outputs_def.get(key)
|
144
|
+
if (
|
145
|
+
output_def is not None and output_def.is_var_handle() and not self.__is_basic_type(value) # 基础类型即使是变量也不放进 store,直接作为 json 内容传递
|
146
|
+
):
|
147
|
+
ref = self.__store_ref(key)
|
148
|
+
self.__store[ref] = value
|
149
|
+
d: VarValueDict = {
|
150
|
+
"__OOMOL_TYPE__": "oomol/var",
|
151
|
+
"value": asdict(ref)
|
152
|
+
}
|
153
|
+
v = d
|
154
|
+
elif output_def is not None and output_def.is_bin_handle():
|
155
|
+
if not isinstance(value, bytes):
|
156
|
+
self.send_warning(
|
157
|
+
f"Output handle key: [{key}] is defined as binary, but the value is not bytes."
|
158
|
+
)
|
159
|
+
return
|
160
|
+
|
161
|
+
bin_file = f"{self.session_dir}/binary/{self.session_id}/{self.job_id}/{key}"
|
162
|
+
os.makedirs(os.path.dirname(bin_file), exist_ok=True)
|
163
|
+
try:
|
164
|
+
with open(bin_file, "wb") as f:
|
165
|
+
f.write(value)
|
166
|
+
except IOError as e:
|
167
|
+
self.send_warning(
|
168
|
+
f"Output handle key: [{key}] is defined as binary, but an error occurred while writing the file: {e}"
|
169
|
+
)
|
170
|
+
return
|
171
|
+
|
172
|
+
if os.path.exists(bin_file):
|
173
|
+
bin_value: BinValueDict = {
|
174
|
+
"__OOMOL_TYPE__": "oomol/bin",
|
175
|
+
"value": bin_file,
|
176
|
+
}
|
177
|
+
v = bin_value
|
178
|
+
else:
|
179
|
+
self.send_warning(
|
180
|
+
f"Output handle key: [{key}] is defined as binary, but the file is not written."
|
181
|
+
)
|
182
|
+
return
|
183
|
+
|
184
|
+
# 如果传入 key 在输出定义中不存在,直接忽略,不发送数据。但是 done 仍然生效。
|
185
|
+
if self.__outputs_def is not None and self.__outputs_def.get(key) is None:
|
186
|
+
self.send_warning(
|
187
|
+
f"Output handle key: [{key}] is not defined in Block outputs schema."
|
188
|
+
)
|
189
|
+
if done:
|
190
|
+
self.done()
|
191
|
+
return
|
192
|
+
|
193
|
+
node_result = {
|
194
|
+
"type": "BlockOutput",
|
195
|
+
"handle": key,
|
196
|
+
"output": v,
|
197
|
+
"done": done,
|
198
|
+
}
|
199
|
+
self.__mainframe.send(self.job_info, node_result)
|
200
|
+
|
201
|
+
if done:
|
202
|
+
self.done()
|
203
|
+
|
204
|
+
def done(self, error: str | None = None):
|
205
|
+
if self.__is_done:
|
206
|
+
self.send_warning("done has been called multiple times, will be ignored.")
|
207
|
+
return
|
208
|
+
self.__is_done = True
|
209
|
+
if error is None:
|
210
|
+
self.__mainframe.send(self.job_info, {"type": "BlockFinished"})
|
211
|
+
else:
|
212
|
+
self.__mainframe.send(
|
213
|
+
self.job_info, {"type": "BlockFinished", "error": error}
|
214
|
+
)
|
215
|
+
|
216
|
+
def send_message(self, payload):
|
217
|
+
self.__mainframe.report(
|
218
|
+
self.block_info,
|
219
|
+
{
|
220
|
+
"type": "BlockMessage",
|
221
|
+
"payload": payload,
|
222
|
+
},
|
223
|
+
)
|
224
|
+
|
225
|
+
def __dataframe(self, payload: PreviewPayload) -> PreviewPayload:
|
226
|
+
if isinstance(payload, DataFrame):
|
227
|
+
payload = { "type": "table", "data": payload }
|
228
|
+
|
229
|
+
if isinstance(payload, dict) and payload.get("type") == "table":
|
230
|
+
df = payload.get("data")
|
231
|
+
if isinstance(df, ShapeDataFrame):
|
232
|
+
row_count = df.shape[0]
|
233
|
+
if row_count <= 10:
|
234
|
+
data = df.to_dict(orient='split')
|
235
|
+
columns = data.get("columns", [])
|
236
|
+
rows = data.get("data", [])
|
237
|
+
elif isinstance(df, PartialDataFrame):
|
238
|
+
data_columns = loads(df.head(5).to_json(orient='split'))
|
239
|
+
columns = data_columns.get("columns", [])
|
240
|
+
rows_head = data_columns.get("data", [])
|
241
|
+
data_tail = loads(df.tail(5).to_json(orient='split'))
|
242
|
+
rows_tail = data_tail.get("data", [])
|
243
|
+
rows_dots = [["..."] * len(columns)]
|
244
|
+
rows = rows_head + rows_dots + rows_tail
|
245
|
+
else:
|
246
|
+
print("dataframe more than 10 rows but not support head and tail is not supported")
|
247
|
+
return payload
|
248
|
+
data: TablePreviewData = { "rows": rows, "columns": columns, "row_count": row_count }
|
249
|
+
payload = { "type": "table", "data": data }
|
250
|
+
else:
|
251
|
+
print("dataframe is not support shape property")
|
252
|
+
|
253
|
+
return payload
|
254
|
+
|
255
|
+
def __matplotlib(self, payload: PreviewPayload) -> PreviewPayload:
|
256
|
+
# payload is a matplotlib Figure
|
257
|
+
if hasattr(payload, 'savefig'):
|
258
|
+
fig: Any = payload
|
259
|
+
buffer = BytesIO()
|
260
|
+
fig.savefig(buffer, format='png')
|
261
|
+
buffer.seek(0)
|
262
|
+
png = buffer.getvalue()
|
263
|
+
buffer.close()
|
264
|
+
url = f'data:image/png;base64,{b64encode(png).decode("utf-8")}'
|
265
|
+
payload = { "type": "image", "data": url }
|
266
|
+
|
267
|
+
return payload
|
268
|
+
|
269
|
+
def preview(self, payload: PreviewPayload):
|
270
|
+
payload = self.__dataframe(payload)
|
271
|
+
payload = self.__matplotlib(payload)
|
272
|
+
|
273
|
+
self.__mainframe.report(
|
274
|
+
self.block_info,
|
275
|
+
{
|
276
|
+
"type": "BlockPreview",
|
277
|
+
"payload": payload,
|
278
|
+
},
|
279
|
+
)
|
280
|
+
|
281
|
+
@throttle(0.3)
|
282
|
+
def report_progress(self, progress: float | int):
|
283
|
+
"""report progress
|
284
|
+
|
285
|
+
This api is used to report the progress of the block. but it just effect the ui progress not the real progress.
|
286
|
+
This api is throttled. the minimum interval is 0.3s.
|
287
|
+
When you first call this api, it will report the progress immediately. After it invoked once, it will report the progress at the end of the throttling period.
|
288
|
+
|
289
|
+
| 0.25 s | 0.2 s |
|
290
|
+
first call second call third call 4 5 6 7's calls
|
291
|
+
| | | | | | |
|
292
|
+
| -------- 0.3 s -------- | -------- 0.3 s -------- |
|
293
|
+
invoke invoke invoke
|
294
|
+
:param float | int progress: the progress of the block, the value should be in [0, 100].
|
295
|
+
"""
|
296
|
+
self.__mainframe.report(
|
297
|
+
self.block_info,
|
298
|
+
{
|
299
|
+
"type": "BlockProgress",
|
300
|
+
"rate": progress,
|
301
|
+
}
|
302
|
+
)
|
303
|
+
|
304
|
+
def report_log(self, line: str, stdio: str = "stdout"):
|
305
|
+
self.__mainframe.report(
|
306
|
+
self.block_info,
|
307
|
+
{
|
308
|
+
"type": "BlockLog",
|
309
|
+
"log": line,
|
310
|
+
stdio: stdio,
|
311
|
+
},
|
312
|
+
)
|
313
|
+
|
314
|
+
def log_json(self, payload):
|
315
|
+
self.__mainframe.report(
|
316
|
+
self.block_info,
|
317
|
+
{
|
318
|
+
"type": "BlockLogJSON",
|
319
|
+
"json": payload,
|
320
|
+
},
|
321
|
+
)
|
322
|
+
|
323
|
+
def send_warning(self, warning: str):
|
324
|
+
self.__mainframe.report(self.block_info, {"type": "BlockWarning", "warning": warning})
|
325
|
+
|
326
|
+
def send_error(self, error: str):
|
327
|
+
'''
|
328
|
+
deprecated, use error(error) instead.
|
329
|
+
consider to remove in the future.
|
330
|
+
'''
|
331
|
+
self.error(error)
|
332
|
+
|
333
|
+
def error(self, error: str):
|
334
|
+
self.__mainframe.send(self.job_info, {"type": "BlockError", "error": error})
|
oocana/data.py
ADDED
@@ -0,0 +1,96 @@
|
|
1
|
+
from dataclasses import dataclass, asdict
|
2
|
+
from typing import TypedDict, Literal
|
3
|
+
from simplejson import JSONEncoder
|
4
|
+
import simplejson as json
|
5
|
+
|
6
|
+
EXECUTOR_NAME = "python"
|
7
|
+
|
8
|
+
__all__ = ["dumps", "BinValueDict", "VarValueDict", "JobDict", "BlockDict", "StoreKey", "BlockInfo", "EXECUTOR_NAME", "JobDict", "BinValueDict", "VarValueDict"]
|
9
|
+
|
10
|
+
def dumps(obj, **kwargs):
|
11
|
+
return json.dumps(obj, cls=DataclassJSONEncoder, ignore_nan=True, **kwargs)
|
12
|
+
|
13
|
+
class DataclassJSONEncoder(JSONEncoder):
|
14
|
+
def default(self, o): # pyright: ignore[reportIncompatibleMethodOverride]
|
15
|
+
if hasattr(o, '__dataclass_fields__'):
|
16
|
+
return asdict(o)
|
17
|
+
return JSONEncoder.default(self, o)
|
18
|
+
|
19
|
+
class BinValueDict(TypedDict):
|
20
|
+
value: str
|
21
|
+
__OOMOL_TYPE__: Literal["oomol/bin"]
|
22
|
+
|
23
|
+
class VarValueDict(TypedDict):
|
24
|
+
value: dict
|
25
|
+
__OOMOL_TYPE__: Literal["oomol/var"]
|
26
|
+
|
27
|
+
class JobDict(TypedDict):
|
28
|
+
session_id: str
|
29
|
+
job_id: str
|
30
|
+
|
31
|
+
class BlockDict(TypedDict):
|
32
|
+
|
33
|
+
try:
|
34
|
+
# NotRequired, Required was added in version 3.11
|
35
|
+
from typing import NotRequired, Required, TypedDict # type: ignore
|
36
|
+
except ImportError:
|
37
|
+
from typing_extensions import NotRequired, Required, TypedDict
|
38
|
+
|
39
|
+
session_id: str
|
40
|
+
job_id: str
|
41
|
+
stacks: list
|
42
|
+
block_path: NotRequired[str]
|
43
|
+
|
44
|
+
# dataclass 默认字段必须一一匹配
|
45
|
+
# 如果多一个或者少一个字段,就会报错。
|
46
|
+
# 这里想兼容额外多余字段,所以需要自己重写 __init__ 方法,忽略处理多余字段。同时需要自己处理缺少字段的情况。
|
47
|
+
@dataclass(frozen=True, kw_only=True)
|
48
|
+
class StoreKey:
|
49
|
+
executor: str
|
50
|
+
handle: str
|
51
|
+
job_id: str
|
52
|
+
session_id: str
|
53
|
+
|
54
|
+
def __init__(self, **kwargs):
|
55
|
+
for key, value in kwargs.items():
|
56
|
+
object.__setattr__(self, key, value)
|
57
|
+
for key in self.__annotations__.keys():
|
58
|
+
if key not in kwargs:
|
59
|
+
raise ValueError(f"missing key {key}")
|
60
|
+
|
61
|
+
|
62
|
+
# 发送 reporter 时,固定需要的 block 信息参数
|
63
|
+
@dataclass(frozen=True, kw_only=True)
|
64
|
+
class BlockInfo:
|
65
|
+
|
66
|
+
session_id: str
|
67
|
+
job_id: str
|
68
|
+
stacks: list
|
69
|
+
block_path: str | None = None
|
70
|
+
|
71
|
+
def __init__(self, **kwargs):
|
72
|
+
for key, value in kwargs.items():
|
73
|
+
object.__setattr__(self, key, value)
|
74
|
+
for key in self.__annotations__.keys():
|
75
|
+
if key not in kwargs and key != "block_path":
|
76
|
+
raise ValueError(f"missing key {key}")
|
77
|
+
|
78
|
+
def job_info(self) -> JobDict:
|
79
|
+
return {"session_id": self.session_id, "job_id": self.job_id}
|
80
|
+
|
81
|
+
def block_dict(self) -> BlockDict:
|
82
|
+
if self.block_path is None:
|
83
|
+
return {
|
84
|
+
"session_id": self.session_id,
|
85
|
+
"job_id": self.job_id,
|
86
|
+
"stacks": self.stacks,
|
87
|
+
}
|
88
|
+
|
89
|
+
return {
|
90
|
+
"session_id": self.session_id,
|
91
|
+
"job_id": self.job_id,
|
92
|
+
"stacks": self.stacks,
|
93
|
+
"block_path": self.block_path,
|
94
|
+
}
|
95
|
+
|
96
|
+
|
oocana/handle_data.py
ADDED
@@ -0,0 +1,55 @@
|
|
1
|
+
from dataclasses import dataclass
|
2
|
+
from typing import Any, Optional
|
3
|
+
from .schema import FieldSchema, ContentMediaType
|
4
|
+
|
5
|
+
__all__ = ["HandleDef", "InputHandleDef"]
|
6
|
+
|
7
|
+
@dataclass(frozen=True, kw_only=True)
|
8
|
+
class HandleDef:
|
9
|
+
"""The base handle for output def, can be directly used for output def
|
10
|
+
"""
|
11
|
+
handle: str
|
12
|
+
"""The name of the handle. it should be unique in handle list."""
|
13
|
+
|
14
|
+
json_schema: Optional[FieldSchema] = None
|
15
|
+
"""The schema of the handle. It is used to validate the handle's content."""
|
16
|
+
|
17
|
+
name: Optional[str] = None
|
18
|
+
"""A alias of the handle's type name. It is used to display in the UI and connect to the other handle match"""
|
19
|
+
|
20
|
+
def __init__(self, **kwargs):
|
21
|
+
for key, value in kwargs.items():
|
22
|
+
object.__setattr__(self, key, value)
|
23
|
+
if "handle" not in kwargs:
|
24
|
+
raise ValueError("missing attr key: 'handle'")
|
25
|
+
json_schema = self.json_schema
|
26
|
+
if json_schema is not None and not isinstance(json_schema, FieldSchema):
|
27
|
+
object.__setattr__(self, "json_schema", FieldSchema.generate_schema(json_schema))
|
28
|
+
|
29
|
+
def check_handle_type(self, type: ContentMediaType) -> bool:
|
30
|
+
if self.handle is None:
|
31
|
+
return False
|
32
|
+
if self.json_schema is None:
|
33
|
+
return False
|
34
|
+
if self.json_schema.contentMediaType is None:
|
35
|
+
return False
|
36
|
+
return self.json_schema.contentMediaType == type
|
37
|
+
|
38
|
+
def is_var_handle(self) -> bool:
|
39
|
+
return self.check_handle_type("oomol/var")
|
40
|
+
|
41
|
+
def is_secret_handle(self) -> bool:
|
42
|
+
return self.check_handle_type("oomol/secret")
|
43
|
+
|
44
|
+
def is_bin_handle(self) -> bool:
|
45
|
+
return self.check_handle_type("oomol/bin")
|
46
|
+
|
47
|
+
@dataclass(frozen=True, kw_only=True)
|
48
|
+
class InputHandleDef(HandleDef):
|
49
|
+
|
50
|
+
value: Optional[Any] = None
|
51
|
+
"""default value for input handle, can be None.
|
52
|
+
"""
|
53
|
+
|
54
|
+
def __init__(self, **kwargs):
|
55
|
+
super().__init__(**kwargs)
|
oocana/mainframe.py
ADDED
@@ -0,0 +1,135 @@
|
|
1
|
+
from simplejson import loads
|
2
|
+
import paho.mqtt.client as mqtt
|
3
|
+
from paho.mqtt.enums import CallbackAPIVersion
|
4
|
+
import operator
|
5
|
+
from urllib.parse import urlparse
|
6
|
+
import uuid
|
7
|
+
from .data import BlockDict, JobDict, dumps
|
8
|
+
import logging
|
9
|
+
from typing import Optional
|
10
|
+
|
11
|
+
__all__ = ["Mainframe"]
|
12
|
+
|
13
|
+
class Mainframe:
|
14
|
+
address: str
|
15
|
+
client: mqtt.Client
|
16
|
+
client_id: str
|
17
|
+
_subscriptions: set[str] = set()
|
18
|
+
_logger: logging.Logger
|
19
|
+
|
20
|
+
def __init__(self, address: str, client_id: Optional[str] = None, logger = None) -> None:
|
21
|
+
self.address = address
|
22
|
+
self.client_id = client_id or f"python-executor-{uuid.uuid4().hex[:8]}"
|
23
|
+
self._logger = logger or logging.getLogger(__name__)
|
24
|
+
|
25
|
+
def connect(self):
|
26
|
+
connect_address = (
|
27
|
+
self.address
|
28
|
+
if operator.contains(self.address, "://")
|
29
|
+
else f"mqtt://{self.address}"
|
30
|
+
)
|
31
|
+
url = urlparse(connect_address)
|
32
|
+
client = self._setup_client()
|
33
|
+
client.connect(host=url.hostname, port=url.port) # type: ignore
|
34
|
+
client.loop_start()
|
35
|
+
|
36
|
+
def _setup_client(self):
|
37
|
+
self.client = mqtt.Client(
|
38
|
+
callback_api_version=CallbackAPIVersion.VERSION2,
|
39
|
+
client_id=self.client_id,
|
40
|
+
)
|
41
|
+
self.client.logger = self._logger
|
42
|
+
self.client.on_connect = self.on_connect
|
43
|
+
self.client.on_disconnect = self.on_disconnect
|
44
|
+
self.client.on_connect_fail = self.on_connect_fail # type: ignore
|
45
|
+
return self.client
|
46
|
+
|
47
|
+
# mqtt v5 重连后,订阅和队列信息会丢失(v3 在初始化时,设置 clean_session 后,会保留两者。
|
48
|
+
# 我们的 broker 使用的是 v5,在 on_connect 里订阅,可以保证每次重连都重新订阅上。
|
49
|
+
def on_connect(self, client, userdata, flags, reason_code, properties):
|
50
|
+
if reason_code != 0:
|
51
|
+
self._logger.error("connect to broker failed, reason_code: %s", reason_code)
|
52
|
+
return
|
53
|
+
else:
|
54
|
+
self._logger.info("connect to broker success")
|
55
|
+
|
56
|
+
for topic in self._subscriptions.copy(): # 进程冲突
|
57
|
+
self._logger.info("resubscribe to topic: {}".format(topic))
|
58
|
+
self.client.subscribe(topic, qos=1)
|
59
|
+
|
60
|
+
def on_connect_fail(self) -> None:
|
61
|
+
self._logger.error("connect to broker failed")
|
62
|
+
|
63
|
+
def on_disconnect(self, client, userdata, flags, reason_code, properties):
|
64
|
+
self._logger.warning("disconnect to broker, reason_code: %s", reason_code)
|
65
|
+
|
66
|
+
# 不等待 publish 完成,使用 qos 参数来会保证消息到达。
|
67
|
+
def send(self, job_info: JobDict, msg) -> mqtt.MQTTMessageInfo:
|
68
|
+
return self.client.publish(
|
69
|
+
f'session/{job_info["session_id"]}', dumps({"job_id": job_info["job_id"], "session_id": job_info["session_id"], **msg}), qos=1
|
70
|
+
)
|
71
|
+
|
72
|
+
def report(self, block_info: BlockDict, msg: dict) -> mqtt.MQTTMessageInfo:
|
73
|
+
return self.client.publish("report", dumps({**block_info, **msg}), qos=1)
|
74
|
+
|
75
|
+
def notify_executor_ready(self, session_id: str, executor_name: str, package: str | None) -> None:
|
76
|
+
self.client.publish(f"session/{session_id}", dumps({
|
77
|
+
"type": "ExecutorReady",
|
78
|
+
"session_id": session_id,
|
79
|
+
"executor_name": executor_name,
|
80
|
+
"package": package,
|
81
|
+
}), qos=1)
|
82
|
+
|
83
|
+
def notify_block_ready(self, session_id: str, job_id: str) -> dict:
|
84
|
+
|
85
|
+
topic = f"inputs/{session_id}/{job_id}"
|
86
|
+
replay = None
|
87
|
+
|
88
|
+
def on_message_once(_client, _userdata, message):
|
89
|
+
nonlocal replay
|
90
|
+
self.client.unsubscribe(topic)
|
91
|
+
replay = loads(message.payload)
|
92
|
+
|
93
|
+
self.client.subscribe(topic, qos=1)
|
94
|
+
self.client.message_callback_add(topic, on_message_once)
|
95
|
+
|
96
|
+
self.client.publish(f"session/{session_id}", dumps({
|
97
|
+
"type": "BlockReady",
|
98
|
+
"session_id": session_id,
|
99
|
+
"job_id": job_id,
|
100
|
+
}), qos=1)
|
101
|
+
|
102
|
+
while True:
|
103
|
+
if replay is not None:
|
104
|
+
self._logger.info("notify ready success in {} {}".format(session_id, job_id))
|
105
|
+
return replay
|
106
|
+
|
107
|
+
def publish(self, topic, payload):
|
108
|
+
self.client.publish(topic, dumps(payload), qos=1)
|
109
|
+
|
110
|
+
def subscribe(self, topic: str, callback):
|
111
|
+
def on_message(_client, _userdata, message):
|
112
|
+
self._logger.info("receive topic: {} payload: {}".format(topic, message.payload))
|
113
|
+
payload = loads(message.payload)
|
114
|
+
callback(payload)
|
115
|
+
|
116
|
+
self.client.message_callback_add(topic, on_message)
|
117
|
+
self._subscriptions.add(topic)
|
118
|
+
|
119
|
+
if self.client.is_connected():
|
120
|
+
self.client.subscribe(topic, qos=1)
|
121
|
+
self._logger.info("subscribe to topic: {}".format(topic))
|
122
|
+
else:
|
123
|
+
self._logger.info("wait connected to subscribe to topic: {}".format(topic))
|
124
|
+
|
125
|
+
|
126
|
+
def unsubscribe(self, topic):
|
127
|
+
self.client.message_callback_remove(topic)
|
128
|
+
self.client.unsubscribe(topic)
|
129
|
+
self._subscriptions.remove(topic)
|
130
|
+
|
131
|
+
def loop(self):
|
132
|
+
self.client.loop_forever()
|
133
|
+
|
134
|
+
def disconnect(self):
|
135
|
+
self.client.disconnect()
|
oocana/preview.py
ADDED
@@ -0,0 +1,79 @@
|
|
1
|
+
|
2
|
+
from typing import Any, TypedDict, List, Literal, TypeAlias, Union, Protocol, runtime_checkable
|
3
|
+
|
4
|
+
__all__ = ["PreviewPayload", "TablePreviewPayload", "TextPreviewPayload", "JSONPreviewPayload", "ImagePreviewPayload", "MediaPreviewPayload", "PandasPreviewPayload", "DefaultPreviewPayload"]
|
5
|
+
|
6
|
+
# this class is for pandas.DataFrame
|
7
|
+
@runtime_checkable
|
8
|
+
class DataFrame(Protocol):
|
9
|
+
|
10
|
+
def __dataframe__(self, *args: Any, **kwargs: Any) -> Any:
|
11
|
+
...
|
12
|
+
|
13
|
+
def to_dict(self, *args: Any, **kwargs: Any) -> Any:
|
14
|
+
...
|
15
|
+
|
16
|
+
@runtime_checkable
|
17
|
+
class JsonAble(Protocol):
|
18
|
+
|
19
|
+
def to_json(self, *args: Any, **kwargs: Any) -> Any:
|
20
|
+
...
|
21
|
+
|
22
|
+
@runtime_checkable
|
23
|
+
class ShapeDataFrame(DataFrame, Protocol):
|
24
|
+
|
25
|
+
@property
|
26
|
+
def shape(self) -> tuple[int, int]:
|
27
|
+
...
|
28
|
+
|
29
|
+
@runtime_checkable
|
30
|
+
class PartialDataFrame(Protocol):
|
31
|
+
def head(self, *args: Any, **kwargs: Any) -> JsonAble:
|
32
|
+
...
|
33
|
+
|
34
|
+
def tail(self, *args: Any, **kwargs: Any) -> JsonAble:
|
35
|
+
...
|
36
|
+
|
37
|
+
class TablePreviewData(TypedDict):
|
38
|
+
columns: List[str | int | float]
|
39
|
+
rows: List[List[str | int | float | bool]]
|
40
|
+
row_count: int | None
|
41
|
+
|
42
|
+
class TablePreviewPayload(TypedDict):
|
43
|
+
type: Literal['table']
|
44
|
+
data: TablePreviewData | Any
|
45
|
+
|
46
|
+
class TextPreviewPayload(TypedDict):
|
47
|
+
type: Literal["text"]
|
48
|
+
data: Any
|
49
|
+
|
50
|
+
class JSONPreviewPayload(TypedDict):
|
51
|
+
type: Literal["json"]
|
52
|
+
data: Any
|
53
|
+
|
54
|
+
class ImagePreviewPayload(TypedDict):
|
55
|
+
type: Literal['image']
|
56
|
+
data: str | List[str]
|
57
|
+
|
58
|
+
class MediaPreviewPayload(TypedDict):
|
59
|
+
type: Literal["image", 'video', 'audio', 'markdown', "iframe", "html"]
|
60
|
+
data: str
|
61
|
+
|
62
|
+
class PandasPreviewPayload(TypedDict):
|
63
|
+
type: Literal['table']
|
64
|
+
data: DataFrame
|
65
|
+
|
66
|
+
class DefaultPreviewPayload:
|
67
|
+
type: str
|
68
|
+
data: Any
|
69
|
+
|
70
|
+
PreviewPayload: TypeAlias = Union[
|
71
|
+
TablePreviewPayload,
|
72
|
+
TextPreviewPayload,
|
73
|
+
JSONPreviewPayload,
|
74
|
+
ImagePreviewPayload,
|
75
|
+
MediaPreviewPayload,
|
76
|
+
DataFrame,
|
77
|
+
PandasPreviewPayload,
|
78
|
+
DefaultPreviewPayload
|
79
|
+
]
|
oocana/schema.py
ADDED
@@ -0,0 +1,118 @@
|
|
1
|
+
from typing import Literal, Dict, Optional, TypeAlias, Any, cast
|
2
|
+
from dataclasses import dataclass
|
3
|
+
from .data import BinValueDict, VarValueDict
|
4
|
+
|
5
|
+
__all__ = ["FieldSchema", "PrimitiveFieldSchema", "VarFieldSchema", "SecretFieldSchema", "ArrayFieldSchema", "ObjectFieldSchema", "ContentMediaType", "is_bin_value", "is_var_value", "is_array_dict", "is_object_dict", "is_var_dict", "is_primitive_dict", "is_secret_dict"]
|
6
|
+
|
7
|
+
OomolType = Literal["oomol/var", "oomol/secret", "oomol/bin"]
|
8
|
+
|
9
|
+
ContentMediaType: TypeAlias = Literal["oomol/bin", "oomol/secret", "oomol/var"]
|
10
|
+
|
11
|
+
def is_bin_value(d: BinValueDict | Any):
|
12
|
+
if isinstance(d, dict) is False:
|
13
|
+
return False
|
14
|
+
dd = cast(BinValueDict, d)
|
15
|
+
return dd.get("__OOMOL_TYPE__") == "oomol/bin" and isinstance(dd.get("value") , str)
|
16
|
+
|
17
|
+
def is_var_value(d: VarValueDict | Any):
|
18
|
+
if isinstance(d, dict) is False:
|
19
|
+
return False
|
20
|
+
dd = cast(VarValueDict, d)
|
21
|
+
return dd.get("__OOMOL_TYPE__") == "oomol/var" and isinstance(dd.get("value") , dict)
|
22
|
+
|
23
|
+
def is_array_dict(dict: Dict):
|
24
|
+
return dict.get("type") == "array"
|
25
|
+
|
26
|
+
def is_object_dict(dict: Dict):
|
27
|
+
return dict.get("type") == "object"
|
28
|
+
|
29
|
+
def is_var_dict(dict: Dict):
|
30
|
+
return dict.get("contentMediaType") == "oomol/var"
|
31
|
+
|
32
|
+
def is_primitive_dict(dict: Dict):
|
33
|
+
return dict.get("type") in ["string", "number", "boolean"] and dict.get("contentMediaType") is None
|
34
|
+
|
35
|
+
def is_secret_dict(dict: Dict):
|
36
|
+
return dict.get("contentMediaType") == "oomol/secret" and dict.get("type") == "string"
|
37
|
+
|
38
|
+
@dataclass(frozen=True, kw_only=True)
|
39
|
+
class FieldSchema:
|
40
|
+
""" The JSON schema of the handle. It contains the schema of the handle's content.
|
41
|
+
but we only need the contentMediaType to check the handle's type here.
|
42
|
+
"""
|
43
|
+
|
44
|
+
contentMediaType: Optional[ContentMediaType] = None
|
45
|
+
"""The media type of the content of the schema."""
|
46
|
+
|
47
|
+
def __init__(self, **kwargs):
|
48
|
+
for key, value in kwargs.items():
|
49
|
+
object.__setattr__(self, key, value)
|
50
|
+
|
51
|
+
@staticmethod
|
52
|
+
def generate_schema(dict: Dict):
|
53
|
+
if is_var_dict(dict):
|
54
|
+
return VarFieldSchema(**dict)
|
55
|
+
elif is_secret_dict(dict):
|
56
|
+
return SecretFieldSchema(**dict)
|
57
|
+
elif is_primitive_dict(dict):
|
58
|
+
return PrimitiveFieldSchema(**dict)
|
59
|
+
elif is_array_dict(dict):
|
60
|
+
return ArrayFieldSchema(**dict)
|
61
|
+
elif is_object_dict(dict):
|
62
|
+
return ObjectFieldSchema(**dict)
|
63
|
+
else:
|
64
|
+
return FieldSchema(**dict)
|
65
|
+
|
66
|
+
@dataclass(frozen=True, kw_only=True)
|
67
|
+
class PrimitiveFieldSchema(FieldSchema):
|
68
|
+
type: Literal["string", "number", "boolean"]
|
69
|
+
contentMediaType: None = None
|
70
|
+
def __init__(self, **kwargs):
|
71
|
+
for key, value in kwargs.items():
|
72
|
+
object.__setattr__(self, key, value)
|
73
|
+
|
74
|
+
|
75
|
+
@dataclass(frozen=True, kw_only=True)
|
76
|
+
class VarFieldSchema(FieldSchema):
|
77
|
+
contentMediaType: Literal["oomol/var"] = "oomol/var"
|
78
|
+
|
79
|
+
def __init__(self, **kwargs):
|
80
|
+
for key, value in kwargs.items():
|
81
|
+
object.__setattr__(self, key, value)
|
82
|
+
|
83
|
+
@dataclass(frozen=True, kw_only=True)
|
84
|
+
class SecretFieldSchema(FieldSchema):
|
85
|
+
type: Literal["string"] = "string"
|
86
|
+
contentMediaType: Literal["oomol/secret"] = "oomol/secret"
|
87
|
+
|
88
|
+
def __init__(self, **kwargs):
|
89
|
+
for key, value in kwargs.items():
|
90
|
+
object.__setattr__(self, key, value)
|
91
|
+
|
92
|
+
@dataclass(frozen=True, kw_only=True)
|
93
|
+
class ArrayFieldSchema(FieldSchema):
|
94
|
+
type: Literal["array"] = "array"
|
95
|
+
items: Optional['FieldSchema'] = None
|
96
|
+
|
97
|
+
def __init__(self, **kwargs):
|
98
|
+
for key, value in kwargs.items():
|
99
|
+
object.__setattr__(self, key, value)
|
100
|
+
items = self.items
|
101
|
+
if items is not None and not isinstance(items, FieldSchema):
|
102
|
+
object.__setattr__(self, "items", FieldSchema.generate_schema(items))
|
103
|
+
|
104
|
+
@dataclass(frozen=True, kw_only=True)
|
105
|
+
class ObjectFieldSchema(FieldSchema):
|
106
|
+
type: Literal["object"] = "object"
|
107
|
+
properties: Optional[Dict[str, 'FieldSchema']] = None
|
108
|
+
|
109
|
+
def __init__(self, **kwargs):
|
110
|
+
for key, value in kwargs.items():
|
111
|
+
object.__setattr__(self, key, value)
|
112
|
+
|
113
|
+
properties = self.properties
|
114
|
+
if properties is not None:
|
115
|
+
for key, value in properties.items():
|
116
|
+
if not isinstance(value, FieldSchema):
|
117
|
+
properties[key] = FieldSchema.generate_schema(value)
|
118
|
+
|
oocana/service.py
ADDED
@@ -0,0 +1,70 @@
|
|
1
|
+
from typing import Literal, Callable, Any, TypedDict, Optional, TypeAlias, Union
|
2
|
+
from .context import Context
|
3
|
+
from .data import JobDict
|
4
|
+
from abc import ABC, abstractmethod
|
5
|
+
|
6
|
+
__all__ = ["ServiceMessage", "BlockHandler", "ServiceContextAbstractClass", "ServiceExecutor", "ServiceExecutePayload", "StopAtOption"]
|
7
|
+
|
8
|
+
class ServiceMessage(TypedDict):
|
9
|
+
job_id: str
|
10
|
+
node_id: str
|
11
|
+
flow_path: str
|
12
|
+
payload: Any
|
13
|
+
|
14
|
+
BlockHandler: TypeAlias = Union[Callable[[str, Any, Context], Any], dict[str, Callable[[Any, Context], Any]]]
|
15
|
+
|
16
|
+
class ServiceContextAbstractClass(ABC):
|
17
|
+
|
18
|
+
@property
|
19
|
+
@abstractmethod
|
20
|
+
def block_handler(self) -> BlockHandler:
|
21
|
+
pass
|
22
|
+
|
23
|
+
@block_handler.setter
|
24
|
+
@abstractmethod
|
25
|
+
def block_handler(self, value: BlockHandler):
|
26
|
+
pass
|
27
|
+
|
28
|
+
def __setitem__(self, key: str, value: Any):
|
29
|
+
pass
|
30
|
+
|
31
|
+
@property
|
32
|
+
@abstractmethod
|
33
|
+
def waiting_ready_notify(self) -> bool:
|
34
|
+
"""set to True if the service need to wait for the ready signal before start. default is false which means the service will start immediately after block_handler is set.
|
35
|
+
this function need to be called before set block_handler
|
36
|
+
after set this function, developer need call notify_ready manually when the service is ready to start, otherwise the service will not run block
|
37
|
+
"""
|
38
|
+
pass
|
39
|
+
|
40
|
+
@waiting_ready_notify.setter
|
41
|
+
@abstractmethod
|
42
|
+
def waiting_ready_notify(self, value: bool):
|
43
|
+
pass
|
44
|
+
|
45
|
+
def notify_ready(self):
|
46
|
+
"""notify the service that the service is ready to start. this function need to be called after waiting_ready_notify is set to True otherwise this function has no effect"""
|
47
|
+
pass
|
48
|
+
|
49
|
+
def add_message_callback(self, callback: Callable[[ServiceMessage], Any]):
|
50
|
+
"""add a callback to handle the message to the service, the callback will be called when the message is sent to the service
|
51
|
+
:param callback: the callback to handle the message
|
52
|
+
"""
|
53
|
+
pass
|
54
|
+
|
55
|
+
StopAtOption: TypeAlias = Optional[Literal["block_end", "session_end", "app_end", "never"]]
|
56
|
+
|
57
|
+
class ServiceExecutor(TypedDict):
|
58
|
+
name: str
|
59
|
+
entry: str
|
60
|
+
function: str
|
61
|
+
start_at: Optional[Literal["block_start", "session_start", "app_start"]]
|
62
|
+
stop_at: StopAtOption
|
63
|
+
keep_alive: Optional[int]
|
64
|
+
|
65
|
+
class ServiceExecutePayload(JobDict):
|
66
|
+
dir: str
|
67
|
+
block_name: str
|
68
|
+
service_executor: ServiceExecutor
|
69
|
+
outputs: dict
|
70
|
+
service_hash: str
|
oocana/throtter.py
ADDED
@@ -0,0 +1,37 @@
|
|
1
|
+
import time
|
2
|
+
import threading
|
3
|
+
|
4
|
+
def throttle(period):
|
5
|
+
last_invoke_time = 0
|
6
|
+
timer = None
|
7
|
+
last_args = None
|
8
|
+
last_kwargs = None
|
9
|
+
|
10
|
+
def decorator(fn):
|
11
|
+
def wrapper(*args, **kwargs):
|
12
|
+
nonlocal last_invoke_time, timer, last_args, last_kwargs
|
13
|
+
now = time.time()
|
14
|
+
should_invoke = now - last_invoke_time > period
|
15
|
+
|
16
|
+
def invoke():
|
17
|
+
nonlocal last_invoke_time, timer, last_args, last_kwargs
|
18
|
+
last_invoke_time = time.time()
|
19
|
+
timer = None
|
20
|
+
return fn(*last_args, **last_kwargs)
|
21
|
+
|
22
|
+
if should_invoke:
|
23
|
+
if timer:
|
24
|
+
timer.cancel()
|
25
|
+
timer = None
|
26
|
+
last_invoke_time = now
|
27
|
+
return fn(*args, **kwargs)
|
28
|
+
else:
|
29
|
+
last_args = args
|
30
|
+
last_kwargs = kwargs
|
31
|
+
if timer:
|
32
|
+
return
|
33
|
+
timer = threading.Timer(period - (now - last_invoke_time), lambda: invoke())
|
34
|
+
timer.start()
|
35
|
+
|
36
|
+
return wrapper
|
37
|
+
return decorator
|
@@ -0,0 +1,10 @@
|
|
1
|
+
Metadata-Version: 2.1
|
2
|
+
Name: oocana
|
3
|
+
Version: 0.15.0
|
4
|
+
Summary: python implement of oocana to give a context for oocana block
|
5
|
+
License: MIT
|
6
|
+
Requires-Python: >=3.9
|
7
|
+
Requires-Dist: paho-mqtt>=2
|
8
|
+
Requires-Dist: simplejson>=3.19.2
|
9
|
+
Requires-Dist: typing-extensions>=4.12.2; python_version < "3.11"
|
10
|
+
|
@@ -0,0 +1,13 @@
|
|
1
|
+
oocana-0.15.0.dist-info/METADATA,sha256=8jv7zdOr3ObiASdywJdz3z6OSPBPQ220zZH_4S1La9c,287
|
2
|
+
oocana-0.15.0.dist-info/WHEEL,sha256=thaaA2w1JzcGC48WYufAs8nrYZjJm8LqNfnXFOFyCC4,90
|
3
|
+
oocana-0.15.0.dist-info/entry_points.txt,sha256=6OYgBcLyFCUgeqLgnvMyOJxPCWzgy7se4rLPKtNonMs,34
|
4
|
+
oocana/__init__.py,sha256=zMrQ1asZoRE91-BShf1v2ibdThaMc07YtZt5iNbbxsg,281
|
5
|
+
oocana/context.py,sha256=yG8Vlg8v9riCFCdqufrLzO5tZlSKPX0w6mkat0YT1dQ,11622
|
6
|
+
oocana/data.py,sha256=ex042cqPHxqyRI8nuQBCguL1aLKX-7GGDughc-EAPqY,2939
|
7
|
+
oocana/handle_data.py,sha256=p0iEvdsVV3BqcelM8rvq0a_VPI52SeahaGCszfhZ0TI,1927
|
8
|
+
oocana/mainframe.py,sha256=bmKaSD9gGacT6tI48pSXiMG8BB3Xa1spP5HesLkNgV0,5100
|
9
|
+
oocana/preview.py,sha256=sAKcVUFmOMvrzYLrvqAxd26SppSvK1qJqRTHdAS-KDc,1959
|
10
|
+
oocana/schema.py,sha256=8LwMaW4eXa3EmKxR4kzyTOpZiClMRMMsMA6f9CXodW4,4332
|
11
|
+
oocana/service.py,sha256=dPCcScQfMBlEjIodFnKU17-HlbBzrQbQK6CNRw7SmOE,2442
|
12
|
+
oocana/throtter.py,sha256=NqQ1THmYLd62Kr84IpUedzKEuopzrbMaTFfbv0TDPZU,1104
|
13
|
+
oocana-0.15.0.dist-info/RECORD,,
|