appose 0.1.0__py3-none-any.whl → 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- appose/__init__.py +93 -8
- appose/environment.py +5 -3
- appose/paths.py +2 -2
- appose/python_worker.py +96 -17
- appose/service.py +101 -22
- appose/types.py +206 -4
- {appose-0.1.0.dist-info → appose-0.4.0.dist-info}/METADATA +36 -29
- appose-0.4.0.dist-info/RECORD +11 -0
- {appose-0.1.0.dist-info → appose-0.4.0.dist-info}/WHEEL +1 -1
- {appose-0.1.0.dist-info → appose-0.4.0.dist-info/licenses}/LICENSE.txt +1 -1
- appose-0.1.0.dist-info/RECORD +0 -11
- {appose-0.1.0.dist-info → appose-0.4.0.dist-info}/top_level.txt +0 -0
appose/__init__.py
CHANGED
@@ -2,7 +2,7 @@
|
|
2
2
|
# #%L
|
3
3
|
# Appose: multi-language interprocess cooperation with shared memory.
|
4
4
|
# %%
|
5
|
-
# Copyright (C) 2023 Appose developers.
|
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
|
@@ -52,7 +50,7 @@ Here is a very simple example written in Python:
|
|
52
50
|
Task task = groovy.task("""
|
53
51
|
5 + 6
|
54
52
|
""")
|
55
|
-
task.
|
53
|
+
task.wait_for()
|
56
54
|
result = task.outputs.get("result")
|
57
55
|
assert 11 == result
|
58
56
|
|
@@ -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"]
|
@@ -103,7 +101,7 @@ And here is an example using a few more of Appose's features:
|
|
103
101
|
# Task is taking too long; request a cancelation.
|
104
102
|
task.cancel()
|
105
103
|
|
106
|
-
task.
|
104
|
+
task.wait_for()
|
107
105
|
|
108
106
|
Of course, the above examples could have been done all in Python. But
|
109
107
|
hopefully they hint at the possibilities of easy cross-language integration.
|
@@ -127,13 +125,100 @@ 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
|
135
219
|
|
136
220
|
from .environment import Builder, Environment
|
221
|
+
from .types import NDArray, SharedMemory # noqa: F401
|
137
222
|
|
138
223
|
|
139
224
|
def base(directory: Path) -> Builder:
|
appose/environment.py
CHANGED
@@ -2,7 +2,7 @@
|
|
2
2
|
# #%L
|
3
3
|
# Appose: multi-language interprocess cooperation with shared memory.
|
4
4
|
# %%
|
5
|
-
# Copyright (C) 2023 Appose developers.
|
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:
|
@@ -58,6 +58,7 @@ class Environment:
|
|
58
58
|
"""
|
59
59
|
python_exes = [
|
60
60
|
"python",
|
61
|
+
"python3",
|
61
62
|
"python.exe",
|
62
63
|
"bin/python",
|
63
64
|
"bin/python.exe",
|
@@ -109,8 +110,9 @@ class Environment:
|
|
109
110
|
# TODO: Ensure that the classpath includes Appose and its dependencies.
|
110
111
|
|
111
112
|
# Append any explicitly requested classpath elements.
|
112
|
-
|
113
|
-
|
113
|
+
if class_path is not None:
|
114
|
+
for element in class_path:
|
115
|
+
cp[element] = None
|
114
116
|
|
115
117
|
# Build up the service arguments.
|
116
118
|
args = [
|
appose/paths.py
CHANGED
@@ -2,7 +2,7 @@
|
|
2
2
|
# #%L
|
3
3
|
# Appose: multi-language interprocess cooperation with shared memory.
|
4
4
|
# %%
|
5
|
-
# Copyright (C) 2023 Appose developers.
|
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:
|
@@ -51,6 +51,6 @@ def find_exe(dirs: Sequence[str], exes: Sequence[str]) -> Optional[Path]:
|
|
51
51
|
# Candidate is a relative path; check beneath each given directory.
|
52
52
|
for d in dirs:
|
53
53
|
f = Path(d) / exe
|
54
|
-
if can_execute(f):
|
54
|
+
if can_execute(f) and not f.is_dir():
|
55
55
|
return f
|
56
56
|
return None
|
appose/python_worker.py
CHANGED
@@ -2,7 +2,7 @@
|
|
2
2
|
# #%L
|
3
3
|
# Appose: multi-language interprocess cooperation with shared memory.
|
4
4
|
# %%
|
5
|
-
# Copyright (C) 2023 Appose developers.
|
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:
|
@@ -28,39 +28,57 @@
|
|
28
28
|
###
|
29
29
|
|
30
30
|
"""
|
31
|
-
|
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
|
32
39
|
"""
|
33
40
|
|
34
41
|
import ast
|
35
42
|
import sys
|
36
43
|
import traceback
|
37
44
|
from threading import Thread
|
38
|
-
from
|
45
|
+
from time import sleep
|
46
|
+
from typing import Any, Dict, Optional
|
39
47
|
|
40
48
|
# NB: Avoid relative imports so that this script can be run standalone.
|
41
49
|
from appose.service import RequestType, ResponseType
|
42
|
-
from appose.types import Args, decode, encode
|
50
|
+
from appose.types import Args, _set_worker, decode, encode
|
43
51
|
|
44
52
|
|
45
53
|
class Task:
|
46
54
|
def __init__(self, uuid: str) -> None:
|
47
55
|
self.uuid = uuid
|
48
56
|
self.outputs = {}
|
57
|
+
self.finished = False
|
49
58
|
self.cancel_requested = False
|
59
|
+
self.thread = None # Initialize thread attribute
|
50
60
|
|
51
61
|
def update(
|
52
62
|
self,
|
53
63
|
message: Optional[str] = None,
|
54
64
|
current: Optional[int] = None,
|
55
65
|
maximum: Optional[int] = None,
|
66
|
+
info: Optional[Dict[str, Any]] = None,
|
56
67
|
) -> None:
|
57
68
|
args = {}
|
58
69
|
if message is not None:
|
59
|
-
args["message"] = message
|
70
|
+
args["message"] = str(message)
|
60
71
|
if current is not None:
|
61
|
-
|
72
|
+
try:
|
73
|
+
args["current"] = int(current)
|
74
|
+
except ValueError:
|
75
|
+
pass
|
62
76
|
if maximum is not None:
|
63
|
-
|
77
|
+
try:
|
78
|
+
args["maximum"] = int(maximum)
|
79
|
+
except ValueError:
|
80
|
+
pass
|
81
|
+
args["info"] = info
|
64
82
|
self._respond(ResponseType.UPDATE, args)
|
65
83
|
|
66
84
|
def cancel(self) -> None:
|
@@ -74,7 +92,6 @@ class Task:
|
|
74
92
|
def execute_script():
|
75
93
|
# Populate script bindings.
|
76
94
|
binding = {"task": self}
|
77
|
-
# TODO: Magically convert shared memory image inputs.
|
78
95
|
if inputs is not None:
|
79
96
|
binding.update(inputs)
|
80
97
|
|
@@ -99,7 +116,14 @@ class Task:
|
|
99
116
|
# Last statement of the script looks like an expression. Evaluate!
|
100
117
|
last = ast.Expression(block.body.pop().value)
|
101
118
|
|
102
|
-
|
119
|
+
# NB: When `exec` gets two separate objects as *globals* and
|
120
|
+
# *locals*, the code will be executed as if it were embedded in
|
121
|
+
# a class definition. This means functions and classes defined
|
122
|
+
# in the executed code will not be able to access variables
|
123
|
+
# assigned at the top level, because the "top level" variables
|
124
|
+
# are treated as class variables in a class definition.
|
125
|
+
# See: https://docs.python.org/3/library/functions.html#exec
|
126
|
+
_globals = binding
|
103
127
|
exec(compile(block, "<string>", mode="exec"), _globals, binding)
|
104
128
|
if last is not None:
|
105
129
|
result = eval(
|
@@ -119,10 +143,24 @@ class Task:
|
|
119
143
|
|
120
144
|
self._report_completion()
|
121
145
|
|
122
|
-
#
|
123
|
-
#
|
124
|
-
#
|
125
|
-
|
146
|
+
# HACK: Pre-load toplevel import statements before running the script
|
147
|
+
# as a whole on its own Thread. Why? Because on Windows, some imports
|
148
|
+
# (e.g. numpy) may lead to hangs if loaded from a separate thread.
|
149
|
+
# See https://github.com/apposed/appose/issues/13.
|
150
|
+
block = ast.parse(script, mode="exec")
|
151
|
+
import_nodes = [
|
152
|
+
node
|
153
|
+
for node in block.body
|
154
|
+
if isinstance(node, (ast.Import, ast.ImportFrom))
|
155
|
+
]
|
156
|
+
import_block = ast.Module(body=import_nodes, type_ignores=[])
|
157
|
+
compiled_imports = compile(import_block, filename="<imports>", mode="exec")
|
158
|
+
exec(compiled_imports, globals())
|
159
|
+
|
160
|
+
# Create a thread and save a reference to it, in case its script
|
161
|
+
# ends up killing the thread. This happens e.g. if it calls sys.exit.
|
162
|
+
self.thread = Thread(target=execute_script, name=f"Appose-{self.uuid}")
|
163
|
+
self.thread.start()
|
126
164
|
|
127
165
|
def _report_launch(self) -> None:
|
128
166
|
self._respond(ResponseType.LAUNCH, None)
|
@@ -132,15 +170,56 @@ class Task:
|
|
132
170
|
self._respond(ResponseType.COMPLETION, args)
|
133
171
|
|
134
172
|
def _respond(self, response_type: ResponseType, args: Optional[Args]) -> None:
|
135
|
-
|
173
|
+
already_terminated = False
|
174
|
+
if response_type.is_terminal():
|
175
|
+
if self.finished:
|
176
|
+
# This is not the first terminal response. Let's
|
177
|
+
# remember, in case an exception is generated below,
|
178
|
+
# so that we can avoid infinite recursion loops.
|
179
|
+
already_terminated = True
|
180
|
+
self.finished = True
|
181
|
+
|
182
|
+
response = {}
|
136
183
|
if args is not None:
|
137
184
|
response.update(args)
|
185
|
+
response.update({"task": self.uuid, "responseType": response_type.value})
|
138
186
|
# NB: Flush is necessary to ensure service receives the data!
|
139
|
-
|
187
|
+
try:
|
188
|
+
print(encode(response), flush=True)
|
189
|
+
except Exception:
|
190
|
+
if already_terminated:
|
191
|
+
# An exception triggered a failure response which
|
192
|
+
# then triggered another exception. Let's stop here
|
193
|
+
# to avoid the risk of infinite recursion loops.
|
194
|
+
return
|
195
|
+
# Encoding can fail due to unsupported types, when the
|
196
|
+
# response or its elements are not supported by JSON encoding.
|
197
|
+
# No matter what goes wrong, we want to tell the caller.
|
198
|
+
self.fail(traceback.format_exc())
|
140
199
|
|
141
200
|
|
142
201
|
def main() -> None:
|
202
|
+
_set_worker(True)
|
203
|
+
|
143
204
|
tasks = {}
|
205
|
+
running = True
|
206
|
+
|
207
|
+
def cleanup_threads():
|
208
|
+
while running:
|
209
|
+
sleep(0.05)
|
210
|
+
dead = {
|
211
|
+
uuid: task
|
212
|
+
for uuid, task in tasks.items()
|
213
|
+
if task.thread is not None and not task.thread.is_alive()
|
214
|
+
}
|
215
|
+
for uuid, task in dead.items():
|
216
|
+
tasks.pop(uuid)
|
217
|
+
if not task.finished:
|
218
|
+
# The task died before reporting a terminal status.
|
219
|
+
# We report this situation as failure by thread death.
|
220
|
+
task.fail("thread death")
|
221
|
+
|
222
|
+
Thread(target=cleanup_threads, name="Appose-Janitor").start()
|
144
223
|
|
145
224
|
while True:
|
146
225
|
try:
|
@@ -165,12 +244,12 @@ def main() -> None:
|
|
165
244
|
case RequestType.CANCEL:
|
166
245
|
task = tasks.get(uuid)
|
167
246
|
if task is None:
|
168
|
-
# TODO: proper logging
|
169
|
-
# Maybe should stdout the error back to Appose calling process.
|
170
247
|
print(f"No such task: {uuid}", file=sys.stderr)
|
171
248
|
continue
|
172
249
|
task.cancel_requested = True
|
173
250
|
|
251
|
+
running = False
|
252
|
+
|
174
253
|
|
175
254
|
if __name__ == "__main__":
|
176
255
|
main()
|
appose/service.py
CHANGED
@@ -2,7 +2,7 @@
|
|
2
2
|
# #%L
|
3
3
|
# Appose: multi-language interprocess cooperation with shared memory.
|
4
4
|
# %%
|
5
|
-
# Copyright (C) 2023 Appose developers.
|
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:
|
@@ -61,6 +61,7 @@ class Service:
|
|
61
61
|
self._process: Optional[subprocess.Popen] = None
|
62
62
|
self._stdout_thread: Optional[threading.Thread] = None
|
63
63
|
self._stderr_thread: Optional[threading.Thread] = None
|
64
|
+
self._monitor_thread: Optional[threading.Thread] = None
|
64
65
|
self._debug_callback: Optional[Callable[[Any], Any]] = None
|
65
66
|
|
66
67
|
def debug(self, debug_callback: Callable[[Any], Any]) -> None:
|
@@ -101,8 +102,12 @@ class Service:
|
|
101
102
|
self._stderr_thread = threading.Thread(
|
102
103
|
target=self._stderr_loop, name=f"{prefix}-Stderr"
|
103
104
|
)
|
105
|
+
self._monitor_thread = threading.Thread(
|
106
|
+
target=self._monitor_loop, name=f"{prefix}-Monitor"
|
107
|
+
)
|
104
108
|
self._stdout_thread.start()
|
105
109
|
self._stderr_thread.start()
|
110
|
+
self._monitor_thread.start()
|
106
111
|
|
107
112
|
def task(self, script: str, inputs: Optional[Args] = None) -> "Task":
|
108
113
|
"""
|
@@ -131,15 +136,24 @@ class Service:
|
|
131
136
|
"""
|
132
137
|
Input loop processing lines from the worker's stdout stream.
|
133
138
|
"""
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
+
while True:
|
140
|
+
stdout = self._process.stdout
|
141
|
+
# noinspection PyBroadException
|
142
|
+
try:
|
143
|
+
line = None if stdout is None else stdout.readline()
|
144
|
+
except Exception:
|
145
|
+
# Something went wrong reading the line. Panic!
|
146
|
+
self._debug_service(format_exc())
|
147
|
+
break
|
148
|
+
|
149
|
+
if not line: # readline returns empty string upon EOF
|
150
|
+
self._debug_service("<worker stdout closed>")
|
151
|
+
return
|
139
152
|
|
140
|
-
|
141
|
-
|
153
|
+
# noinspection PyBroadException
|
154
|
+
try:
|
142
155
|
response = decode(line)
|
156
|
+
self._debug_service(line) # Echo the line to the debug listener.
|
143
157
|
uuid = response.get("task")
|
144
158
|
if uuid is None:
|
145
159
|
self._debug_service("Invalid service message: {line}")
|
@@ -150,8 +164,10 @@ class Service:
|
|
150
164
|
continue
|
151
165
|
# noinspection PyProtectedMember
|
152
166
|
task._handle(response)
|
153
|
-
|
154
|
-
|
167
|
+
except Exception:
|
168
|
+
# Something went wrong decoding the line of JSON.
|
169
|
+
# Skip it and keep going, but log it first.
|
170
|
+
self._debug_service(f"<INVALID> {line}")
|
155
171
|
|
156
172
|
def _stderr_loop(self) -> None:
|
157
173
|
"""
|
@@ -160,14 +176,37 @@ class Service:
|
|
160
176
|
# noinspection PyBroadException
|
161
177
|
try:
|
162
178
|
while True:
|
163
|
-
|
164
|
-
if
|
179
|
+
stderr = self._process.stderr
|
180
|
+
line = None if stderr is None else stderr.readline()
|
181
|
+
if not line: # readline returns empty string upon EOF
|
165
182
|
self._debug_service("<worker stderr closed>")
|
166
183
|
return
|
167
184
|
self._debug_worker(line)
|
168
185
|
except Exception:
|
169
186
|
self._debug_service(format_exc())
|
170
187
|
|
188
|
+
def _monitor_loop(self) -> None:
|
189
|
+
# Wait until the worker process terminates.
|
190
|
+
self._process.wait()
|
191
|
+
|
192
|
+
# Do some sanity checks.
|
193
|
+
exit_code = self._process.returncode
|
194
|
+
if exit_code != 0:
|
195
|
+
self._debug_service(
|
196
|
+
f"<worker process terminated with exit code {exit_code}>"
|
197
|
+
)
|
198
|
+
task_count = len(self._tasks)
|
199
|
+
if task_count > 0:
|
200
|
+
self._debug_service(
|
201
|
+
f"<worker process terminated with {task_count} pending tasks>"
|
202
|
+
)
|
203
|
+
|
204
|
+
# Notify any remaining tasks about the process crash.
|
205
|
+
for task in self._tasks.values():
|
206
|
+
task._crash()
|
207
|
+
|
208
|
+
self._tasks.clear()
|
209
|
+
|
171
210
|
def _debug_service(self, message: str) -> None:
|
172
211
|
self._debug("SERVICE", message)
|
173
212
|
|
@@ -190,15 +229,17 @@ class TaskStatus(Enum):
|
|
190
229
|
COMPLETE = "COMPLETE"
|
191
230
|
CANCELED = "CANCELED"
|
192
231
|
FAILED = "FAILED"
|
232
|
+
CRASHED = "CRASHED"
|
193
233
|
|
194
234
|
def is_finished(self):
|
195
235
|
"""
|
196
|
-
True iff status is COMPLETE, CANCELED, or
|
236
|
+
True iff status is COMPLETE, CANCELED, FAILED, or CRASHED.
|
197
237
|
"""
|
198
238
|
return self in (
|
199
239
|
TaskStatus.COMPLETE,
|
200
240
|
TaskStatus.CANCELED,
|
201
241
|
TaskStatus.FAILED,
|
242
|
+
TaskStatus.CRASHED,
|
202
243
|
)
|
203
244
|
|
204
245
|
|
@@ -213,12 +254,40 @@ class ResponseType(Enum):
|
|
213
254
|
COMPLETION = "COMPLETION"
|
214
255
|
CANCELATION = "CANCELATION"
|
215
256
|
FAILURE = "FAILURE"
|
257
|
+
CRASH = "CRASH"
|
258
|
+
|
259
|
+
"""
|
260
|
+
True iff response type is COMPLETE, CANCELED, FAILED, or CRASHED.
|
261
|
+
"""
|
262
|
+
|
263
|
+
def is_terminal(self):
|
264
|
+
return self in (
|
265
|
+
ResponseType.COMPLETION,
|
266
|
+
ResponseType.CANCELATION,
|
267
|
+
ResponseType.FAILURE,
|
268
|
+
ResponseType.CRASH,
|
269
|
+
)
|
216
270
|
|
217
271
|
|
218
272
|
class TaskEvent:
|
219
|
-
def __init__(
|
273
|
+
def __init__(
|
274
|
+
self,
|
275
|
+
task: "Task",
|
276
|
+
response_type: ResponseType,
|
277
|
+
message: Optional[str] = None,
|
278
|
+
current: Optional[int] = None,
|
279
|
+
maximum: Optional[int] = None,
|
280
|
+
info: Optional[Dict[str, Any]] = None,
|
281
|
+
) -> None:
|
220
282
|
self.task: "Task" = task
|
221
283
|
self.response_type: ResponseType = response_type
|
284
|
+
self.message: Optional[str] = message
|
285
|
+
self.current: Optional[int] = current
|
286
|
+
self.maximum: Optional[int] = maximum
|
287
|
+
self.info: Optional[Dict[str, Any]] = info
|
288
|
+
|
289
|
+
def __str__(self):
|
290
|
+
return f"[{self.response_type}] {self.task}"
|
222
291
|
|
223
292
|
|
224
293
|
# noinspection PyProtectedMember
|
@@ -309,13 +378,8 @@ class Task:
|
|
309
378
|
case ResponseType.LAUNCH:
|
310
379
|
self.status = TaskStatus.RUNNING
|
311
380
|
case ResponseType.UPDATE:
|
312
|
-
|
313
|
-
|
314
|
-
maximum = response.get("maximum")
|
315
|
-
if current is not None:
|
316
|
-
self.current = int(current)
|
317
|
-
if maximum is not None:
|
318
|
-
self.maximum = int(maximum)
|
381
|
+
# No extra action needed.
|
382
|
+
pass
|
319
383
|
case ResponseType.COMPLETION:
|
320
384
|
self.service._tasks.pop(self.uuid, None)
|
321
385
|
self.status = TaskStatus.COMPLETE
|
@@ -335,10 +399,25 @@ class Task:
|
|
335
399
|
)
|
336
400
|
return
|
337
401
|
|
338
|
-
|
402
|
+
message = response.get("message")
|
403
|
+
current = response.get("current")
|
404
|
+
maximum = response.get("maximum")
|
405
|
+
info = response.get("info")
|
406
|
+
event = TaskEvent(self, response_type, message, current, maximum, info)
|
339
407
|
for listener in self.listeners:
|
340
408
|
listener(event)
|
341
409
|
|
342
410
|
if self.status.is_finished():
|
343
411
|
with self.cv:
|
344
412
|
self.cv.notify_all()
|
413
|
+
|
414
|
+
def _crash(self):
|
415
|
+
event = TaskEvent(self, ResponseType.CRASH)
|
416
|
+
self.status = TaskStatus.CRASHED
|
417
|
+
for listener in self.listeners:
|
418
|
+
listener(event)
|
419
|
+
with self.cv:
|
420
|
+
self.cv.notify_all()
|
421
|
+
|
422
|
+
def __str__(self):
|
423
|
+
return f"{self.uuid=}, {self.status=}, {self.error=}"
|
appose/types.py
CHANGED
@@ -2,7 +2,7 @@
|
|
2
2
|
# #%L
|
3
3
|
# Appose: multi-language interprocess cooperation with shared memory.
|
4
4
|
# %%
|
5
|
-
# Copyright (C) 2023 Appose developers.
|
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:
|
@@ -28,14 +28,216 @@
|
|
28
28
|
###
|
29
29
|
|
30
30
|
import json
|
31
|
-
|
31
|
+
import re
|
32
|
+
from math import ceil, prod
|
33
|
+
from multiprocessing import resource_tracker, shared_memory
|
34
|
+
from typing import Any, Dict, Sequence, Union
|
32
35
|
|
33
36
|
Args = Dict[str, Any]
|
34
37
|
|
35
38
|
|
39
|
+
class SharedMemory(shared_memory.SharedMemory):
|
40
|
+
"""
|
41
|
+
An enhanced version of Python's multiprocessing.shared_memory.SharedMemory
|
42
|
+
class which can be used with a `with` statement. When the program flow
|
43
|
+
exits the `with` block, this class's `dispose()` method will be invoked,
|
44
|
+
which might call `close()` or `unlink()` depending on the value of its
|
45
|
+
`unlink_on_dispose` flag.
|
46
|
+
"""
|
47
|
+
|
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
|
66
|
+
self._unlink_on_dispose = create
|
67
|
+
if _is_worker:
|
68
|
+
# HACK: Remove this shared memory block from the resource_tracker,
|
69
|
+
# which would otherwise want to clean up shared memory blocks
|
70
|
+
# after all known references are done using them.
|
71
|
+
#
|
72
|
+
# There is one resource_tracker per Python process, and they will
|
73
|
+
# each try to delete shared memory blocks known to them when they
|
74
|
+
# are shutting down, even when other processes still need them.
|
75
|
+
#
|
76
|
+
# As such, the rule Appose follows is: let the service process
|
77
|
+
# always handle cleanup of shared memory blocks, regardless of
|
78
|
+
# which process initially allocated it.
|
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
|
110
|
+
|
111
|
+
def unlink_on_dispose(self, value: bool) -> None:
|
112
|
+
"""
|
113
|
+
Set whether the `unlink()` method should be invoked to destroy
|
114
|
+
the shared memory block when the `dispose()` method is called.
|
115
|
+
|
116
|
+
Note: dispose() is the method called when exiting a `with` block.
|
117
|
+
|
118
|
+
By default, shared memory objects constructed with `create=True`
|
119
|
+
will behave this way, whereas shared memory objects constructed
|
120
|
+
with `create=False` will not. But this method allows to override
|
121
|
+
the behavior.
|
122
|
+
"""
|
123
|
+
self._unlink_on_dispose = value
|
124
|
+
|
125
|
+
def dispose(self) -> None:
|
126
|
+
if self._unlink_on_dispose:
|
127
|
+
self.unlink()
|
128
|
+
else:
|
129
|
+
self.close()
|
130
|
+
|
131
|
+
def __enter__(self) -> "SharedMemory":
|
132
|
+
return self
|
133
|
+
|
134
|
+
def __exit__(self, exc_type, exc_value, exc_tb) -> None:
|
135
|
+
self.dispose()
|
136
|
+
|
137
|
+
|
36
138
|
def encode(data: Args) -> str:
|
37
|
-
return json.dumps(data)
|
139
|
+
return json.dumps(data, cls=_ApposeJSONEncoder, separators=(",", ":"))
|
38
140
|
|
39
141
|
|
40
142
|
def decode(the_json: str) -> Args:
|
41
|
-
return json.loads(the_json)
|
143
|
+
return json.loads(the_json, object_hook=_appose_object_hook)
|
144
|
+
|
145
|
+
|
146
|
+
class NDArray:
|
147
|
+
"""
|
148
|
+
Data structure for a multi-dimensional array.
|
149
|
+
The array contains elements of a data type, arranged in
|
150
|
+
a particular shape, and flattened into SharedMemory.
|
151
|
+
"""
|
152
|
+
|
153
|
+
def __init__(self, dtype: str, shape: Sequence[int], shm: SharedMemory = None):
|
154
|
+
"""
|
155
|
+
Create an NDArray.
|
156
|
+
:param dtype: The type of the data elements; e.g. int8, uint8, float32, float64.
|
157
|
+
:param shape: The dimensional extents; e.g. a stack of 7 image planes
|
158
|
+
with resolution 512x512 would have shape [7, 512, 512].
|
159
|
+
:param shm: The SharedMemory containing the array data, or None to create it.
|
160
|
+
"""
|
161
|
+
self.dtype = dtype
|
162
|
+
self.shape = shape
|
163
|
+
self.shm = (
|
164
|
+
SharedMemory(
|
165
|
+
create=True, rsize=ceil(prod(shape) * _bytes_per_element(dtype))
|
166
|
+
)
|
167
|
+
if shm is None
|
168
|
+
else shm
|
169
|
+
)
|
170
|
+
|
171
|
+
def __str__(self):
|
172
|
+
return (
|
173
|
+
f"NDArray("
|
174
|
+
f"dtype='{self.dtype}', "
|
175
|
+
f"shape={self.shape}, "
|
176
|
+
f"shm='{self.shm.name}' ({self.shm.rsize}))"
|
177
|
+
)
|
178
|
+
|
179
|
+
def ndarray(self):
|
180
|
+
"""
|
181
|
+
Create a NumPy ndarray object for working with the array data.
|
182
|
+
No array data is copied; the NumPy array wraps the same SharedMemory.
|
183
|
+
Requires the numpy package to be installed.
|
184
|
+
"""
|
185
|
+
try:
|
186
|
+
import numpy
|
187
|
+
|
188
|
+
return numpy.ndarray(
|
189
|
+
prod(self.shape), dtype=self.dtype, buffer=self.shm.buf
|
190
|
+
).reshape(self.shape)
|
191
|
+
except ModuleNotFoundError:
|
192
|
+
raise ImportError("NumPy is not available.")
|
193
|
+
|
194
|
+
def __enter__(self) -> "NDArray":
|
195
|
+
return self
|
196
|
+
|
197
|
+
def __exit__(self, exc_type, exc_value, exc_tb) -> None:
|
198
|
+
self.shm.dispose()
|
199
|
+
|
200
|
+
|
201
|
+
class _ApposeJSONEncoder(json.JSONEncoder):
|
202
|
+
def default(self, obj):
|
203
|
+
if isinstance(obj, SharedMemory):
|
204
|
+
return {
|
205
|
+
"appose_type": "shm",
|
206
|
+
"name": obj.name,
|
207
|
+
"rsize": obj.rsize,
|
208
|
+
}
|
209
|
+
if isinstance(obj, NDArray):
|
210
|
+
return {
|
211
|
+
"appose_type": "ndarray",
|
212
|
+
"dtype": obj.dtype,
|
213
|
+
"shape": obj.shape,
|
214
|
+
"shm": obj.shm,
|
215
|
+
}
|
216
|
+
return super().default(obj)
|
217
|
+
|
218
|
+
|
219
|
+
def _appose_object_hook(obj: Dict):
|
220
|
+
atype = obj.get("appose_type")
|
221
|
+
if atype == "shm":
|
222
|
+
# Attach to existing shared memory block.
|
223
|
+
return SharedMemory(name=(obj["name"]), rsize=(obj["rsize"]))
|
224
|
+
elif atype == "ndarray":
|
225
|
+
return NDArray(obj["dtype"], obj["shape"], obj["shm"])
|
226
|
+
else:
|
227
|
+
return obj
|
228
|
+
|
229
|
+
|
230
|
+
def _bytes_per_element(dtype: str) -> Union[int, float]:
|
231
|
+
try:
|
232
|
+
bits = int(re.sub("[^0-9]", "", dtype))
|
233
|
+
except ValueError:
|
234
|
+
raise ValueError(f"Invalid dtype: {dtype}")
|
235
|
+
return bits / 8
|
236
|
+
|
237
|
+
|
238
|
+
_is_worker = False
|
239
|
+
|
240
|
+
|
241
|
+
def _set_worker(value: bool) -> None:
|
242
|
+
global _is_worker
|
243
|
+
_is_worker = value
|
@@ -1,14 +1,14 @@
|
|
1
|
-
Metadata-Version: 2.
|
1
|
+
Metadata-Version: 2.4
|
2
2
|
Name: appose
|
3
|
-
Version: 0.
|
3
|
+
Version: 0.4.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
|
10
10
|
Project-URL: download, https://pypi.org/project/appose-python
|
11
|
-
Project-URL: tracker, https://github.com/apposed/appose
|
11
|
+
Project-URL: tracker, https://github.com/apposed/appose/issues
|
12
12
|
Keywords: java,javascript,python,cross-language,interprocess
|
13
13
|
Classifier: Development Status :: 2 - Pre-Alpha
|
14
14
|
Classifier: Intended Audience :: Developers
|
@@ -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
|
@@ -29,17 +29,18 @@ Requires-Python: >=3.10
|
|
29
29
|
Description-Content-Type: text/markdown
|
30
30
|
License-File: LICENSE.txt
|
31
31
|
Provides-Extra: dev
|
32
|
-
Requires-Dist: autopep8
|
33
|
-
Requires-Dist: black
|
34
|
-
Requires-Dist: build
|
35
|
-
Requires-Dist: flake8
|
36
|
-
Requires-Dist: flake8-pyproject
|
37
|
-
Requires-Dist: flake8-typing-imports
|
38
|
-
Requires-Dist: isort
|
39
|
-
Requires-Dist: pytest
|
40
|
-
Requires-Dist: numpy
|
41
|
-
Requires-Dist: toml
|
42
|
-
Requires-Dist: validate-pyproject[all]
|
32
|
+
Requires-Dist: autopep8; extra == "dev"
|
33
|
+
Requires-Dist: black; extra == "dev"
|
34
|
+
Requires-Dist: build; extra == "dev"
|
35
|
+
Requires-Dist: flake8; extra == "dev"
|
36
|
+
Requires-Dist: flake8-pyproject; extra == "dev"
|
37
|
+
Requires-Dist: flake8-typing-imports; extra == "dev"
|
38
|
+
Requires-Dist: isort; extra == "dev"
|
39
|
+
Requires-Dist: pytest; extra == "dev"
|
40
|
+
Requires-Dist: numpy; extra == "dev"
|
41
|
+
Requires-Dist: toml; extra == "dev"
|
42
|
+
Requires-Dist: validate-pyproject[all]; extra == "dev"
|
43
|
+
Dynamic: license-file
|
43
44
|
|
44
45
|
# Appose Python
|
45
46
|
|
@@ -71,16 +72,6 @@ This is the **Python implementation of Appose**.
|
|
71
72
|
|
72
73
|
The name of the package is `appose`.
|
73
74
|
|
74
|
-
### Conda/Mamba
|
75
|
-
|
76
|
-
To use [the conda-forge package](https://anaconda.org/conda-forge/appose),
|
77
|
-
add `appose` to your `environment.yml`'s `dependencies` section:
|
78
|
-
|
79
|
-
```yaml
|
80
|
-
dependencies:
|
81
|
-
- appose
|
82
|
-
```
|
83
|
-
|
84
75
|
### PyPI/Pip
|
85
76
|
|
86
77
|
To use [the PyPI package](https://pypi.org/project/appose),
|
@@ -98,6 +89,16 @@ dependencies = [
|
|
98
89
|
]
|
99
90
|
```
|
100
91
|
|
92
|
+
### Conda/Mamba
|
93
|
+
|
94
|
+
To use [the conda-forge package](https://anaconda.org/conda-forge/appose),
|
95
|
+
add `appose` to your `environment.yml`'s `dependencies` section:
|
96
|
+
|
97
|
+
```yaml
|
98
|
+
dependencies:
|
99
|
+
- appose
|
100
|
+
```
|
101
|
+
|
101
102
|
## Examples
|
102
103
|
|
103
104
|
Here is a minimal example for calling into Java from Python:
|
@@ -107,12 +108,12 @@ import appose
|
|
107
108
|
env = appose.java(vendor="zulu", version="17").build()
|
108
109
|
with env.groovy() as groovy:
|
109
110
|
task = groovy.task("5 + 6")
|
110
|
-
task.
|
111
|
-
result = task.outputs
|
111
|
+
task.wait_for()
|
112
|
+
result = task.outputs["result"]
|
112
113
|
assert 11 == result
|
113
114
|
```
|
114
115
|
|
115
|
-
*Note: The `
|
116
|
+
*Note: The `appose.java` builder is planned, but not yet implemented.*
|
116
117
|
|
117
118
|
Here is an example using a few more of Appose's features:
|
118
119
|
|
@@ -169,3 +170,9 @@ with env.groovy() as groovy:
|
|
169
170
|
|
170
171
|
Of course, the above examples could have been done all in one language. But
|
171
172
|
hopefully they hint at the possibilities of easy cross-language integration.
|
173
|
+
|
174
|
+
## Issue tracker
|
175
|
+
|
176
|
+
All implementations of Appose use the same issue tracker:
|
177
|
+
|
178
|
+
https://github.com/apposed/appose/issues
|
@@ -0,0 +1,11 @@
|
|
1
|
+
appose/__init__.py,sha256=SRXmNEqFIAajVlwVCSAdhHQytRKZJiWI5Y5-xwioW0w,7544
|
2
|
+
appose/environment.py,sha256=Kg-MfAcmVPDltwqvmrcvus-pP01SNUdn4bE2kdkIVuk,7597
|
3
|
+
appose/paths.py,sha256=WgBHwnZdS3Ot8_hssFqUiTGnunolYRrHLf9rAfEU5r4,2182
|
4
|
+
appose/python_worker.py,sha256=iQoz2t0glbXlR1lgynN-yXPIgtlAbCFtMYYpev-LZyU,9844
|
5
|
+
appose/service.py,sha256=UQn1aoL3uQ8gPKS6nf5oYg0Ea1ZRsaQ3TfLCKeCZLPQ,14581
|
6
|
+
appose/types.py,sha256=uidbt2JwWJ2D3Mbtc4Vl2sRP6WNgKjmfcHjJ9QLWkcs,10557
|
7
|
+
appose-0.4.0.dist-info/licenses/LICENSE.txt,sha256=ZedidA6NyZclNlsx-vl9IVBWRAQSqXdNsCWgtFcwzIE,1313
|
8
|
+
appose-0.4.0.dist-info/METADATA,sha256=cI_E4xetwcHlusk2UzSPOk6SWrNr_vBrGjOC_0H4CFo,5598
|
9
|
+
appose-0.4.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
10
|
+
appose-0.4.0.dist-info/top_level.txt,sha256=oqHhw2QGlaFfH3jMR9H5Cd6XYHdEv5DvK1am0iEFPW0,7
|
11
|
+
appose-0.4.0.dist-info/RECORD,,
|
appose-0.1.0.dist-info/RECORD
DELETED
@@ -1,11 +0,0 @@
|
|
1
|
-
appose/__init__.py,sha256=IwFrDvIa6QOgV_RVgbIUfwyzswWB4RckLrzA9cts6IE,5340
|
2
|
-
appose/environment.py,sha256=m1a7166dJq69XPWX53d63KoITqmH4fKMNPKbEGSxeC0,7524
|
3
|
-
appose/paths.py,sha256=qBEWWf0LB0Cpk-l7u5uoPx9DTley6HltMTg0vJ2iFEQ,2156
|
4
|
-
appose/python_worker.py,sha256=ZR6AtWXXk_Xcp0nSoWehbl7LYc6z18ZTTvo4QjO_MwU,6474
|
5
|
-
appose/service.py,sha256=LqchMIxQXV6YF_l8sAGEyJapc2lSL6KBttcbLzdABFw,11966
|
6
|
-
appose/types.py,sha256=uuVNLt21eT3JEPtRQCMQ_IcFnp2yLgrJTSxzAnxkKFY,1618
|
7
|
-
appose-0.1.0.dist-info/LICENSE.txt,sha256=BOQExkI6YKvnUVMUqDbPuSxsY7XXL1XRUTTbnfEWII0,1306
|
8
|
-
appose-0.1.0.dist-info/METADATA,sha256=6ohYqhKiVnCvof0UX4HAnMmarKKIyi7hRScNtEO1T9E,5477
|
9
|
-
appose-0.1.0.dist-info/WHEEL,sha256=pkctZYzUS4AYVn6dJ-7367OJZivF2e8RA9b_ZBjif18,92
|
10
|
-
appose-0.1.0.dist-info/top_level.txt,sha256=oqHhw2QGlaFfH3jMR9H5Cd6XYHdEv5DvK1am0iEFPW0,7
|
11
|
-
appose-0.1.0.dist-info/RECORD,,
|
File without changes
|