appose 0.2.0__tar.gz → 0.6.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.
- {appose-0.2.0 → appose-0.6.0}/LICENSE.txt +1 -1
- {appose-0.2.0/src/appose.egg-info → appose-0.6.0}/PKG-INFO +5 -4
- {appose-0.2.0 → appose-0.6.0}/pyproject.toml +3 -3
- {appose-0.2.0 → appose-0.6.0}/src/appose/__init__.py +90 -6
- {appose-0.2.0 → appose-0.6.0}/src/appose/environment.py +1 -1
- {appose-0.2.0 → appose-0.6.0}/src/appose/paths.py +1 -1
- appose-0.6.0/src/appose/python_worker.py +265 -0
- {appose-0.2.0 → appose-0.6.0}/src/appose/service.py +58 -29
- {appose-0.2.0 → appose-0.6.0}/src/appose/types.py +56 -10
- {appose-0.2.0 → appose-0.6.0/src/appose.egg-info}/PKG-INFO +5 -4
- {appose-0.2.0 → appose-0.6.0}/tests/test_appose.py +79 -10
- {appose-0.2.0 → appose-0.6.0}/tests/test_shm.py +7 -3
- {appose-0.2.0 → appose-0.6.0}/tests/test_types.py +3 -3
- appose-0.2.0/src/appose/python_worker.py +0 -198
- {appose-0.2.0 → appose-0.6.0}/README.md +0 -0
- {appose-0.2.0 → appose-0.6.0}/setup.cfg +0 -0
- {appose-0.2.0 → appose-0.6.0}/src/appose.egg-info/SOURCES.txt +0 -0
- {appose-0.2.0 → appose-0.6.0}/src/appose.egg-info/dependency_links.txt +0 -0
- {appose-0.2.0 → appose-0.6.0}/src/appose.egg-info/requires.txt +0 -0
- {appose-0.2.0 → appose-0.6.0}/src/appose.egg-info/top_level.txt +0 -0
@@ -1,9 +1,9 @@
|
|
1
|
-
Metadata-Version: 2.
|
1
|
+
Metadata-Version: 2.4
|
2
2
|
Name: appose
|
3
|
-
Version: 0.
|
3
|
+
Version: 0.6.0
|
4
4
|
Summary: Appose: multi-language interprocess cooperation with shared memory.
|
5
5
|
Author: Appose developers
|
6
|
-
License:
|
6
|
+
License-Expression: BSD-2-Clause
|
7
7
|
Project-URL: homepage, https://github.com/apposed/appose-python
|
8
8
|
Project-URL: documentation, https://github.com/apposed/appose-python/blob/main/README.md
|
9
9
|
Project-URL: source, https://github.com/apposed/appose-python
|
@@ -17,7 +17,7 @@ Classifier: Intended Audience :: Science/Research
|
|
17
17
|
Classifier: Programming Language :: Python :: 3 :: Only
|
18
18
|
Classifier: Programming Language :: Python :: 3.10
|
19
19
|
Classifier: Programming Language :: Python :: 3.11
|
20
|
-
Classifier:
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
21
21
|
Classifier: Operating System :: Microsoft :: Windows
|
22
22
|
Classifier: Operating System :: Unix
|
23
23
|
Classifier: Operating System :: MacOS
|
@@ -40,6 +40,7 @@ Requires-Dist: pytest; extra == "dev"
|
|
40
40
|
Requires-Dist: numpy; extra == "dev"
|
41
41
|
Requires-Dist: toml; extra == "dev"
|
42
42
|
Requires-Dist: validate-pyproject[all]; extra == "dev"
|
43
|
+
Dynamic: license-file
|
43
44
|
|
44
45
|
# Appose Python
|
45
46
|
|
@@ -4,9 +4,9 @@ build-backend = "setuptools.build_meta"
|
|
4
4
|
|
5
5
|
[project]
|
6
6
|
name = "appose"
|
7
|
-
version = "0.
|
7
|
+
version = "0.6.0"
|
8
8
|
description = "Appose: multi-language interprocess cooperation with shared memory."
|
9
|
-
license =
|
9
|
+
license = "BSD-2-Clause"
|
10
10
|
authors = [{name = "Appose developers"}]
|
11
11
|
readme = "README.md"
|
12
12
|
keywords = ["java", "javascript", "python", "cross-language", "interprocess"]
|
@@ -18,7 +18,7 @@ classifiers = [
|
|
18
18
|
"Programming Language :: Python :: 3 :: Only",
|
19
19
|
"Programming Language :: Python :: 3.10",
|
20
20
|
"Programming Language :: Python :: 3.11",
|
21
|
-
"
|
21
|
+
"Programming Language :: Python :: 3.12",
|
22
22
|
"Operating System :: Microsoft :: Windows",
|
23
23
|
"Operating System :: Unix",
|
24
24
|
"Operating System :: MacOS",
|
@@ -2,7 +2,7 @@
|
|
2
2
|
# #%L
|
3
3
|
# Appose: multi-language interprocess cooperation with shared memory.
|
4
4
|
# %%
|
5
|
-
# Copyright (C) 2023 -
|
5
|
+
# Copyright (C) 2023 - 2025 Appose developers.
|
6
6
|
# %%
|
7
7
|
# Redistribution and use in source and binary forms, with or without
|
8
8
|
# modification, are permitted provided that the following conditions are met:
|
@@ -42,8 +42,6 @@ The steps for using Appose are:
|
|
42
42
|
|
43
43
|
## Examples
|
44
44
|
|
45
|
-
* TODO - move the below code somewhere linkable, for succinctness here.
|
46
|
-
|
47
45
|
Here is a very simple example written in Python:
|
48
46
|
|
49
47
|
import appose
|
@@ -84,7 +82,7 @@ And here is an example using a few more of Appose's features:
|
|
84
82
|
def task_listener(event):
|
85
83
|
match event.responseType:
|
86
84
|
case UPDATE:
|
87
|
-
print(f"Progress {
|
85
|
+
print(f"Progress {event.current}/{event.maximum}")
|
88
86
|
case COMPLETION:
|
89
87
|
numer = task.outputs["numer"]
|
90
88
|
denom = task.outputs["denom"]
|
@@ -127,8 +125,94 @@ But Appose is compatible with any program that abides by the
|
|
127
125
|
2. The worker must issue responses in Appose's response format on its
|
128
126
|
standard output (stdout) stream.
|
129
127
|
|
130
|
-
|
131
|
-
|
128
|
+
### Requests to worker from service
|
129
|
+
|
130
|
+
A *request* is a single line of JSON sent to the worker process via
|
131
|
+
its standard input stream. It has a `task` key taking the form of a
|
132
|
+
UUID, and a `requestType` key with one of the following values:
|
133
|
+
|
134
|
+
#### EXECUTE
|
135
|
+
|
136
|
+
Asynchronously execute a script within the worker process. E.g.:
|
137
|
+
|
138
|
+
{
|
139
|
+
"task" : "87427f91-d193-4b25-8d35-e1292a34b5c4",
|
140
|
+
"requestType" : "EXECUTE",
|
141
|
+
"script" : "task.outputs[\"result\"] = computeResult(gamma)\n",
|
142
|
+
"inputs" : {"gamma": 2.2}
|
143
|
+
}
|
144
|
+
|
145
|
+
#### CANCEL
|
146
|
+
|
147
|
+
Cancel a running script. E.g.:
|
148
|
+
|
149
|
+
{
|
150
|
+
"task" : "87427f91-d193-4b25-8d35-e1292a34b5c4",
|
151
|
+
"requestType" : "CANCEL"
|
152
|
+
}
|
153
|
+
|
154
|
+
### Responses from worker to service
|
155
|
+
|
156
|
+
A *response* is a single line of JSON with a `task` key taking the form
|
157
|
+
of a UUID, and a `responseType` key with one of the following values:
|
158
|
+
|
159
|
+
#### LAUNCH
|
160
|
+
|
161
|
+
A LAUNCH response is issued to confirm the success of an EXECUTE
|
162
|
+
request.
|
163
|
+
|
164
|
+
{
|
165
|
+
"task" : "87427f91-d193-4b25-8d35-e1292a34b5c4",
|
166
|
+
"responseType" : "LAUNCH"
|
167
|
+
}
|
168
|
+
|
169
|
+
#### UPDATE
|
170
|
+
|
171
|
+
An UPDATE response is issued to convey that a task has somehow made
|
172
|
+
progress. The UPDATE response typically comes bundled with a
|
173
|
+
`message` string indicating what has changed, `current` and/or
|
174
|
+
`maximum` progress indicators conveying the step the task has
|
175
|
+
reached, or both.
|
176
|
+
|
177
|
+
{
|
178
|
+
"task" : "87427f91-d193-4b25-8d35-e1292a34b5c4",
|
179
|
+
"responseType" : "UPDATE",
|
180
|
+
"message" : "Processing step 0 of 91",
|
181
|
+
"current" : 0,
|
182
|
+
"maximum" : 91
|
183
|
+
}
|
184
|
+
|
185
|
+
#### COMPLETION
|
186
|
+
|
187
|
+
A COMPLETION response is issued to convey that a task has successfully
|
188
|
+
completed execution, as well as report the values of any task outputs.
|
189
|
+
|
190
|
+
{
|
191
|
+
"task" : "87427f91-d193-4b25-8d35-e1292a34b5c4",
|
192
|
+
"responseType" : "COMPLETION",
|
193
|
+
"outputs" : {"result" : 91}
|
194
|
+
}
|
195
|
+
|
196
|
+
#### CANCELATION
|
197
|
+
|
198
|
+
A CANCELATION response is issued to confirm the success of a CANCEL
|
199
|
+
request.
|
200
|
+
|
201
|
+
{
|
202
|
+
"task" : "87427f91-d193-4b25-8d35-e1292a34b5c4",
|
203
|
+
"responseType" : "CANCELATION"
|
204
|
+
}
|
205
|
+
|
206
|
+
#### FAILURE
|
207
|
+
|
208
|
+
A FAILURE response is issued to convey that a task did not completely
|
209
|
+
and successfully execute, such as an exception being raised.
|
210
|
+
|
211
|
+
{
|
212
|
+
"task" : "87427f91-d193-4b25-8d35-e1292a34b5c4",
|
213
|
+
"responseType" : "FAILURE",
|
214
|
+
"error", "Invalid gamma value"
|
215
|
+
}
|
132
216
|
'''
|
133
217
|
|
134
218
|
from pathlib import Path
|
@@ -2,7 +2,7 @@
|
|
2
2
|
# #%L
|
3
3
|
# Appose: multi-language interprocess cooperation with shared memory.
|
4
4
|
# %%
|
5
|
-
# Copyright (C) 2023 -
|
5
|
+
# Copyright (C) 2023 - 2025 Appose developers.
|
6
6
|
# %%
|
7
7
|
# Redistribution and use in source and binary forms, with or without
|
8
8
|
# modification, are permitted provided that the following conditions are met:
|
@@ -2,7 +2,7 @@
|
|
2
2
|
# #%L
|
3
3
|
# Appose: multi-language interprocess cooperation with shared memory.
|
4
4
|
# %%
|
5
|
-
# Copyright (C) 2023 -
|
5
|
+
# Copyright (C) 2023 - 2025 Appose developers.
|
6
6
|
# %%
|
7
7
|
# Redistribution and use in source and binary forms, with or without
|
8
8
|
# modification, are permitted provided that the following conditions are met:
|
@@ -0,0 +1,265 @@
|
|
1
|
+
###
|
2
|
+
# #%L
|
3
|
+
# Appose: multi-language interprocess cooperation with shared memory.
|
4
|
+
# %%
|
5
|
+
# Copyright (C) 2023 - 2025 Appose developers.
|
6
|
+
# %%
|
7
|
+
# Redistribution and use in source and binary forms, with or without
|
8
|
+
# modification, are permitted provided that the following conditions are met:
|
9
|
+
#
|
10
|
+
# 1. Redistributions of source code must retain the above copyright notice,
|
11
|
+
# this list of conditions and the following disclaimer.
|
12
|
+
# 2. Redistributions in binary form must reproduce the above copyright notice,
|
13
|
+
# this list of conditions and the following disclaimer in the documentation
|
14
|
+
# and/or other materials provided with the distribution.
|
15
|
+
#
|
16
|
+
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
17
|
+
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
18
|
+
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
19
|
+
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
|
20
|
+
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
21
|
+
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
22
|
+
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
23
|
+
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
24
|
+
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
25
|
+
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
26
|
+
# POSSIBILITY OF SUCH DAMAGE.
|
27
|
+
# #L%
|
28
|
+
###
|
29
|
+
|
30
|
+
"""
|
31
|
+
The Appose worker for running Python scripts.
|
32
|
+
|
33
|
+
Like all Appose workers, this program conforms to the Appose worker process
|
34
|
+
contract, meaning it accepts requests on stdin and produces responses on
|
35
|
+
stdout, both formatted according to Appose's assumptions.
|
36
|
+
|
37
|
+
For details, see the Appose README:
|
38
|
+
https://github.com/apposed/appose/blob/-/README.md#workers
|
39
|
+
"""
|
40
|
+
|
41
|
+
import ast
|
42
|
+
import sys
|
43
|
+
import traceback
|
44
|
+
from threading import Thread
|
45
|
+
from time import sleep
|
46
|
+
from typing import Any
|
47
|
+
|
48
|
+
# NB: Avoid relative imports so that this script can be run standalone.
|
49
|
+
from appose.service import RequestType, ResponseType
|
50
|
+
from appose.types import Args, _set_worker, decode, encode
|
51
|
+
|
52
|
+
|
53
|
+
class Task:
|
54
|
+
def __init__(self, uuid: str, script: str, inputs: Args | None = None) -> None:
|
55
|
+
self._uuid = uuid
|
56
|
+
self._script = script
|
57
|
+
self._inputs = inputs
|
58
|
+
self._finished = False
|
59
|
+
self._thread = None
|
60
|
+
|
61
|
+
# Public-facing fields for use within the task script.
|
62
|
+
self.outputs = {}
|
63
|
+
self.cancel_requested = False
|
64
|
+
|
65
|
+
def update(
|
66
|
+
self,
|
67
|
+
message: str | None = None,
|
68
|
+
current: int | None = None,
|
69
|
+
maximum: int | None = None,
|
70
|
+
info: dict[str, Any] | None = None,
|
71
|
+
) -> None:
|
72
|
+
args = {}
|
73
|
+
if message is not None:
|
74
|
+
args["message"] = str(message)
|
75
|
+
if current is not None:
|
76
|
+
try:
|
77
|
+
args["current"] = int(current)
|
78
|
+
except ValueError:
|
79
|
+
pass
|
80
|
+
if maximum is not None:
|
81
|
+
try:
|
82
|
+
args["maximum"] = int(maximum)
|
83
|
+
except ValueError:
|
84
|
+
pass
|
85
|
+
args["info"] = info
|
86
|
+
self._respond(ResponseType.UPDATE, args)
|
87
|
+
|
88
|
+
def cancel(self) -> None:
|
89
|
+
self._respond(ResponseType.CANCELATION, None)
|
90
|
+
|
91
|
+
def fail(self, error: str | None = None) -> None:
|
92
|
+
args = None if error is None else {"error": error}
|
93
|
+
self._respond(ResponseType.FAILURE, args)
|
94
|
+
|
95
|
+
def _run(self) -> None:
|
96
|
+
try:
|
97
|
+
# Populate script bindings.
|
98
|
+
binding = {"task": self}
|
99
|
+
if self._inputs is not None:
|
100
|
+
binding.update(self._inputs)
|
101
|
+
|
102
|
+
# Inform the calling process that the script is launching.
|
103
|
+
self._report_launch()
|
104
|
+
|
105
|
+
# Execute the script.
|
106
|
+
# result = exec(script, locals=binding)
|
107
|
+
result = None
|
108
|
+
|
109
|
+
# NB: Execute the block, except for the last statement,
|
110
|
+
# which we evaluate instead to get its return value.
|
111
|
+
# Credit: https://stackoverflow.com/a/39381428/1207769
|
112
|
+
|
113
|
+
block = ast.parse(self._script, mode="exec")
|
114
|
+
last = None
|
115
|
+
if (
|
116
|
+
len(block.body) > 0
|
117
|
+
and hasattr(block.body[-1], "value")
|
118
|
+
and not isinstance(block.body[-1], ast.Assign)
|
119
|
+
):
|
120
|
+
# Last statement of the script looks like an expression. Evaluate!
|
121
|
+
last = ast.Expression(block.body.pop().value)
|
122
|
+
|
123
|
+
# NB: When `exec` gets two separate objects as *globals* and
|
124
|
+
# *locals*, the code will be executed as if it were embedded in
|
125
|
+
# a class definition. This means functions and classes defined
|
126
|
+
# in the executed code will not be able to access variables
|
127
|
+
# assigned at the top level, because the "top level" variables
|
128
|
+
# are treated as class variables in a class definition.
|
129
|
+
# See: https://docs.python.org/3/library/functions.html#exec
|
130
|
+
_globals = binding
|
131
|
+
exec(compile(block, "<string>", mode="exec"), _globals, binding)
|
132
|
+
if last is not None:
|
133
|
+
result = eval(compile(last, "<string>", mode="eval"), _globals, binding)
|
134
|
+
|
135
|
+
# Report the results to the Appose calling process.
|
136
|
+
if isinstance(result, dict):
|
137
|
+
# Script produced a dict; add all entries to the outputs.
|
138
|
+
self.outputs.update(result)
|
139
|
+
elif result is not None:
|
140
|
+
# Script produced a non-dict; add it alone to the outputs.
|
141
|
+
self.outputs["result"] = result
|
142
|
+
self._report_completion()
|
143
|
+
except Exception:
|
144
|
+
self.fail(traceback.format_exc())
|
145
|
+
|
146
|
+
def _report_launch(self) -> None:
|
147
|
+
self._respond(ResponseType.LAUNCH, None)
|
148
|
+
|
149
|
+
def _report_completion(self) -> None:
|
150
|
+
args = None if self.outputs is None else {"outputs": self.outputs}
|
151
|
+
self._respond(ResponseType.COMPLETION, args)
|
152
|
+
|
153
|
+
def _respond(self, response_type: ResponseType, args: Args | None) -> None:
|
154
|
+
already_terminated = False
|
155
|
+
if response_type.is_terminal():
|
156
|
+
if self._finished:
|
157
|
+
# This is not the first terminal response. Let's
|
158
|
+
# remember, in case an exception is generated below,
|
159
|
+
# so that we can avoid infinite recursion loops.
|
160
|
+
already_terminated = True
|
161
|
+
self._finished = True
|
162
|
+
|
163
|
+
response = {}
|
164
|
+
if args is not None:
|
165
|
+
response.update(args)
|
166
|
+
response.update({"task": self._uuid, "responseType": response_type.value})
|
167
|
+
# NB: Flush is necessary to ensure service receives the data!
|
168
|
+
try:
|
169
|
+
print(encode(response), flush=True)
|
170
|
+
except Exception:
|
171
|
+
if already_terminated:
|
172
|
+
# An exception triggered a failure response which
|
173
|
+
# then triggered another exception. Let's stop here
|
174
|
+
# to avoid the risk of infinite recursion loops.
|
175
|
+
return
|
176
|
+
# Encoding can fail due to unsupported types, when the
|
177
|
+
# response or its elements are not supported by JSON encoding.
|
178
|
+
# No matter what goes wrong, we want to tell the caller.
|
179
|
+
self.fail(traceback.format_exc())
|
180
|
+
|
181
|
+
|
182
|
+
class Worker:
|
183
|
+
|
184
|
+
def __init__(self):
|
185
|
+
self.tasks = {}
|
186
|
+
self.queue: list[Task] = []
|
187
|
+
self.running = True
|
188
|
+
|
189
|
+
# Flag this process as a worker, not a service.
|
190
|
+
_set_worker(True)
|
191
|
+
|
192
|
+
Thread(target=self._process_input, name="Appose-Receiver").start()
|
193
|
+
Thread(target=self._cleanup_threads, name="Appose-Janitor").start()
|
194
|
+
|
195
|
+
def run(self) -> None:
|
196
|
+
"""
|
197
|
+
Processes tasks from the task queue.
|
198
|
+
"""
|
199
|
+
while self.running:
|
200
|
+
if len(self.queue) == 0:
|
201
|
+
# Nothing queued, so wait a bit.
|
202
|
+
sleep(0.05)
|
203
|
+
continue
|
204
|
+
task = self.queue.pop()
|
205
|
+
task._run()
|
206
|
+
|
207
|
+
def _process_input(self) -> None:
|
208
|
+
while True:
|
209
|
+
try:
|
210
|
+
line = input().strip()
|
211
|
+
except EOFError:
|
212
|
+
line = None
|
213
|
+
if not line:
|
214
|
+
self.running = False
|
215
|
+
return
|
216
|
+
|
217
|
+
request = decode(line)
|
218
|
+
uuid = request.get("task")
|
219
|
+
request_type = request.get("requestType")
|
220
|
+
|
221
|
+
match RequestType(request_type):
|
222
|
+
case RequestType.EXECUTE:
|
223
|
+
script = request.get("script")
|
224
|
+
inputs = request.get("inputs")
|
225
|
+
queue = request.get("queue")
|
226
|
+
task = Task(uuid, script, inputs)
|
227
|
+
self.tasks[uuid] = task
|
228
|
+
if queue == "main":
|
229
|
+
# Add the task to the main thread queue.
|
230
|
+
self.queue.append(task)
|
231
|
+
else:
|
232
|
+
# Create a thread and save a reference to it, in case its script
|
233
|
+
# kills the thread. This happens e.g. if it calls sys.exit.
|
234
|
+
task._thread = Thread(target=task._run, name=f"Appose-{uuid}")
|
235
|
+
task._thread.start()
|
236
|
+
|
237
|
+
case RequestType.CANCEL:
|
238
|
+
task = self.tasks.get(uuid)
|
239
|
+
if task is None:
|
240
|
+
print(f"No such task: {uuid}", file=sys.stderr)
|
241
|
+
continue
|
242
|
+
task.cancel_requested = True
|
243
|
+
|
244
|
+
def _cleanup_threads(self) -> None:
|
245
|
+
while self.running:
|
246
|
+
sleep(0.05)
|
247
|
+
dead = {
|
248
|
+
uuid: task
|
249
|
+
for uuid, task in self.tasks.items()
|
250
|
+
if task._thread is not None and not task._thread.is_alive()
|
251
|
+
}
|
252
|
+
for uuid, task in dead.items():
|
253
|
+
self.tasks.pop(uuid)
|
254
|
+
if not task._finished:
|
255
|
+
# The task died before reporting a terminal status.
|
256
|
+
# We report this situation as failure by thread death.
|
257
|
+
task.fail("thread death")
|
258
|
+
|
259
|
+
|
260
|
+
def main() -> None:
|
261
|
+
Worker().run()
|
262
|
+
|
263
|
+
|
264
|
+
if __name__ == "__main__":
|
265
|
+
main()
|
@@ -2,7 +2,7 @@
|
|
2
2
|
# #%L
|
3
3
|
# Appose: multi-language interprocess cooperation with shared memory.
|
4
4
|
# %%
|
5
|
-
# Copyright (C) 2023 -
|
5
|
+
# Copyright (C) 2023 - 2025 Appose developers.
|
6
6
|
# %%
|
7
7
|
# Redistribution and use in source and binary forms, with or without
|
8
8
|
# modification, are permitted provided that the following conditions are met:
|
@@ -36,7 +36,7 @@ import threading
|
|
36
36
|
from enum import Enum
|
37
37
|
from pathlib import Path
|
38
38
|
from traceback import format_exc
|
39
|
-
from typing import Any, Callable
|
39
|
+
from typing import Any, Callable
|
40
40
|
from uuid import uuid4
|
41
41
|
|
42
42
|
from .types import Args, decode, encode
|
@@ -52,17 +52,17 @@ class Service:
|
|
52
52
|
|
53
53
|
_service_count = 0
|
54
54
|
|
55
|
-
def __init__(self, cwd:
|
55
|
+
def __init__(self, cwd: str | Path, args: list[str]) -> None:
|
56
56
|
self._cwd = cwd
|
57
57
|
self._args = args[:]
|
58
|
-
self._tasks:
|
58
|
+
self._tasks: dict[str, "Task"] = {}
|
59
59
|
self._service_id = Service._service_count
|
60
60
|
Service._service_count += 1
|
61
|
-
self._process:
|
62
|
-
self._stdout_thread:
|
63
|
-
self._stderr_thread:
|
64
|
-
self._monitor_thread:
|
65
|
-
self._debug_callback:
|
61
|
+
self._process: subprocess.Popen | None = None
|
62
|
+
self._stdout_thread: threading.Thread | None = None
|
63
|
+
self._stderr_thread: threading.Thread | None = None
|
64
|
+
self._monitor_thread: threading.Thread | None = None
|
65
|
+
self._debug_callback: Callable[[Any], Any] | None = None
|
66
66
|
|
67
67
|
def debug(self, debug_callback: Callable[[Any], Any]) -> None:
|
68
68
|
"""
|
@@ -109,16 +109,20 @@ class Service:
|
|
109
109
|
self._stderr_thread.start()
|
110
110
|
self._monitor_thread.start()
|
111
111
|
|
112
|
-
def task(
|
112
|
+
def task(
|
113
|
+
self, script: str, inputs: Args | None = None, queue: str | None = None
|
114
|
+
) -> "Task":
|
113
115
|
"""
|
114
116
|
Create a new task, passing the given script to the worker for execution.
|
115
117
|
:param script:
|
116
118
|
The script for the worker to execute in its environment.
|
117
119
|
:param inputs:
|
118
120
|
Optional list of key/value pairs to feed into the script as inputs.
|
121
|
+
:param queue:
|
122
|
+
Optional queue target. Pass "main" to queue to worker's main thread.
|
119
123
|
"""
|
120
124
|
self.start()
|
121
|
-
return Task(self, script, inputs)
|
125
|
+
return Task(self, script, inputs, queue)
|
122
126
|
|
123
127
|
def close(self) -> None:
|
124
128
|
"""
|
@@ -256,11 +260,35 @@ class ResponseType(Enum):
|
|
256
260
|
FAILURE = "FAILURE"
|
257
261
|
CRASH = "CRASH"
|
258
262
|
|
263
|
+
"""
|
264
|
+
True iff response type is COMPLETE, CANCELED, FAILED, or CRASHED.
|
265
|
+
"""
|
266
|
+
|
267
|
+
def is_terminal(self):
|
268
|
+
return self in (
|
269
|
+
ResponseType.COMPLETION,
|
270
|
+
ResponseType.CANCELATION,
|
271
|
+
ResponseType.FAILURE,
|
272
|
+
ResponseType.CRASH,
|
273
|
+
)
|
274
|
+
|
259
275
|
|
260
276
|
class TaskEvent:
|
261
|
-
def __init__(
|
277
|
+
def __init__(
|
278
|
+
self,
|
279
|
+
task: "Task",
|
280
|
+
response_type: ResponseType,
|
281
|
+
message: str | None = None,
|
282
|
+
current: int | None = None,
|
283
|
+
maximum: int | None = None,
|
284
|
+
info: dict[str, Any] | None = None,
|
285
|
+
) -> None:
|
262
286
|
self.task: "Task" = task
|
263
287
|
self.response_type: ResponseType = response_type
|
288
|
+
self.message: str | None = message
|
289
|
+
self.current: int | None = current
|
290
|
+
self.maximum: int | None = maximum
|
291
|
+
self.info: dict[str, Any] | None = info
|
264
292
|
|
265
293
|
def __str__(self):
|
266
294
|
return f"[{self.response_type}] {self.task}"
|
@@ -274,21 +302,26 @@ class Task:
|
|
274
302
|
"""
|
275
303
|
|
276
304
|
def __init__(
|
277
|
-
self,
|
305
|
+
self,
|
306
|
+
service: Service,
|
307
|
+
script: str,
|
308
|
+
inputs: Args | None = None,
|
309
|
+
queue: str | None = None,
|
278
310
|
) -> None:
|
279
311
|
self.uuid = uuid4().hex
|
280
312
|
self.service = service
|
281
313
|
self.script = script
|
282
314
|
self.inputs: Args = {}
|
315
|
+
self.queue: str | None = queue
|
283
316
|
if inputs is not None:
|
284
317
|
self.inputs.update(inputs)
|
285
318
|
self.outputs: Args = {}
|
286
319
|
self.status: TaskStatus = TaskStatus.INITIAL
|
287
|
-
self.message:
|
320
|
+
self.message: str | None = None
|
288
321
|
self.current: int = 0
|
289
322
|
self.maximum: int = 1
|
290
|
-
self.error:
|
291
|
-
self.listeners:
|
323
|
+
self.error: str | None = None
|
324
|
+
self.listeners: list[Callable[["TaskEvent"], None]] = []
|
292
325
|
self.cv = threading.Condition()
|
293
326
|
self.service._tasks[self.uuid] = self
|
294
327
|
|
@@ -299,7 +332,7 @@ class Task:
|
|
299
332
|
|
300
333
|
self.status = TaskStatus.QUEUED
|
301
334
|
|
302
|
-
args = {"script": self.script, "inputs": self.inputs}
|
335
|
+
args = {"script": self.script, "inputs": self.inputs, "queue": self.queue}
|
303
336
|
self._request(RequestType.EXECUTE, args)
|
304
337
|
|
305
338
|
return self
|
@@ -354,13 +387,8 @@ class Task:
|
|
354
387
|
case ResponseType.LAUNCH:
|
355
388
|
self.status = TaskStatus.RUNNING
|
356
389
|
case ResponseType.UPDATE:
|
357
|
-
|
358
|
-
|
359
|
-
maximum = response.get("maximum")
|
360
|
-
if current is not None:
|
361
|
-
self.current = int(current)
|
362
|
-
if maximum is not None:
|
363
|
-
self.maximum = int(maximum)
|
390
|
+
# No extra action needed.
|
391
|
+
pass
|
364
392
|
case ResponseType.COMPLETION:
|
365
393
|
self.service._tasks.pop(self.uuid, None)
|
366
394
|
self.status = TaskStatus.COMPLETE
|
@@ -380,7 +408,11 @@ class Task:
|
|
380
408
|
)
|
381
409
|
return
|
382
410
|
|
383
|
-
|
411
|
+
message = response.get("message")
|
412
|
+
current = response.get("current")
|
413
|
+
maximum = response.get("maximum")
|
414
|
+
info = response.get("info")
|
415
|
+
event = TaskEvent(self, response_type, message, current, maximum, info)
|
384
416
|
for listener in self.listeners:
|
385
417
|
listener(event)
|
386
418
|
|
@@ -397,7 +429,4 @@ class Task:
|
|
397
429
|
self.cv.notify_all()
|
398
430
|
|
399
431
|
def __str__(self):
|
400
|
-
return
|
401
|
-
f"{self.uuid=}, {self.status=}, {self.message=}, "
|
402
|
-
f"{self.current=}, {self.maximum=}, {self.error=}"
|
403
|
-
)
|
432
|
+
return f"{self.uuid=}, {self.status=}, {self.error=}"
|
@@ -2,7 +2,7 @@
|
|
2
2
|
# #%L
|
3
3
|
# Appose: multi-language interprocess cooperation with shared memory.
|
4
4
|
# %%
|
5
|
-
# Copyright (C) 2023 -
|
5
|
+
# Copyright (C) 2023 - 2025 Appose developers.
|
6
6
|
# %%
|
7
7
|
# Redistribution and use in source and binary forms, with or without
|
8
8
|
# modification, are permitted provided that the following conditions are met:
|
@@ -45,13 +45,29 @@ class SharedMemory(shared_memory.SharedMemory):
|
|
45
45
|
`unlink_on_dispose` flag.
|
46
46
|
"""
|
47
47
|
|
48
|
-
def __init__(self, name: str = None, create: bool = False,
|
49
|
-
|
48
|
+
def __init__(self, name: str = None, create: bool = False, rsize: int = 0):
|
49
|
+
"""
|
50
|
+
Create a new shared memory block, or attach to an existing one.
|
51
|
+
|
52
|
+
:param name:
|
53
|
+
The unique name for the requested shared memory, specified as a
|
54
|
+
string. If create is True (i.e. a new shared memory block) and
|
55
|
+
no name is given, a novel name will be generated.
|
56
|
+
:param create:
|
57
|
+
Whether a new shared memory block is created (True)
|
58
|
+
or an existing one is attached to (False).
|
59
|
+
:param rsize:
|
60
|
+
Requested size in bytes. The true allocated size will be at least
|
61
|
+
this much, but may be rounded up to the next block size multiple,
|
62
|
+
depending on the running platform.
|
63
|
+
"""
|
64
|
+
super().__init__(name=name, create=create, size=rsize)
|
65
|
+
self.rsize = rsize
|
50
66
|
self._unlink_on_dispose = create
|
51
67
|
if _is_worker:
|
52
68
|
# HACK: Remove this shared memory block from the resource_tracker,
|
53
|
-
# which
|
54
|
-
# references are done using them.
|
69
|
+
# which would otherwise want to clean up shared memory blocks
|
70
|
+
# after all known references are done using them.
|
55
71
|
#
|
56
72
|
# There is one resource_tracker per Python process, and they will
|
57
73
|
# each try to delete shared memory blocks known to them when they
|
@@ -60,7 +76,37 @@ class SharedMemory(shared_memory.SharedMemory):
|
|
60
76
|
# As such, the rule Appose follows is: let the service process
|
61
77
|
# always handle cleanup of shared memory blocks, regardless of
|
62
78
|
# which process initially allocated it.
|
63
|
-
|
79
|
+
try:
|
80
|
+
resource_tracker.unregister(self._name, "shared_memory")
|
81
|
+
except ModuleNotFoundError:
|
82
|
+
# Unfortunately, on (some?) Windows systems, we see the error:
|
83
|
+
#
|
84
|
+
# Traceback (most recent call last): # noqa: E501
|
85
|
+
# File "...\site-packages\appose\types.py", line 97, in decode # noqa: E501
|
86
|
+
# return json.loads(the_json, object_hook=_appose_object_hook) # noqa: E501
|
87
|
+
# File "...\lib\json\__init__.py", line 359, in loads # noqa: E501
|
88
|
+
# return cls(**kw).decode(s) # noqa: E501
|
89
|
+
# File "...\lib\json\decoder.py", line 337, in decode # noqa: E501
|
90
|
+
# obj, end = self.raw_decode(s, idx=_w(s, 0).end()) # noqa: E501
|
91
|
+
# File "...\lib\json\decoder.py", line 353, in raw_decode # noqa: E501
|
92
|
+
# obj, end = self.scan_once(s, idx) # noqa: E501
|
93
|
+
# File "...\site-packages\appose\types.py", line 177, in _appose_object_hook # noqa: E501
|
94
|
+
# return SharedMemory(name=(obj["name"]), size=(obj["size"])) # noqa: E501
|
95
|
+
# File "...\site-packages\appose\types.py", line 63, in __init__ # noqa: E501
|
96
|
+
# resource_tracker.unregister(self._name, "shared_memory") # noqa: E501
|
97
|
+
# File "...\lib\multiprocessing\resource_tracker.py", line 159, in unregister # noqa: E501
|
98
|
+
# self._send('UNREGISTER', name, rtype) # noqa: E501
|
99
|
+
# File "...\lib\multiprocessing\resource_tracker.py", line 162, in _send # noqa: E501
|
100
|
+
# self.ensure_running() # noqa: E501
|
101
|
+
# File "...\lib\multiprocessing\resource_tracker.py", line 129, in ensure_running # noqa: E501
|
102
|
+
# pid = util.spawnv_passfds(exe, args, fds_to_pass) # noqa: E501
|
103
|
+
# File "...\lib\multiprocessing\util.py", line 448, in spawnv_passfds # noqa: E501
|
104
|
+
# import _posixsubprocess # noqa: E501
|
105
|
+
# ModuleNotFoundError: No module named '_posixsubprocess' # noqa: E501
|
106
|
+
#
|
107
|
+
# A bug in Python? Regardless: we guard against it here.
|
108
|
+
# See also: https://github.com/imglib/imglib2-appose/issues/1
|
109
|
+
pass
|
64
110
|
|
65
111
|
def unlink_on_dispose(self, value: bool) -> None:
|
66
112
|
"""
|
@@ -116,7 +162,7 @@ class NDArray:
|
|
116
162
|
self.shape = shape
|
117
163
|
self.shm = (
|
118
164
|
SharedMemory(
|
119
|
-
create=True,
|
165
|
+
create=True, rsize=ceil(prod(shape) * _bytes_per_element(dtype))
|
120
166
|
)
|
121
167
|
if shm is None
|
122
168
|
else shm
|
@@ -127,7 +173,7 @@ class NDArray:
|
|
127
173
|
f"NDArray("
|
128
174
|
f"dtype='{self.dtype}', "
|
129
175
|
f"shape={self.shape}, "
|
130
|
-
f"shm='{self.shm.name}' ({self.shm.
|
176
|
+
f"shm='{self.shm.name}' ({self.shm.rsize}))"
|
131
177
|
)
|
132
178
|
|
133
179
|
def ndarray(self):
|
@@ -158,7 +204,7 @@ class _ApposeJSONEncoder(json.JSONEncoder):
|
|
158
204
|
return {
|
159
205
|
"appose_type": "shm",
|
160
206
|
"name": obj.name,
|
161
|
-
"
|
207
|
+
"rsize": obj.rsize,
|
162
208
|
}
|
163
209
|
if isinstance(obj, NDArray):
|
164
210
|
return {
|
@@ -174,7 +220,7 @@ def _appose_object_hook(obj: Dict):
|
|
174
220
|
atype = obj.get("appose_type")
|
175
221
|
if atype == "shm":
|
176
222
|
# Attach to existing shared memory block.
|
177
|
-
return SharedMemory(name=(obj["name"]),
|
223
|
+
return SharedMemory(name=(obj["name"]), rsize=(obj["rsize"]))
|
178
224
|
elif atype == "ndarray":
|
179
225
|
return NDArray(obj["dtype"], obj["shape"], obj["shm"])
|
180
226
|
else:
|
@@ -1,9 +1,9 @@
|
|
1
|
-
Metadata-Version: 2.
|
1
|
+
Metadata-Version: 2.4
|
2
2
|
Name: appose
|
3
|
-
Version: 0.
|
3
|
+
Version: 0.6.0
|
4
4
|
Summary: Appose: multi-language interprocess cooperation with shared memory.
|
5
5
|
Author: Appose developers
|
6
|
-
License:
|
6
|
+
License-Expression: BSD-2-Clause
|
7
7
|
Project-URL: homepage, https://github.com/apposed/appose-python
|
8
8
|
Project-URL: documentation, https://github.com/apposed/appose-python/blob/main/README.md
|
9
9
|
Project-URL: source, https://github.com/apposed/appose-python
|
@@ -17,7 +17,7 @@ Classifier: Intended Audience :: Science/Research
|
|
17
17
|
Classifier: Programming Language :: Python :: 3 :: Only
|
18
18
|
Classifier: Programming Language :: Python :: 3.10
|
19
19
|
Classifier: Programming Language :: Python :: 3.11
|
20
|
-
Classifier:
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
21
21
|
Classifier: Operating System :: Microsoft :: Windows
|
22
22
|
Classifier: Operating System :: Unix
|
23
23
|
Classifier: Operating System :: MacOS
|
@@ -40,6 +40,7 @@ Requires-Dist: pytest; extra == "dev"
|
|
40
40
|
Requires-Dist: numpy; extra == "dev"
|
41
41
|
Requires-Dist: toml; extra == "dev"
|
42
42
|
Requires-Dist: validate-pyproject[all]; extra == "dev"
|
43
|
+
Dynamic: license-file
|
43
44
|
|
44
45
|
# Appose Python
|
45
46
|
|
@@ -2,7 +2,7 @@
|
|
2
2
|
# #%L
|
3
3
|
# Appose: multi-language interprocess cooperation with shared memory.
|
4
4
|
# %%
|
5
|
-
# Copyright (C) 2023 -
|
5
|
+
# Copyright (C) 2023 - 2025 Appose developers.
|
6
6
|
# %%
|
7
7
|
# Redistribution and use in source and binary forms, with or without
|
8
8
|
# modification, are permitted provided that the following conditions are met:
|
@@ -27,6 +27,8 @@
|
|
27
27
|
# #L%
|
28
28
|
###
|
29
29
|
|
30
|
+
import os
|
31
|
+
|
30
32
|
import appose
|
31
33
|
from appose.service import ResponseType, Service, TaskStatus
|
32
34
|
|
@@ -55,18 +57,36 @@ while v != 1:
|
|
55
57
|
task.outputs["result"] = time
|
56
58
|
"""
|
57
59
|
|
60
|
+
sqrt_import = """
|
61
|
+
from math import sqrt
|
62
|
+
def sqrt_age(age):
|
63
|
+
return sqrt(age)
|
64
|
+
task.outputs["result"] = sqrt_age(age)
|
65
|
+
"""
|
66
|
+
|
67
|
+
main_thread_check_groovy = """
|
68
|
+
task.outputs["thread"] = Thread.currentThread().getName()
|
69
|
+
"""
|
70
|
+
|
71
|
+
main_thread_check_python = """
|
72
|
+
import threading
|
73
|
+
task.outputs["thread"] = threading.current_thread().name
|
74
|
+
"""
|
75
|
+
|
58
76
|
|
59
77
|
def test_groovy():
|
60
78
|
env = appose.system()
|
61
79
|
# NB: For now, use bin/test.sh to copy the needed JARs.
|
62
80
|
class_path = ["target/dependency/*"]
|
63
81
|
with env.groovy(class_path=class_path) as service:
|
82
|
+
maybe_debug(service)
|
64
83
|
execute_and_assert(service, collatz_groovy)
|
65
84
|
|
66
85
|
|
67
86
|
def test_python():
|
68
87
|
env = appose.system()
|
69
88
|
with env.python() as service:
|
89
|
+
maybe_debug(service)
|
70
90
|
execute_and_assert(service, collatz_python)
|
71
91
|
|
72
92
|
|
@@ -83,6 +103,49 @@ def test_service_startup_failure():
|
|
83
103
|
) == str(e)
|
84
104
|
|
85
105
|
|
106
|
+
def test_scope():
|
107
|
+
env = appose.system()
|
108
|
+
with env.python() as service:
|
109
|
+
maybe_debug(service)
|
110
|
+
task = service.task(sqrt_import, {"age": 100})
|
111
|
+
task.start()
|
112
|
+
task.wait_for()
|
113
|
+
result = round(task.outputs.get("result"))
|
114
|
+
assert result == 10
|
115
|
+
|
116
|
+
|
117
|
+
def test_main_thread_queue_groovy():
|
118
|
+
env = appose.system()
|
119
|
+
# NB: For now, use bin/test.sh to copy the needed JARs.
|
120
|
+
class_path = ["target/dependency/*"]
|
121
|
+
with env.groovy(class_path=class_path) as service:
|
122
|
+
maybe_debug(service)
|
123
|
+
|
124
|
+
task = service.task(main_thread_check_groovy, queue="main")
|
125
|
+
task.wait_for()
|
126
|
+
thread = task.outputs.get("thread")
|
127
|
+
assert thread == "main"
|
128
|
+
|
129
|
+
task = service.task(main_thread_check_groovy)
|
130
|
+
task.wait_for()
|
131
|
+
thread = task.outputs.get("thread")
|
132
|
+
assert thread != "main"
|
133
|
+
|
134
|
+
|
135
|
+
def test_main_thread_queue_python():
|
136
|
+
env = appose.system()
|
137
|
+
with env.python() as service:
|
138
|
+
task = service.task(main_thread_check_python, queue="main")
|
139
|
+
task.wait_for()
|
140
|
+
thread = task.outputs.get("thread")
|
141
|
+
assert thread == "MainThread"
|
142
|
+
|
143
|
+
task = service.task(main_thread_check_python)
|
144
|
+
task.wait_for()
|
145
|
+
thread = task.outputs.get("thread")
|
146
|
+
assert thread != "MainThread"
|
147
|
+
|
148
|
+
|
86
149
|
def execute_and_assert(service: Service, script: str):
|
87
150
|
task = service.task(script)
|
88
151
|
|
@@ -91,10 +154,10 @@ def execute_and_assert(service: Service, script: str):
|
|
91
154
|
class TaskState:
|
92
155
|
def __init__(self, event):
|
93
156
|
self.response_type = event.response_type
|
157
|
+
self.message = event.message
|
158
|
+
self.current = event.current
|
159
|
+
self.maximum = event.maximum
|
94
160
|
self.status = event.task.status
|
95
|
-
self.message = event.task.message
|
96
|
-
self.current = event.task.current
|
97
|
-
self.maximum = event.task.maximum
|
98
161
|
self.error = event.task.error
|
99
162
|
|
100
163
|
events = []
|
@@ -116,8 +179,8 @@ def execute_and_assert(service: Service, script: str):
|
|
116
179
|
assert ResponseType.LAUNCH == launch.response_type
|
117
180
|
assert TaskStatus.RUNNING == launch.status
|
118
181
|
assert launch.message is None
|
119
|
-
assert
|
120
|
-
assert
|
182
|
+
assert launch.current is None
|
183
|
+
assert launch.maximum is None
|
121
184
|
assert launch.error is None
|
122
185
|
|
123
186
|
v = 9999
|
@@ -128,13 +191,19 @@ def execute_and_assert(service: Service, script: str):
|
|
128
191
|
assert TaskStatus.RUNNING == update.status
|
129
192
|
assert f"[{i}] -> {v}" == update.message
|
130
193
|
assert i == update.current
|
131
|
-
assert
|
194
|
+
assert update.maximum is None
|
132
195
|
assert update.error is None
|
133
196
|
|
134
197
|
completion = events[92]
|
135
198
|
assert ResponseType.COMPLETION == completion.response_type
|
136
199
|
assert TaskStatus.COMPLETE == completion.status
|
137
|
-
assert
|
138
|
-
assert
|
139
|
-
assert
|
200
|
+
assert completion.message is None # no message from non-UPDATE response
|
201
|
+
assert completion.current is None # no current from non-UPDATE response
|
202
|
+
assert completion.maximum is None # no maximum from non-UPDATE response
|
140
203
|
assert completion.error is None
|
204
|
+
|
205
|
+
|
206
|
+
def maybe_debug(service):
|
207
|
+
debug = os.getenv("DEBUG")
|
208
|
+
if debug:
|
209
|
+
service.debug(print)
|
@@ -2,7 +2,7 @@
|
|
2
2
|
# #%L
|
3
3
|
# Appose: multi-language interprocess cooperation with shared memory.
|
4
4
|
# %%
|
5
|
-
# Copyright (C) 2023 -
|
5
|
+
# Copyright (C) 2023 - 2025 Appose developers.
|
6
6
|
# %%
|
7
7
|
# Redistribution and use in source and binary forms, with or without
|
8
8
|
# modification, are permitted provided that the following conditions are met:
|
@@ -31,6 +31,7 @@ import appose
|
|
31
31
|
from appose.service import TaskStatus
|
32
32
|
|
33
33
|
ndarray_inspect = """
|
34
|
+
task.outputs["rsize"] = data.shm.rsize
|
34
35
|
task.outputs["size"] = data.shm.size
|
35
36
|
task.outputs["dtype"] = data.dtype
|
36
37
|
task.outputs["shape"] = data.shape
|
@@ -41,7 +42,7 @@ task.outputs["sum"] = sum(v for v in data.shm.buf)
|
|
41
42
|
def test_ndarray():
|
42
43
|
env = appose.system()
|
43
44
|
with env.python() as service:
|
44
|
-
with appose.SharedMemory(create=True,
|
45
|
+
with appose.SharedMemory(create=True, rsize=2 * 2 * 20 * 25) as shm:
|
45
46
|
# Construct the data.
|
46
47
|
shm.buf[0] = 123
|
47
48
|
shm.buf[456] = 78
|
@@ -54,7 +55,10 @@ def test_ndarray():
|
|
54
55
|
|
55
56
|
# Validate the execution result.
|
56
57
|
assert TaskStatus.COMPLETE == task.status
|
57
|
-
|
58
|
+
# The requested size is 2*20*25*2=2000, but actual allocated
|
59
|
+
# shm size varies by platform; e.g. on macOS it is 16384.
|
60
|
+
assert 2 * 20 * 25 * 2 == task.outputs["rsize"]
|
61
|
+
assert task.outputs["size"] >= task.outputs["rsize"]
|
58
62
|
assert "uint16" == task.outputs["dtype"]
|
59
63
|
assert [2, 20, 25] == task.outputs["shape"]
|
60
64
|
assert 123 + 78 + 210 == task.outputs["sum"]
|
@@ -2,7 +2,7 @@
|
|
2
2
|
# #%L
|
3
3
|
# Appose: multi-language interprocess cooperation with shared memory.
|
4
4
|
# %%
|
5
|
-
# Copyright (C) 2023 -
|
5
|
+
# Copyright (C) 2023 - 2025 Appose developers.
|
6
6
|
# %%
|
7
7
|
# Redistribution and use in source and binary forms, with or without
|
8
8
|
# modification, are permitted provided that the following conditions are met:
|
@@ -56,7 +56,7 @@ class TypesTest(unittest.TestCase):
|
|
56
56
|
'"shm":{' # noqa: E131
|
57
57
|
'"appose_type":"shm",' # noqa: E131
|
58
58
|
'"name":"SHM_NAME",' # noqa: E131
|
59
|
-
'"
|
59
|
+
'"rsize":4000' # noqa: E131
|
60
60
|
"}" # noqa: E131
|
61
61
|
"}"
|
62
62
|
# fmt: on
|
@@ -103,7 +103,7 @@ class TypesTest(unittest.TestCase):
|
|
103
103
|
self.assertEqual(expected, json_str)
|
104
104
|
|
105
105
|
def test_decode(self):
|
106
|
-
with appose.SharedMemory(create=True,
|
106
|
+
with appose.SharedMemory(create=True, rsize=4000) as shm:
|
107
107
|
shm_name = shm.name
|
108
108
|
data = appose.types.decode(self.JSON.replace("SHM_NAME", shm_name))
|
109
109
|
self.assertIsNotNone(data)
|
@@ -1,198 +0,0 @@
|
|
1
|
-
###
|
2
|
-
# #%L
|
3
|
-
# Appose: multi-language interprocess cooperation with shared memory.
|
4
|
-
# %%
|
5
|
-
# Copyright (C) 2023 - 2024 Appose developers.
|
6
|
-
# %%
|
7
|
-
# Redistribution and use in source and binary forms, with or without
|
8
|
-
# modification, are permitted provided that the following conditions are met:
|
9
|
-
#
|
10
|
-
# 1. Redistributions of source code must retain the above copyright notice,
|
11
|
-
# this list of conditions and the following disclaimer.
|
12
|
-
# 2. Redistributions in binary form must reproduce the above copyright notice,
|
13
|
-
# this list of conditions and the following disclaimer in the documentation
|
14
|
-
# and/or other materials provided with the distribution.
|
15
|
-
#
|
16
|
-
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
17
|
-
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
18
|
-
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
19
|
-
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
|
20
|
-
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
21
|
-
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
22
|
-
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
23
|
-
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
24
|
-
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
25
|
-
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
26
|
-
# POSSIBILITY OF SUCH DAMAGE.
|
27
|
-
# #L%
|
28
|
-
###
|
29
|
-
|
30
|
-
"""
|
31
|
-
The Appose worker for running Python scripts.
|
32
|
-
|
33
|
-
Like all Appose workers, this program conforms to the Appose worker process
|
34
|
-
contract, meaning it accepts requests on stdin and produces responses on
|
35
|
-
stdout, both formatted according to Appose's assumptions.
|
36
|
-
|
37
|
-
For details, see the Appose README:
|
38
|
-
https://github.com/apposed/appose/blob/-/README.md#workers
|
39
|
-
"""
|
40
|
-
|
41
|
-
import ast
|
42
|
-
import sys
|
43
|
-
import traceback
|
44
|
-
from threading import Thread
|
45
|
-
from typing import Optional
|
46
|
-
|
47
|
-
# NB: Avoid relative imports so that this script can be run standalone.
|
48
|
-
from appose.service import RequestType, ResponseType
|
49
|
-
from appose.types import Args, _set_worker, decode, encode
|
50
|
-
|
51
|
-
|
52
|
-
class Task:
|
53
|
-
def __init__(self, uuid: str) -> None:
|
54
|
-
self.uuid = uuid
|
55
|
-
self.outputs = {}
|
56
|
-
self.cancel_requested = False
|
57
|
-
|
58
|
-
def update(
|
59
|
-
self,
|
60
|
-
message: Optional[str] = None,
|
61
|
-
current: Optional[int] = None,
|
62
|
-
maximum: Optional[int] = None,
|
63
|
-
) -> None:
|
64
|
-
args = {}
|
65
|
-
if message is not None:
|
66
|
-
args["message"] = str(message)
|
67
|
-
if current is not None:
|
68
|
-
try:
|
69
|
-
args["current"] = int(current)
|
70
|
-
except ValueError:
|
71
|
-
pass
|
72
|
-
if maximum is not None:
|
73
|
-
try:
|
74
|
-
args["maximum"] = int(maximum)
|
75
|
-
except ValueError:
|
76
|
-
pass
|
77
|
-
self._respond(ResponseType.UPDATE, args)
|
78
|
-
|
79
|
-
def cancel(self) -> None:
|
80
|
-
self._respond(ResponseType.CANCELATION, None)
|
81
|
-
|
82
|
-
def fail(self, error: Optional[str] = None) -> None:
|
83
|
-
args = None if error is None else {"error": error}
|
84
|
-
self._respond(ResponseType.FAILURE, args)
|
85
|
-
|
86
|
-
def _start(self, script: str, inputs: Optional[Args]) -> None:
|
87
|
-
def execute_script():
|
88
|
-
# Populate script bindings.
|
89
|
-
binding = {"task": self}
|
90
|
-
if inputs is not None:
|
91
|
-
binding.update(inputs)
|
92
|
-
|
93
|
-
# Inform the calling process that the script is launching.
|
94
|
-
self._report_launch()
|
95
|
-
|
96
|
-
# Execute the script.
|
97
|
-
# result = exec(script, locals=binding)
|
98
|
-
result = None
|
99
|
-
try:
|
100
|
-
# NB: Execute the block, except for the last statement,
|
101
|
-
# which we evaluate instead to get its return value.
|
102
|
-
# Credit: https://stackoverflow.com/a/39381428/1207769
|
103
|
-
|
104
|
-
block = ast.parse(script, mode="exec")
|
105
|
-
last = None
|
106
|
-
if (
|
107
|
-
len(block.body) > 0
|
108
|
-
and hasattr(block.body[-1], "value")
|
109
|
-
and not isinstance(block.body[-1], ast.Assign)
|
110
|
-
):
|
111
|
-
# Last statement of the script looks like an expression. Evaluate!
|
112
|
-
last = ast.Expression(block.body.pop().value)
|
113
|
-
|
114
|
-
_globals = {}
|
115
|
-
exec(compile(block, "<string>", mode="exec"), _globals, binding)
|
116
|
-
if last is not None:
|
117
|
-
result = eval(
|
118
|
-
compile(last, "<string>", mode="eval"), _globals, binding
|
119
|
-
)
|
120
|
-
except Exception:
|
121
|
-
self.fail(traceback.format_exc())
|
122
|
-
return
|
123
|
-
|
124
|
-
# Report the results to the Appose calling process.
|
125
|
-
if isinstance(result, dict):
|
126
|
-
# Script produced a dict; add all entries to the outputs.
|
127
|
-
self.outputs.update(result)
|
128
|
-
elif result is not None:
|
129
|
-
# Script produced a non-dict; add it alone to the outputs.
|
130
|
-
self.outputs["result"] = result
|
131
|
-
|
132
|
-
self._report_completion()
|
133
|
-
|
134
|
-
# TODO: Consider whether to retain a reference to this Thread, and
|
135
|
-
# expose a "force" option for cancelation that kills it forcibly; see:
|
136
|
-
# https://www.geeksforgeeks.org/python-different-ways-to-kill-a-thread/
|
137
|
-
Thread(target=execute_script, name=f"Appose-{self.uuid}").start()
|
138
|
-
|
139
|
-
def _report_launch(self) -> None:
|
140
|
-
self._respond(ResponseType.LAUNCH, None)
|
141
|
-
|
142
|
-
def _report_completion(self) -> None:
|
143
|
-
args = None if self.outputs is None else {"outputs": self.outputs}
|
144
|
-
self._respond(ResponseType.COMPLETION, args)
|
145
|
-
|
146
|
-
def _respond(self, response_type: ResponseType, args: Optional[Args]) -> None:
|
147
|
-
response = {"task": self.uuid, "responseType": response_type.value}
|
148
|
-
if args is not None:
|
149
|
-
response.update(args)
|
150
|
-
# NB: Flush is necessary to ensure service receives the data!
|
151
|
-
try:
|
152
|
-
print(encode(response), flush=True)
|
153
|
-
except Exception:
|
154
|
-
# Encoding can fail due to unsupported types, when the response
|
155
|
-
# or its elements are not supported by JSON encoding.
|
156
|
-
# No matter what goes wrong, we want to tell the caller.
|
157
|
-
if response_type is ResponseType.FAILURE:
|
158
|
-
# TODO: How to address this hypothetical case
|
159
|
-
# of a failure message triggering another failure?
|
160
|
-
raise
|
161
|
-
self.fail(traceback.format_exc())
|
162
|
-
|
163
|
-
|
164
|
-
def main() -> None:
|
165
|
-
_set_worker(True)
|
166
|
-
|
167
|
-
tasks = {}
|
168
|
-
|
169
|
-
while True:
|
170
|
-
try:
|
171
|
-
line = input().strip()
|
172
|
-
except EOFError:
|
173
|
-
break
|
174
|
-
if not line:
|
175
|
-
break
|
176
|
-
|
177
|
-
request = decode(line)
|
178
|
-
uuid = request.get("task")
|
179
|
-
request_type = request.get("requestType")
|
180
|
-
|
181
|
-
match RequestType(request_type):
|
182
|
-
case RequestType.EXECUTE:
|
183
|
-
script = request.get("script")
|
184
|
-
inputs = request.get("inputs")
|
185
|
-
task = Task(uuid)
|
186
|
-
tasks[uuid] = task
|
187
|
-
task._start(script, inputs)
|
188
|
-
|
189
|
-
case RequestType.CANCEL:
|
190
|
-
task = tasks.get(uuid)
|
191
|
-
if task is None:
|
192
|
-
print(f"No such task: {uuid}", file=sys.stderr)
|
193
|
-
continue
|
194
|
-
task.cancel_requested = True
|
195
|
-
|
196
|
-
|
197
|
-
if __name__ == "__main__":
|
198
|
-
main()
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|