gisolate 0.2.14__tar.gz → 0.2.16.dev0__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.
Files changed (40) hide show
  1. gisolate-0.2.16.dev0/.code-review-graph/.gitignore +3 -0
  2. gisolate-0.2.16.dev0/.code-review-graph/graph.db +0 -0
  3. {gisolate-0.2.14 → gisolate-0.2.16.dev0}/PKG-INFO +61 -1
  4. {gisolate-0.2.14 → gisolate-0.2.16.dev0}/README.md +60 -0
  5. {gisolate-0.2.14 → gisolate-0.2.16.dev0}/pyproject.toml +1 -1
  6. {gisolate-0.2.14 → gisolate-0.2.16.dev0}/src/gisolate/__init__.py +5 -1
  7. {gisolate-0.2.14 → gisolate-0.2.16.dev0}/src/gisolate/_internal.py +16 -2
  8. {gisolate-0.2.14 → gisolate-0.2.16.dev0}/src/gisolate/proxy.py +6 -6
  9. gisolate-0.2.16.dev0/src/gisolate/pubsub.py +425 -0
  10. gisolate-0.2.16.dev0/tests/test_pubsub.py +644 -0
  11. {gisolate-0.2.14 → gisolate-0.2.16.dev0}/uv.lock +1 -1
  12. {gisolate-0.2.14 → gisolate-0.2.16.dev0}/.envrc +0 -0
  13. {gisolate-0.2.14 → gisolate-0.2.16.dev0}/.gitignore +0 -0
  14. {gisolate-0.2.14 → gisolate-0.2.16.dev0}/CLAUDE.md +0 -0
  15. {gisolate-0.2.14 → gisolate-0.2.16.dev0}/LICENSE +0 -0
  16. {gisolate-0.2.14 → gisolate-0.2.16.dev0}/lefthook.yml +0 -0
  17. {gisolate-0.2.14 → gisolate-0.2.16.dev0}/src/gisolate/_workers.py +0 -0
  18. {gisolate-0.2.14 → gisolate-0.2.16.dev0}/src/gisolate/bridge.py +0 -0
  19. {gisolate-0.2.14 → gisolate-0.2.16.dev0}/src/gisolate/hub.py +0 -0
  20. {gisolate-0.2.14 → gisolate-0.2.16.dev0}/src/gisolate/local.py +0 -0
  21. {gisolate-0.2.14 → gisolate-0.2.16.dev0}/src/gisolate/subprocess.py +0 -0
  22. {gisolate-0.2.14 → gisolate-0.2.16.dev0}/tests/__init__.py +0 -0
  23. {gisolate-0.2.14 → gisolate-0.2.16.dev0}/tests/bdd/__init__.py +0 -0
  24. {gisolate-0.2.14 → gisolate-0.2.16.dev0}/tests/bdd/features/process_proxy.feature +0 -0
  25. {gisolate-0.2.14 → gisolate-0.2.16.dev0}/tests/bdd/features/serialization.feature +0 -0
  26. {gisolate-0.2.14 → gisolate-0.2.16.dev0}/tests/bdd/features/subprocess_run.feature +0 -0
  27. {gisolate-0.2.14 → gisolate-0.2.16.dev0}/tests/bdd/features/thread_local.feature +0 -0
  28. {gisolate-0.2.14 → gisolate-0.2.16.dev0}/tests/bdd/test_proxy.py +0 -0
  29. {gisolate-0.2.14 → gisolate-0.2.16.dev0}/tests/bdd/test_serialization.py +0 -0
  30. {gisolate-0.2.14 → gisolate-0.2.16.dev0}/tests/bdd/test_subprocess.py +0 -0
  31. {gisolate-0.2.14 → gisolate-0.2.16.dev0}/tests/bdd/test_thread_local.py +0 -0
  32. {gisolate-0.2.14 → gisolate-0.2.16.dev0}/tests/conftest.py +0 -0
  33. {gisolate-0.2.14 → gisolate-0.2.16.dev0}/tests/helpers.py +0 -0
  34. {gisolate-0.2.14 → gisolate-0.2.16.dev0}/tests/test_bridge.py +0 -0
  35. {gisolate-0.2.14 → gisolate-0.2.16.dev0}/tests/test_hub.py +0 -0
  36. {gisolate-0.2.14 → gisolate-0.2.16.dev0}/tests/test_internal.py +0 -0
  37. {gisolate-0.2.14 → gisolate-0.2.16.dev0}/tests/test_local.py +0 -0
  38. {gisolate-0.2.14 → gisolate-0.2.16.dev0}/tests/test_proxy.py +0 -0
  39. {gisolate-0.2.14 → gisolate-0.2.16.dev0}/tests/test_subprocess.py +0 -0
  40. {gisolate-0.2.14 → gisolate-0.2.16.dev0}/tests/test_workers.py +0 -0
@@ -0,0 +1,3 @@
1
+ # Auto-generated by code-review-graph — do not commit database files.
2
+ # The graph.db contains absolute paths and code structure metadata.
3
+ *
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gisolate
3
- Version: 0.2.14
3
+ Version: 0.2.16.dev0
4
4
  Summary: Process isolation for gevent applications — run any object in a clean subprocess, call methods transparently via ZMQ IPC.
5
5
  Project-URL: Repository, https://github.com/wy-z/gisolate
6
6
  License-Expression: MIT
@@ -105,6 +105,47 @@ asyncio.run(main())
105
105
  server.close()
106
106
  ```
107
107
 
108
+ ### ProcessPublisher / ProcessSubscriber — one-way fan-out
109
+
110
+ ZMQ PUB/SUB for one-way data streaming (snapshots, signals, heartbeats). Use this when message loss is acceptable; use `ProcessBridge` when you need request/response with delivery guarantees.
111
+
112
+ ```python
113
+ # Producer (gevent side)
114
+ from gisolate import ProcessPublisher
115
+
116
+ pub = ProcessPublisher("ipc:///tmp/stream.sock").start()
117
+ pub.publish("v1.snapshot.AAPL", {"price": 150.0})
118
+ pub.publish("v1.heartbeat.gevent", {"ts_ns": 1234567890})
119
+ pub.close()
120
+
121
+ # Consumer (asyncio side)
122
+ import asyncio
123
+ from gisolate import ProcessSubscriber
124
+
125
+ async def main():
126
+ sub = ProcessSubscriber("ipc:///tmp/stream.sock")
127
+
128
+ async def on_snapshot(topic, payload):
129
+ print(topic, payload)
130
+
131
+ async def on_heartbeat(topic, payload):
132
+ print("heartbeat", payload)
133
+
134
+ sub.subscribe("v1.snapshot.", on_snapshot)
135
+ sub.subscribe("v1.heartbeat.", on_heartbeat)
136
+ sub.start()
137
+ await asyncio.sleep(10)
138
+ await sub.close()
139
+
140
+ asyncio.run(main())
141
+ ```
142
+
143
+ Notes:
144
+ - **Topic prefix matching** — `sub.subscribe("v1.snapshot.", h)` receives every topic starting with that prefix.
145
+ - **Multiple handlers per prefix** — invoked concurrently with `asyncio.gather`. Exceptions in one handler do not kill the reader task.
146
+ - **Lossy by design** — `publish` is non-blocking; messages are dropped when the send queue is full (slow subscriber). Set `sndhwm=` to tune.
147
+ - **Pluggable serializer** — defaults to `SmartPickle`. Pass any object implementing the `Serializer` protocol (`dumps`/`loads`) to use msgpack, JSON, etc.
148
+
108
149
  ### ThreadLocalProxy — per-thread instances
109
150
 
110
151
  Thread-local proxy using unpatched `threading.local` for true isolation in `gevent.threadpool`:
@@ -153,6 +194,25 @@ Run a function in an isolated subprocess. Blocks with gevent-safe polling.
153
194
  - **`await bridge.call(func, *args, timeout=60, **kwargs)`** — async RPC call (client mode)
154
195
  - **`bridge.close()`** — cleanup resources
155
196
 
197
+ ### `ProcessPublisher(address, *, serializer=SmartPickle, sndhwm=1000)`
198
+
199
+ - **`pub.start()`** — bind the PUB socket (idempotent, returns self)
200
+ - **`pub.publish(topic, payload)`** — non-blocking publish; drops on slow consumers
201
+ - **`pub.close()`** — cleanup (idempotent)
202
+ - Supports context manager (`with` statement)
203
+
204
+ ### `ProcessSubscriber(address, *, serializer=SmartPickle)`
205
+
206
+ - **`sub.subscribe(topic_prefix, handler)`** — register an async handler for a topic prefix
207
+ - **`sub.unsubscribe(topic_prefix, handler=None)`** — remove a handler or all handlers for a prefix
208
+ - **`sub.start()`** — connect and spawn the reader task (idempotent, returns self)
209
+ - **`await sub.close()`** — cancel reader and cleanup (idempotent)
210
+ - Supports async context manager (`async with` statement)
211
+
212
+ ### `Serializer` (Protocol)
213
+
214
+ Anything with `dumps(obj) -> bytes` and `loads(bytes) -> obj` static methods can be used as a serializer for `ProcessPublisher` / `ProcessSubscriber`. Default is `SmartPickle` (pickle, falling back to dill).
215
+
156
216
  ### `ThreadLocalProxy(factory)`
157
217
 
158
218
  Transparent proxy delegating attribute access to a per-thread instance.
@@ -84,6 +84,47 @@ asyncio.run(main())
84
84
  server.close()
85
85
  ```
86
86
 
87
+ ### ProcessPublisher / ProcessSubscriber — one-way fan-out
88
+
89
+ ZMQ PUB/SUB for one-way data streaming (snapshots, signals, heartbeats). Use this when message loss is acceptable; use `ProcessBridge` when you need request/response with delivery guarantees.
90
+
91
+ ```python
92
+ # Producer (gevent side)
93
+ from gisolate import ProcessPublisher
94
+
95
+ pub = ProcessPublisher("ipc:///tmp/stream.sock").start()
96
+ pub.publish("v1.snapshot.AAPL", {"price": 150.0})
97
+ pub.publish("v1.heartbeat.gevent", {"ts_ns": 1234567890})
98
+ pub.close()
99
+
100
+ # Consumer (asyncio side)
101
+ import asyncio
102
+ from gisolate import ProcessSubscriber
103
+
104
+ async def main():
105
+ sub = ProcessSubscriber("ipc:///tmp/stream.sock")
106
+
107
+ async def on_snapshot(topic, payload):
108
+ print(topic, payload)
109
+
110
+ async def on_heartbeat(topic, payload):
111
+ print("heartbeat", payload)
112
+
113
+ sub.subscribe("v1.snapshot.", on_snapshot)
114
+ sub.subscribe("v1.heartbeat.", on_heartbeat)
115
+ sub.start()
116
+ await asyncio.sleep(10)
117
+ await sub.close()
118
+
119
+ asyncio.run(main())
120
+ ```
121
+
122
+ Notes:
123
+ - **Topic prefix matching** — `sub.subscribe("v1.snapshot.", h)` receives every topic starting with that prefix.
124
+ - **Multiple handlers per prefix** — invoked concurrently with `asyncio.gather`. Exceptions in one handler do not kill the reader task.
125
+ - **Lossy by design** — `publish` is non-blocking; messages are dropped when the send queue is full (slow subscriber). Set `sndhwm=` to tune.
126
+ - **Pluggable serializer** — defaults to `SmartPickle`. Pass any object implementing the `Serializer` protocol (`dumps`/`loads`) to use msgpack, JSON, etc.
127
+
87
128
  ### ThreadLocalProxy — per-thread instances
88
129
 
89
130
  Thread-local proxy using unpatched `threading.local` for true isolation in `gevent.threadpool`:
@@ -132,6 +173,25 @@ Run a function in an isolated subprocess. Blocks with gevent-safe polling.
132
173
  - **`await bridge.call(func, *args, timeout=60, **kwargs)`** — async RPC call (client mode)
133
174
  - **`bridge.close()`** — cleanup resources
134
175
 
176
+ ### `ProcessPublisher(address, *, serializer=SmartPickle, sndhwm=1000)`
177
+
178
+ - **`pub.start()`** — bind the PUB socket (idempotent, returns self)
179
+ - **`pub.publish(topic, payload)`** — non-blocking publish; drops on slow consumers
180
+ - **`pub.close()`** — cleanup (idempotent)
181
+ - Supports context manager (`with` statement)
182
+
183
+ ### `ProcessSubscriber(address, *, serializer=SmartPickle)`
184
+
185
+ - **`sub.subscribe(topic_prefix, handler)`** — register an async handler for a topic prefix
186
+ - **`sub.unsubscribe(topic_prefix, handler=None)`** — remove a handler or all handlers for a prefix
187
+ - **`sub.start()`** — connect and spawn the reader task (idempotent, returns self)
188
+ - **`await sub.close()`** — cancel reader and cleanup (idempotent)
189
+ - Supports async context manager (`async with` statement)
190
+
191
+ ### `Serializer` (Protocol)
192
+
193
+ Anything with `dumps(obj) -> bytes` and `loads(bytes) -> obj` static methods can be used as a serializer for `ProcessPublisher` / `ProcessSubscriber`. Default is `SmartPickle` (pickle, falling back to dill).
194
+
135
195
  ### `ThreadLocalProxy(factory)`
136
196
 
137
197
  Transparent proxy delegating attribute access to a per-thread instance.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "gisolate"
3
- version = "0.2.14"
3
+ version = "0.2.16.dev0"
4
4
  description = "Process isolation for gevent applications — run any object in a clean subprocess, call methods transparently via ZMQ IPC."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -4,11 +4,12 @@ Run any object in a clean subprocess, call methods transparently via ZMQ IPC.
4
4
  Isolates libraries incompatible with gevent monkey-patching.
5
5
  """
6
6
 
7
- from ._internal import ProcessError, RemoteError
7
+ from ._internal import ProcessError, RemoteError, Serializer
8
8
  from .bridge import ProcessBridge
9
9
  from .hub import ensure_hub_started, shutdown as shutdown_hub, spawn_on_main_hub
10
10
  from .local import ThreadLocalProxy
11
11
  from .proxy import ProcessProxy, get_default_mp_context, set_default_mp_context
12
+ from .pubsub import ProcessPublisher, ProcessSubscriber
12
13
  from .subprocess import run_in_subprocess
13
14
 
14
15
  # Pre-initialize threadpoolctl on main thread to cache library info.
@@ -24,7 +25,10 @@ __all__ = [
24
25
  "ProcessBridge",
25
26
  "ProcessError",
26
27
  "ProcessProxy",
28
+ "ProcessPublisher",
29
+ "ProcessSubscriber",
27
30
  "RemoteError",
31
+ "Serializer",
28
32
  "ThreadLocalProxy",
29
33
  "ensure_hub_started",
30
34
  "get_default_mp_context",
@@ -4,7 +4,7 @@ import contextlib
4
4
  import io
5
5
  import logging
6
6
  import pickle
7
- from typing import Any
7
+ from typing import Any, Protocol, runtime_checkable
8
8
 
9
9
  import dill
10
10
  import gevent.monkey
@@ -44,8 +44,22 @@ class RemoteError(RuntimeError):
44
44
  # ---------------------------------------------------------------------------
45
45
 
46
46
 
47
+ @runtime_checkable
48
+ class Serializer(Protocol):
49
+ """Pluggable serializer protocol: ``dumps(obj) -> bytes`` / ``loads(bytes) -> obj``."""
50
+
51
+ @staticmethod
52
+ def dumps(obj: Any) -> bytes: ...
53
+
54
+ @staticmethod
55
+ def loads(data: bytes) -> Any: ...
56
+
57
+
47
58
  class SmartPickle:
48
- """Serializer preferring pickle, falling back to dill. Learns from failures."""
59
+ """Serializer preferring pickle, falling back to dill. Learns from failures.
60
+
61
+ Implements the :class:`Serializer` protocol.
62
+ """
49
63
 
50
64
  _PICKLE = b"P"
51
65
  _DILL = b"D"
@@ -73,14 +73,14 @@ class ProcessProxy(abc.ABC):
73
73
  - shutdown(): thread-safe (marshals to main hub if needed)
74
74
  """
75
75
 
76
- AUTO_RESTART_THRESHOLD = 6
77
- RESTART_COOLDOWN = 6.0
78
- ALIVE_CHECK_INTERVAL = 10
79
76
  mp_context: Any = None
80
77
  patch_kwargs: dict | None = None
81
78
  timeout: float = 24
82
79
  max_concurrency: int | None = None
83
80
  daemon: bool = True
81
+ auto_restart_threshold: int = 6
82
+ restart_cooldown: float = 6.0
83
+ alive_check_interval: int = 10
84
84
 
85
85
  @staticmethod
86
86
  @abc.abstractmethod
@@ -215,7 +215,7 @@ class ProcessProxy(abc.ABC):
215
215
  return hub.run_on_main_hub(self.restart_process)
216
216
  with self._lock:
217
217
  now = time.monotonic()
218
- if self._is_alive() and now - self._last_restart < self.RESTART_COOLDOWN:
218
+ if self._is_alive() and now - self._last_restart < self.restart_cooldown:
219
219
  log.warning("Restart skipped (cooldown)")
220
220
  return
221
221
  self._last_restart = now
@@ -260,7 +260,7 @@ class ProcessProxy(abc.ABC):
260
260
  idle_cycles = 0
261
261
  else:
262
262
  idle_cycles += 1
263
- if idle_cycles >= self.ALIVE_CHECK_INTERVAL and not self._is_alive():
263
+ if idle_cycles >= self.alive_check_interval and not self._is_alive():
264
264
  break
265
265
  if not self._shutdown:
266
266
  log.warning("Child process died, stopping reader")
@@ -334,7 +334,7 @@ class ProcessProxy(abc.ABC):
334
334
  except ProcessError:
335
335
  with self._lock:
336
336
  self._error_count += 1
337
- should_restart = self._error_count >= self.AUTO_RESTART_THRESHOLD
337
+ should_restart = self._error_count >= self.auto_restart_threshold
338
338
  if should_restart:
339
339
  log.warning(f"{self._error_count} consecutive errors, restarting")
340
340
  self.restart_process()