oocana-python-executor 0.15.0__tar.gz
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_python_executor-0.15.0/PKG-INFO +9 -0
- oocana_python_executor-0.15.0/pyproject.toml +27 -0
- oocana_python_executor-0.15.0/python_executor/__init__.py +0 -0
- oocana_python_executor-0.15.0/python_executor/block.py +189 -0
- oocana_python_executor-0.15.0/python_executor/context.py +68 -0
- oocana_python_executor-0.15.0/python_executor/data.py +5 -0
- oocana_python_executor-0.15.0/python_executor/executor.py +247 -0
- oocana_python_executor-0.15.0/python_executor/hook.py +50 -0
- oocana_python_executor-0.15.0/python_executor/logger.py +19 -0
- oocana_python_executor-0.15.0/python_executor/matplot/matplotlib_oomol/__init__.py +1 -0
- oocana_python_executor-0.15.0/python_executor/matplot/matplotlib_oomol/oomol.py +28 -0
- oocana_python_executor-0.15.0/python_executor/matplot/oomol_matplot_helper.py +68 -0
- oocana_python_executor-0.15.0/python_executor/secret.py +146 -0
- oocana_python_executor-0.15.0/python_executor/service.py +238 -0
- oocana_python_executor-0.15.0/python_executor/topic.py +63 -0
- oocana_python_executor-0.15.0/python_executor/utils.py +32 -0
- oocana_python_executor-0.15.0/tests/test_cli.py +50 -0
- oocana_python_executor-0.15.0/tests/test_secret.py +142 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: oocana-python-executor
|
|
3
|
+
Version: 0.15.0
|
|
4
|
+
Summary: a client subscribe mqtt topic to execute oocana's block
|
|
5
|
+
Author-Email: l1shen <lishen1635@gmail.com>, yleaf <11785335+leavesster@users.noreply.github.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
Requires-Dist: oocana
|
|
9
|
+
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "oocana-python-executor"
|
|
3
|
+
version = "0.15.0"
|
|
4
|
+
authors = [
|
|
5
|
+
{ name = "l1shen", email = "lishen1635@gmail.com" },
|
|
6
|
+
{ name = "yleaf", email = "11785335+leavesster@users.noreply.github.com" },
|
|
7
|
+
]
|
|
8
|
+
description = "a client subscribe mqtt topic to execute oocana's block"
|
|
9
|
+
requires-python = ">=3.9"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"oocana",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.license]
|
|
15
|
+
text = "MIT"
|
|
16
|
+
|
|
17
|
+
[project.scripts]
|
|
18
|
+
python-executor = "python_executor.executor:main"
|
|
19
|
+
|
|
20
|
+
[build-system]
|
|
21
|
+
requires = [
|
|
22
|
+
"pdm-backend",
|
|
23
|
+
]
|
|
24
|
+
build-backend = "pdm.backend"
|
|
25
|
+
|
|
26
|
+
[tool.pdm]
|
|
27
|
+
distribution = true
|
|
File without changes
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
from oocana import Context, Mainframe
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Optional, TypedDict
|
|
4
|
+
import inspect
|
|
5
|
+
import traceback
|
|
6
|
+
import logging
|
|
7
|
+
from .data import store, vars, EXECUTOR_NAME
|
|
8
|
+
from .context import createContext
|
|
9
|
+
from .hook import ExitFunctionException
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
import importlib
|
|
13
|
+
import importlib.util
|
|
14
|
+
import threading
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ExecutorOptionsDict(TypedDict):
|
|
18
|
+
function: Optional[str]
|
|
19
|
+
entry: Optional[str]
|
|
20
|
+
source: Optional[str]
|
|
21
|
+
|
|
22
|
+
# entry 与 source 是二选一的存在
|
|
23
|
+
class ExecutorDict(TypedDict):
|
|
24
|
+
options: Optional[ExecutorOptionsDict]
|
|
25
|
+
|
|
26
|
+
tmp_files = set()
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class ExecutePayload:
|
|
30
|
+
session_id: str
|
|
31
|
+
job_id: str
|
|
32
|
+
dir: str
|
|
33
|
+
executor: ExecutorDict
|
|
34
|
+
outputs: Optional[dict] = None
|
|
35
|
+
|
|
36
|
+
def __init__(self, *args, **kwargs):
|
|
37
|
+
if args:
|
|
38
|
+
self.session_id = args[0]
|
|
39
|
+
self.job_id = args[1]
|
|
40
|
+
self.executor = args[2]
|
|
41
|
+
self.dir = args[3]
|
|
42
|
+
self.outputs = args[4]
|
|
43
|
+
if kwargs:
|
|
44
|
+
for key, value in kwargs.items():
|
|
45
|
+
setattr(self, key, value)
|
|
46
|
+
|
|
47
|
+
lock = threading.Lock()
|
|
48
|
+
|
|
49
|
+
def load_module(file_path: str, source_dir=None):
|
|
50
|
+
|
|
51
|
+
if (os.path.isabs(file_path)):
|
|
52
|
+
file_abs_path = file_path
|
|
53
|
+
else:
|
|
54
|
+
dirname = source_dir if source_dir else os.getcwd()
|
|
55
|
+
file_abs_path = os.path.abspath(os.path.join(dirname, file_path))
|
|
56
|
+
with lock:
|
|
57
|
+
if file_abs_path in sys.modules:
|
|
58
|
+
return sys.modules[file_abs_path]
|
|
59
|
+
|
|
60
|
+
module_dir = os.path.dirname(file_abs_path)
|
|
61
|
+
sys.path.insert(0, module_dir)
|
|
62
|
+
|
|
63
|
+
file_spec = importlib.util.spec_from_file_location(file_abs_path, file_abs_path)
|
|
64
|
+
module = importlib.util.module_from_spec(file_spec) # type: ignore
|
|
65
|
+
sys.modules[file_abs_path] = module
|
|
66
|
+
|
|
67
|
+
file_spec.loader.exec_module(module) # type: ignore
|
|
68
|
+
return module
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def output_return_object(obj, context: Context):
|
|
72
|
+
if obj is None:
|
|
73
|
+
context.done()
|
|
74
|
+
elif obj is context.keepAlive:
|
|
75
|
+
pass
|
|
76
|
+
elif isinstance(obj, dict):
|
|
77
|
+
for k, v in obj.items():
|
|
78
|
+
context.output(k, v)
|
|
79
|
+
context.done()
|
|
80
|
+
else:
|
|
81
|
+
context.done(f"return object needs to be a dictionary, but get type: {type(obj)}")
|
|
82
|
+
|
|
83
|
+
logger = logging.getLogger(EXECUTOR_NAME)
|
|
84
|
+
|
|
85
|
+
async def run_block(message, mainframe: Mainframe, session_dir: str):
|
|
86
|
+
|
|
87
|
+
logger.info(f"block {message.get('job_id')} start")
|
|
88
|
+
try:
|
|
89
|
+
payload = ExecutePayload(**message)
|
|
90
|
+
context = createContext(mainframe, payload.session_id, payload.job_id, store, payload.outputs, session_dir)
|
|
91
|
+
except Exception:
|
|
92
|
+
traceback_str = traceback.format_exc()
|
|
93
|
+
# rust 那边会保证传过来的 message 一定是符合格式的,所以这里不应该出现异常。这里主要是防止 rust 修改错误。
|
|
94
|
+
mainframe.send(
|
|
95
|
+
{
|
|
96
|
+
"job_id": message["job_id"],
|
|
97
|
+
"session_id": message["session_id"],
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
"type": "BlockFinished",
|
|
101
|
+
"job_id": message["job_id"],
|
|
102
|
+
"error": traceback_str
|
|
103
|
+
})
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
vars.set(context)
|
|
107
|
+
|
|
108
|
+
load_dir = payload.dir
|
|
109
|
+
|
|
110
|
+
options = payload.executor.get("options")
|
|
111
|
+
|
|
112
|
+
node_id = context.node_id
|
|
113
|
+
|
|
114
|
+
file_path = options["entry"] if options is not None and options.get("entry") is not None else 'index.py'
|
|
115
|
+
|
|
116
|
+
source = options.get("source") if options is not None else None
|
|
117
|
+
if source is not None:
|
|
118
|
+
if not os.path.exists(load_dir):
|
|
119
|
+
os.makedirs(load_dir)
|
|
120
|
+
|
|
121
|
+
dir_path = os.path.join(load_dir, ".scriptlets")
|
|
122
|
+
if not os.path.exists(dir_path):
|
|
123
|
+
os.makedirs(dir_path)
|
|
124
|
+
tmp_py = os.path.join(dir_path, f"{node_id}.py")
|
|
125
|
+
# 记录临时文件,但是现在不再清理
|
|
126
|
+
tmp_files.add(tmp_py)
|
|
127
|
+
|
|
128
|
+
with open(tmp_py, "w") as f:
|
|
129
|
+
f.write(source)
|
|
130
|
+
file_path = tmp_py
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
# TODO: 这里的异常处理,应该跟详细一些,提供语法错误提示。
|
|
134
|
+
index_module = load_module(file_path, load_dir) # type: ignore
|
|
135
|
+
except Exception:
|
|
136
|
+
traceback_str = traceback.format_exc()
|
|
137
|
+
context.done(traceback_str)
|
|
138
|
+
return
|
|
139
|
+
function_name: str = options.get("function") if payload.executor is not None and options.get("function") is not None else 'main' # type: ignore
|
|
140
|
+
fn = index_module.__dict__.get(function_name)
|
|
141
|
+
|
|
142
|
+
if fn is None:
|
|
143
|
+
context.done(f"function {function_name} not found in {file_path}")
|
|
144
|
+
return
|
|
145
|
+
if not callable(fn):
|
|
146
|
+
context.done(f"{function_name} is not a function in {file_path}")
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
signature = inspect.signature(fn)
|
|
151
|
+
params_count = len(signature.parameters)
|
|
152
|
+
result = None
|
|
153
|
+
traceback_str = None
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
if inspect.iscoroutinefunction(fn):
|
|
157
|
+
if params_count == 0:
|
|
158
|
+
result = await fn()
|
|
159
|
+
elif params_count == 1:
|
|
160
|
+
only_context_param = list(signature.parameters.values())[0].annotation is Context
|
|
161
|
+
result = await fn(context) if only_context_param else await fn(context.inputs)
|
|
162
|
+
else:
|
|
163
|
+
result = await fn(context.inputs, context)
|
|
164
|
+
else:
|
|
165
|
+
if params_count == 0:
|
|
166
|
+
result = fn()
|
|
167
|
+
elif params_count == 1:
|
|
168
|
+
only_context_param = list(signature.parameters.values())[0].annotation is Context
|
|
169
|
+
result = fn(context) if only_context_param else fn(context.inputs)
|
|
170
|
+
else:
|
|
171
|
+
result = fn(context.inputs, context)
|
|
172
|
+
except ExitFunctionException as e:
|
|
173
|
+
if e.args[0] is not None:
|
|
174
|
+
context.done("block call exit with message: " + str(e.args[0]))
|
|
175
|
+
else:
|
|
176
|
+
context.done()
|
|
177
|
+
except Exception:
|
|
178
|
+
traceback_str = traceback.format_exc()
|
|
179
|
+
|
|
180
|
+
if traceback_str is not None:
|
|
181
|
+
context.done(traceback_str)
|
|
182
|
+
else:
|
|
183
|
+
output_return_object(result, context)
|
|
184
|
+
except Exception:
|
|
185
|
+
traceback_str = traceback.format_exc()
|
|
186
|
+
context.done(traceback_str)
|
|
187
|
+
finally:
|
|
188
|
+
logger.info(f"block {message.get('job_id')} done")
|
|
189
|
+
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from oocana import Mainframe, Context, StoreKey, BlockInfo, BinValueDict, VarValueDict, InputHandleDef, is_bin_value, is_var_value
|
|
3
|
+
from typing import Dict
|
|
4
|
+
from .secret import replace_secret
|
|
5
|
+
import os.path
|
|
6
|
+
from .logger import ContextHandler
|
|
7
|
+
from .data import EXECUTOR_NAME
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(EXECUTOR_NAME)
|
|
10
|
+
|
|
11
|
+
def createContext(
|
|
12
|
+
mainframe: Mainframe, session_id: str, job_id: str, store, output, session_dir: str
|
|
13
|
+
) -> Context:
|
|
14
|
+
|
|
15
|
+
node_props = mainframe.notify_block_ready(session_id, job_id)
|
|
16
|
+
|
|
17
|
+
inputs_def: Dict[str, Dict] | None = node_props.get("inputs_def")
|
|
18
|
+
inputs = node_props.get("inputs")
|
|
19
|
+
|
|
20
|
+
if inputs_def is not None and inputs is not None:
|
|
21
|
+
|
|
22
|
+
inputs_def_handles: Dict[str, InputHandleDef] = {}
|
|
23
|
+
for k, v in inputs_def.items():
|
|
24
|
+
inputs_def_handles[k] = InputHandleDef(**v)
|
|
25
|
+
|
|
26
|
+
inputs = replace_secret(inputs, inputs_def_handles, node_props.get("inputs_def_patch"))
|
|
27
|
+
|
|
28
|
+
for k, v in inputs.items():
|
|
29
|
+
input_def = inputs_def_handles.get(k)
|
|
30
|
+
if input_def is None:
|
|
31
|
+
continue
|
|
32
|
+
if is_var_value(v):
|
|
33
|
+
wrap_var: VarValueDict = v
|
|
34
|
+
try:
|
|
35
|
+
ref = StoreKey(**wrap_var["value"])
|
|
36
|
+
except: # noqa: E722
|
|
37
|
+
logger.warning(f"not valid object ref: {wrap_var}")
|
|
38
|
+
continue
|
|
39
|
+
if ref in store:
|
|
40
|
+
inputs[k] = store.get(ref)
|
|
41
|
+
else:
|
|
42
|
+
logger.error(f"object {ref} not found in store")
|
|
43
|
+
elif is_bin_value(v):
|
|
44
|
+
wrap_bin: BinValueDict = v
|
|
45
|
+
path = wrap_bin["value"]
|
|
46
|
+
if isinstance(path, str):
|
|
47
|
+
# check file path v is exist
|
|
48
|
+
if not os.path.exists(path):
|
|
49
|
+
logger.error(f"file {path} for oomol/bin is not found")
|
|
50
|
+
continue
|
|
51
|
+
|
|
52
|
+
with open(path, "rb") as f:
|
|
53
|
+
inputs[k] = f.read()
|
|
54
|
+
else:
|
|
55
|
+
logger.error(f"not valid bin handle: {v}")
|
|
56
|
+
|
|
57
|
+
if inputs is None:
|
|
58
|
+
inputs = {}
|
|
59
|
+
|
|
60
|
+
blockInfo = BlockInfo(**node_props)
|
|
61
|
+
|
|
62
|
+
ctx = Context(inputs, blockInfo, mainframe, store, output, session_dir)
|
|
63
|
+
# 跟 executor 日志分开,避免有的库在 logger 里面使用 print,导致 hook 出现递归调用。
|
|
64
|
+
block_logger = logging.getLogger(f"block {job_id}")
|
|
65
|
+
ctx_handler = ContextHandler(ctx)
|
|
66
|
+
block_logger.addHandler(ctx_handler)
|
|
67
|
+
ctx._logger = logger
|
|
68
|
+
return ctx
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
import queue
|
|
6
|
+
import sys
|
|
7
|
+
import logging
|
|
8
|
+
from . import hook
|
|
9
|
+
from oocana import Mainframe, ServiceExecutePayload
|
|
10
|
+
from .utils import run_in_new_thread, run_async_code, oocana_dir
|
|
11
|
+
from .block import run_block
|
|
12
|
+
from oocana import EXECUTOR_NAME
|
|
13
|
+
from .matplot.oomol_matplot_helper import import_helper, add_matplot_module
|
|
14
|
+
from typing import Literal
|
|
15
|
+
from .topic import prepare_report_topic, service_config_topic, run_action_topic, ServiceTopicParams, ReportStatusPayload, exit_report_topic, status_report_topic
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(EXECUTOR_NAME)
|
|
18
|
+
service_store: dict[str, Literal["launching", "running"]] = {}
|
|
19
|
+
job_set = set()
|
|
20
|
+
|
|
21
|
+
# 日志目录 ~/.oocana/sessions/{session_id}
|
|
22
|
+
# executor 的日志都会记录在 [python-executor-{suffix}.log | python-executor.log]
|
|
23
|
+
# 全局 logger 会记录在 python-{suffix}.log | python.log
|
|
24
|
+
def config_logger(session_id: str, suffix: str | None, output: Literal["console", "file"]):
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
format = '%(asctime)s - %(levelname)s - {%(pathname)s:%(lineno)d} - %(message)s'
|
|
28
|
+
fmt = logging.Formatter(format)
|
|
29
|
+
logger.setLevel(logging.DEBUG)
|
|
30
|
+
if output == "file":
|
|
31
|
+
executor_dir = os.path.join(oocana_dir(), "sessions", session_id)
|
|
32
|
+
logger_file = os.path.join(executor_dir, f"python-executor-{suffix}.log") if suffix is not None else os.path.join(executor_dir, "python-executor.log")
|
|
33
|
+
|
|
34
|
+
if not os.path.exists(logger_file):
|
|
35
|
+
os.makedirs(os.path.dirname(logger_file), exist_ok=True)
|
|
36
|
+
|
|
37
|
+
print(f"setup logging in file {logger_file}")
|
|
38
|
+
h = logging.FileHandler(logger_file)
|
|
39
|
+
|
|
40
|
+
global_logger_file = os.path.join(executor_dir, f"python-{suffix}.log") if suffix is not None else os.path.join(executor_dir, "python.log")
|
|
41
|
+
logging.basicConfig(filename=global_logger_file, level=logging.DEBUG, format=format)
|
|
42
|
+
else:
|
|
43
|
+
logging.basicConfig(level=logging.DEBUG, format=format)
|
|
44
|
+
h = logging.StreamHandler(sys.stdout)
|
|
45
|
+
|
|
46
|
+
h.setFormatter(fmt)
|
|
47
|
+
logger.addHandler(h)
|
|
48
|
+
# 跟全局日志分开。避免有的库在全局 logger 里面使用了 print 等 API,导致 hook 出现递归调用
|
|
49
|
+
logger.propagate = False
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def run_executor(address: str, session_id: str, package: str | None, session_dir: str, suffix: str | None = None):
|
|
53
|
+
|
|
54
|
+
if suffix is not None:
|
|
55
|
+
mainframe = Mainframe(address, f"python-executor-{suffix}", logger)
|
|
56
|
+
else:
|
|
57
|
+
mainframe = Mainframe(address, f"python-executor-{session_id}", logger)
|
|
58
|
+
|
|
59
|
+
mainframe.connect()
|
|
60
|
+
|
|
61
|
+
print(f"connecting to broker {address} success")
|
|
62
|
+
sys.stdout.flush()
|
|
63
|
+
|
|
64
|
+
logger.info("executor start") if package is None else logger.info(f"executor start for package {package}")
|
|
65
|
+
|
|
66
|
+
add_matplot_module()
|
|
67
|
+
import_helper(logger)
|
|
68
|
+
|
|
69
|
+
# add package to sys.path
|
|
70
|
+
if package is not None:
|
|
71
|
+
sys.path.append(package)
|
|
72
|
+
elif os.path.exists("/app/workspace"):
|
|
73
|
+
sys.path.append("/app/workspace")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def not_current_session(message):
|
|
77
|
+
return message.get("session_id") != session_id
|
|
78
|
+
|
|
79
|
+
def not_current_package(message):
|
|
80
|
+
return message.get("package") != package
|
|
81
|
+
|
|
82
|
+
# 目前的 mqtt 库,在 subscribe 回调里 publish 消息会导致死锁无法工作,参考 https://github.com/eclipse/paho.mqtt.python/issues/527 或者 https://stackoverflow.com/a/36964192/4770006
|
|
83
|
+
# 通过这种方式来绕过,所有需要 callback 后 publish message 的情况,都需要使用 future 类似方式来绕过。
|
|
84
|
+
fs = queue.Queue()
|
|
85
|
+
loop = asyncio.get_event_loop()
|
|
86
|
+
|
|
87
|
+
def execute_block(message):
|
|
88
|
+
if not_current_session(message):
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
if not_current_package(message):
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
# https://github.com/oomol/oocana-rust/issues/310 临时解决方案
|
|
95
|
+
job_id = message.get("job_id")
|
|
96
|
+
if job_id in job_set:
|
|
97
|
+
logger.warning(f"job {job_id} already running, ignore")
|
|
98
|
+
return
|
|
99
|
+
job_set.add(job_id)
|
|
100
|
+
|
|
101
|
+
nonlocal fs
|
|
102
|
+
f = loop.create_future()
|
|
103
|
+
fs.put(f)
|
|
104
|
+
f.set_result(message)
|
|
105
|
+
|
|
106
|
+
def execute_service_block(message):
|
|
107
|
+
if not_current_session(message):
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
if not_current_package(message):
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
nonlocal fs
|
|
114
|
+
f = loop.create_future()
|
|
115
|
+
fs.put(f)
|
|
116
|
+
f.set_result(message)
|
|
117
|
+
|
|
118
|
+
def service_exit(message: ReportStatusPayload):
|
|
119
|
+
service_hash = message.get("service_hash")
|
|
120
|
+
if service_hash in service_store:
|
|
121
|
+
del service_store[service_hash]
|
|
122
|
+
|
|
123
|
+
def service_status(message: ReportStatusPayload):
|
|
124
|
+
service_hash = message.get("service_hash")
|
|
125
|
+
if service_hash in service_store:
|
|
126
|
+
service_store[service_hash] = "running"
|
|
127
|
+
|
|
128
|
+
def report_message(message):
|
|
129
|
+
type = message.get("type")
|
|
130
|
+
if type == "SessionFinished":
|
|
131
|
+
if not_current_session(message):
|
|
132
|
+
return
|
|
133
|
+
logger.info(f"session {session_id} finished, exit executor")
|
|
134
|
+
mainframe.disconnect() # TODO: 即使调用 disconnect,在 broker 上也无法看不到主动断开的信息,有时间再调查。
|
|
135
|
+
if os.getenv("IS_FORKED"): # fork 进程无法直接使用 sys.exit 退出
|
|
136
|
+
os._exit(0)
|
|
137
|
+
else:
|
|
138
|
+
hook.original_exit(0)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
mainframe.subscribe(f"executor/{EXECUTOR_NAME}/run_block", execute_block)
|
|
142
|
+
mainframe.subscribe(f"executor/{EXECUTOR_NAME}/run_service_block", execute_service_block)
|
|
143
|
+
mainframe.subscribe('report', report_message)
|
|
144
|
+
mainframe.subscribe(exit_report_topic(), service_exit)
|
|
145
|
+
mainframe.subscribe(status_report_topic(), service_status)
|
|
146
|
+
|
|
147
|
+
mainframe.notify_executor_ready(session_id, EXECUTOR_NAME, package)
|
|
148
|
+
|
|
149
|
+
async def spawn_service(message: ServiceExecutePayload, service_hash: str):
|
|
150
|
+
logger.info(f"create new service {message.get('dir')}")
|
|
151
|
+
service_store[service_hash] = "launching"
|
|
152
|
+
|
|
153
|
+
parent_dir = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
|
|
154
|
+
|
|
155
|
+
is_global_service = message.get("service_executor").get("stop_at") in ["app_end", "never"]
|
|
156
|
+
|
|
157
|
+
if is_global_service:
|
|
158
|
+
process = await asyncio.create_subprocess_shell(
|
|
159
|
+
f"python -u -m python_executor.service --address {address} --service-hash {service_hash} --session-dir {session_dir}",
|
|
160
|
+
cwd=parent_dir
|
|
161
|
+
)
|
|
162
|
+
else:
|
|
163
|
+
process = await asyncio.create_subprocess_shell(
|
|
164
|
+
f"python -u -m python_executor.service --address {address} --session-id {session_id} --service-hash {service_hash} --session-dir {session_dir}",
|
|
165
|
+
cwd=parent_dir
|
|
166
|
+
)
|
|
167
|
+
params: ServiceTopicParams = {
|
|
168
|
+
"service_hash": service_hash,
|
|
169
|
+
"session_id": session_id
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
def send_service_config(params: ServiceTopicParams, message: ServiceExecutePayload):
|
|
173
|
+
|
|
174
|
+
async def run():
|
|
175
|
+
mainframe.publish(service_config_topic(params), message)
|
|
176
|
+
service_store[service_hash] = "running"
|
|
177
|
+
run_in_new_thread(run)
|
|
178
|
+
|
|
179
|
+
# FIXME: mqtt 不能在 subscribe 后立即 publish,需要修复。
|
|
180
|
+
mainframe.subscribe(prepare_report_topic(params), lambda _: send_service_config(params, message))
|
|
181
|
+
|
|
182
|
+
await process.wait()
|
|
183
|
+
logger.info(f"service {service_hash} exit")
|
|
184
|
+
del service_store[service_hash]
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def run_service_block(message: ServiceExecutePayload):
|
|
188
|
+
logger.info(f"service block {message.get('job_id')} start")
|
|
189
|
+
service_hash = message.get("service_hash")
|
|
190
|
+
params: ServiceTopicParams = {
|
|
191
|
+
"service_hash": service_hash,
|
|
192
|
+
"session_id": session_id
|
|
193
|
+
}
|
|
194
|
+
mainframe.publish(run_action_topic(params), message)
|
|
195
|
+
|
|
196
|
+
while True:
|
|
197
|
+
await asyncio.sleep(1)
|
|
198
|
+
if not fs.empty():
|
|
199
|
+
f = fs.get()
|
|
200
|
+
message = await f
|
|
201
|
+
if message.get("service_executor") is not None:
|
|
202
|
+
service_hash = message.get("service_hash")
|
|
203
|
+
status = service_store.get(service_hash)
|
|
204
|
+
if status is None:
|
|
205
|
+
asyncio.create_task(spawn_service(message, service_hash))
|
|
206
|
+
elif status == "running":
|
|
207
|
+
run_service_block(message)
|
|
208
|
+
elif status == "launching":
|
|
209
|
+
logger.info(f"service {service_hash} is launching, set message back to fs to wait next time")
|
|
210
|
+
fs.put(f)
|
|
211
|
+
else:
|
|
212
|
+
if not_current_session(message):
|
|
213
|
+
continue
|
|
214
|
+
run_block_in_new_thread(message, mainframe, session_dir=session_dir)
|
|
215
|
+
|
|
216
|
+
def run_block_in_new_thread(message, mainframe: Mainframe, session_dir: str):
|
|
217
|
+
|
|
218
|
+
async def run():
|
|
219
|
+
await run_block(message, mainframe, session_dir=session_dir)
|
|
220
|
+
run_in_new_thread(run)
|
|
221
|
+
|
|
222
|
+
def main():
|
|
223
|
+
|
|
224
|
+
import argparse
|
|
225
|
+
parser = argparse.ArgumentParser(description="run executor with address, session-id, tmp-dir")
|
|
226
|
+
parser.add_argument("--session-id", help="executor subscribe session id", required=True)
|
|
227
|
+
parser.add_argument("--address", help="mqtt address", default="mqtt://127.0.0.1:47688")
|
|
228
|
+
parser.add_argument("--session-dir", help="a tmp dir for whole session", required=True)
|
|
229
|
+
parser.add_argument("--output", help="output log to console or file", default="file", choices=["console", "file"])
|
|
230
|
+
parser.add_argument("--package", help="package path, if set, executor will only run same package block", default=None)
|
|
231
|
+
parser.add_argument("--suffix", help="suffix for log file", default=None)
|
|
232
|
+
|
|
233
|
+
args = parser.parse_args()
|
|
234
|
+
|
|
235
|
+
address: str = args.address
|
|
236
|
+
session_id: str = str(args.session_id)
|
|
237
|
+
output: Literal["console", "file"] = args.output
|
|
238
|
+
package: str | None = args.package
|
|
239
|
+
suffix: str | None = args.suffix
|
|
240
|
+
session_dir: str = args.session_dir
|
|
241
|
+
|
|
242
|
+
config_logger(session_id, suffix, output)
|
|
243
|
+
|
|
244
|
+
run_async_code(run_executor(address=address, session_id=session_id, package=package, session_dir=session_dir, suffix=suffix))
|
|
245
|
+
|
|
246
|
+
if __name__ == '__main__':
|
|
247
|
+
main()
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from sys import exit
|
|
2
|
+
from builtins import exit as global_exit
|
|
3
|
+
from typing import TypeAlias, Any
|
|
4
|
+
import sys
|
|
5
|
+
import builtins
|
|
6
|
+
from .data import vars, EXECUTOR_NAME
|
|
7
|
+
import logging
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(EXECUTOR_NAME)
|
|
10
|
+
|
|
11
|
+
class ExitFunctionException(Exception):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
original_exit = exit
|
|
15
|
+
original_global_exit = global_exit
|
|
16
|
+
original_print = print
|
|
17
|
+
|
|
18
|
+
_ExitCode: TypeAlias = str | int | None
|
|
19
|
+
|
|
20
|
+
def sys_exit(status: _ExitCode = None) -> None:
|
|
21
|
+
raise ExitFunctionException(status)
|
|
22
|
+
|
|
23
|
+
def sys_global_exit(status: _ExitCode = None) -> None:
|
|
24
|
+
raise ExitFunctionException(status)
|
|
25
|
+
|
|
26
|
+
def global_print(*values: object, sep: str | None = " ", end: str | None = "\n", file: Any | None = None, flush: bool = False) -> None:
|
|
27
|
+
|
|
28
|
+
context = None # 初始化 context 变量
|
|
29
|
+
try:
|
|
30
|
+
context = vars.get()
|
|
31
|
+
except LookupError:
|
|
32
|
+
# 这个 logger 不会上报到 root handle 中,所以即使 root logger 的 Handler 里面有 print 函数,也不会导致递归调用
|
|
33
|
+
logger.warning("print called outside of block")
|
|
34
|
+
except Exception as e:
|
|
35
|
+
logger.error(f"print error: {e}")
|
|
36
|
+
|
|
37
|
+
if context is not None:
|
|
38
|
+
try:
|
|
39
|
+
msg_sep = sep or " "
|
|
40
|
+
msg = msg_sep.join(map(str, values))
|
|
41
|
+
context.report_log(msg)
|
|
42
|
+
except Exception as e:
|
|
43
|
+
logger.error(f"transform print message to context log error: {e}")
|
|
44
|
+
|
|
45
|
+
original_print(*values, sep=sep, end=end, file=file, flush=flush)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
sys.exit = sys_exit
|
|
49
|
+
builtins.exit = sys_global_exit
|
|
50
|
+
builtins.print = global_print
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from oocana import Context
|
|
3
|
+
import weakref
|
|
4
|
+
|
|
5
|
+
class ContextHandler(logging.Handler):
|
|
6
|
+
|
|
7
|
+
@property
|
|
8
|
+
def context(self):
|
|
9
|
+
return self._context
|
|
10
|
+
|
|
11
|
+
def __init__(self, context: Context):
|
|
12
|
+
super().__init__()
|
|
13
|
+
self._context = weakref.ref(context)
|
|
14
|
+
|
|
15
|
+
def emit(self, record):
|
|
16
|
+
msg = self.format(record)
|
|
17
|
+
ctx = self.context()
|
|
18
|
+
if ctx:
|
|
19
|
+
ctx.report_log(msg)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .oomol import show, FigureCanvas
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""matplotlib.use('module://matplotlib_oomol'), remember to add this file to PYTHONPATH"""
|
|
2
|
+
|
|
3
|
+
from matplotlib.backend_bases import Gcf # type: ignore
|
|
4
|
+
from matplotlib.backends.backend_agg import FigureCanvasAgg # type: ignore
|
|
5
|
+
from python_executor.data import vars
|
|
6
|
+
|
|
7
|
+
FigureCanvas = FigureCanvasAgg
|
|
8
|
+
|
|
9
|
+
def show(*args, **kwargs):
|
|
10
|
+
import sys
|
|
11
|
+
from io import BytesIO
|
|
12
|
+
from base64 import b64encode
|
|
13
|
+
if vars is not None:
|
|
14
|
+
context = vars.get()
|
|
15
|
+
images = []
|
|
16
|
+
for figmanager in Gcf.get_all_fig_managers():
|
|
17
|
+
buffer = BytesIO()
|
|
18
|
+
figmanager.canvas.figure.savefig(buffer, format='png')
|
|
19
|
+
buffer.seek(0)
|
|
20
|
+
png = buffer.getvalue()
|
|
21
|
+
buffer.close()
|
|
22
|
+
base64Data = b64encode(png).decode('utf-8')
|
|
23
|
+
url = f'data:image/png;base64,{base64Data}'
|
|
24
|
+
images.append(url)
|
|
25
|
+
if images:
|
|
26
|
+
context.preview({ "type": "image", "data": images })
|
|
27
|
+
else:
|
|
28
|
+
print('matplotlib_oomol: no sys.modules["oomol"]', file=sys.stderr)
|