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.
@@ -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
@@ -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,5 @@
1
+ from contextvars import ContextVar
2
+ from oocana import Context, EXECUTOR_NAME
3
+
4
+ vars: ContextVar[Context] = ContextVar('context')
5
+ store = {}
@@ -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)