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 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.waitFor()
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 {task.current}/{task.maximum}")
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.waitFor()
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
- TODO - write up the request and response formats in detail here!
131
- JSON, one line per request/response.
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
- for element in class_path:
113
- cp[element] = None
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
- TODO
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 typing import Optional
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
- args["current"] = current
72
+ try:
73
+ args["current"] = int(current)
74
+ except ValueError:
75
+ pass
62
76
  if maximum is not None:
63
- args["maximum"] = maximum
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
- _globals = {}
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
- # TODO: Consider whether to retain a reference to this Thread, and
123
- # expose a "force" option for cancelation that kills it forcibly; see:
124
- # https://www.geeksforgeeks.org/python-different-ways-to-kill-a-thread/
125
- Thread(target=execute_script, name=f"Appose-{self.uuid}").start()
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
- response = {"task": self.uuid, "responseType": response_type.value}
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
- print(encode(response), flush=True)
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
- # noinspection PyBroadException
135
- try:
136
- while True:
137
- line = self._process.stdout.readline()
138
- self._debug_service("<worker stdout closed>" if line is None else line)
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
- if line is None:
141
- return # pipe closed
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
- except Exception:
154
- self._debug_service(format_exc())
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
- line = self._process.stderr.readline()
164
- if line is None:
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 FAILED.
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__(self, task: "Task", response_type: ResponseType) -> None:
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
- self.message = response.get("message")
313
- current = response.get("current")
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
- event = TaskEvent(self, response_type)
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
- from typing import Any, Dict
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
1
+ Metadata-Version: 2.4
2
2
  Name: appose
3
- Version: 0.1.0
3
+ Version: 0.4.0
4
4
  Summary: Appose: multi-language interprocess cooperation with shared memory.
5
5
  Author: Appose developers
6
- License: Simplified BSD 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-python/issues
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: License :: OSI Approved :: BSD License
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 ; 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'
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.waitFor()
111
- result = task.outputs.get("result")
111
+ task.wait_for()
112
+ result = task.outputs["result"]
112
113
  assert 11 == result
113
114
  ```
114
115
 
115
- *Note: The `Appose.java` builder is planned, but not yet implemented.*
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,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: bdist_wheel (0.40.0)
2
+ Generator: setuptools (80.9.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,4 +1,4 @@
1
- Copyright (c) 2023, Appose developers.
1
+ Copyright (c) 2023 - 2025, Appose developers.
2
2
  All rights reserved.
3
3
 
4
4
  Redistribution and use in source and binary forms, with or without modification,
@@ -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,,