formix-pubsub 0.9.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.
- formix_pubsub-0.9.0/LICENSE +21 -0
- formix_pubsub-0.9.0/PKG-INFO +405 -0
- formix_pubsub-0.9.0/README.md +368 -0
- formix_pubsub-0.9.0/formix_pubsub.egg-info/PKG-INFO +405 -0
- formix_pubsub-0.9.0/formix_pubsub.egg-info/SOURCES.txt +16 -0
- formix_pubsub-0.9.0/formix_pubsub.egg-info/dependency_links.txt +1 -0
- formix_pubsub-0.9.0/formix_pubsub.egg-info/requires.txt +12 -0
- formix_pubsub-0.9.0/formix_pubsub.egg-info/top_level.txt +1 -0
- formix_pubsub-0.9.0/pubsub/__init__.py +18 -0
- formix_pubsub-0.9.0/pubsub/abstractions.py +81 -0
- formix_pubsub-0.9.0/pubsub/channel.py +217 -0
- formix_pubsub-0.9.0/pubsub/message.py +218 -0
- formix_pubsub-0.9.0/pubsub/pubsub.py +148 -0
- formix_pubsub-0.9.0/pyproject.toml +80 -0
- formix_pubsub-0.9.0/setup.cfg +4 -0
- formix_pubsub-0.9.0/tests/test_channel.py +328 -0
- formix_pubsub-0.9.0/tests/test_message.py +170 -0
- formix_pubsub-0.9.0/tests/test_pubsub.py +528 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Your Name
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: formix-pubsub
|
|
3
|
+
Version: 0.9.0
|
|
4
|
+
Summary: A Python publish-subscribe messaging library
|
|
5
|
+
Author-email: formix <formix@users.noreply.github.com>
|
|
6
|
+
Maintainer-email: formix <formix@users.noreply.github.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Project-URL: Homepage, https://github.com/formix/pubsub
|
|
9
|
+
Project-URL: Bug Tracker, https://github.com/formix/pubsub/issues
|
|
10
|
+
Project-URL: Documentation, https://github.com/formix/pubsub
|
|
11
|
+
Project-URL: Source Code, https://github.com/formix/pubsub
|
|
12
|
+
Keywords: pubsub,messaging,publish,subscribe,events
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Classifier: Topic :: Communications
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
28
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
29
|
+
Requires-Dist: black; extra == "dev"
|
|
30
|
+
Requires-Dist: flake8; extra == "dev"
|
|
31
|
+
Requires-Dist: mypy; extra == "dev"
|
|
32
|
+
Requires-Dist: pre-commit; extra == "dev"
|
|
33
|
+
Provides-Extra: docs
|
|
34
|
+
Requires-Dist: sphinx; extra == "docs"
|
|
35
|
+
Requires-Dist: sphinx-rtd-theme; extra == "docs"
|
|
36
|
+
Dynamic: license-file
|
|
37
|
+
|
|
38
|
+
# PubSub
|
|
39
|
+
|
|
40
|
+
A lightweight, file-system based publish-subscribe messaging system for Python using FIFO (named pipes).
|
|
41
|
+
|
|
42
|
+
The version 0.9.0 was tested on linux thouroughly, need more testing on Windows, MacOs and BSD.
|
|
43
|
+
|
|
44
|
+
## Features
|
|
45
|
+
|
|
46
|
+
- **Topic-based routing** with wildcard support (`=` for single word, `+` for multiple words)
|
|
47
|
+
- **Multiple subscribers** can listen to the same topic independently
|
|
48
|
+
- **Message persistence** via file system until consumed
|
|
49
|
+
- **Non-blocking operations** using FIFO queues
|
|
50
|
+
- **Context manager support** for automatic resource cleanup
|
|
51
|
+
- **Thread-safe** message publishing and consumption
|
|
52
|
+
|
|
53
|
+
## Installation
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pip install formix-pubsub
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Quick Start
|
|
60
|
+
|
|
61
|
+
### Basic Publish-Subscribe
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from pubsub import Channel, publish, subscribe
|
|
65
|
+
|
|
66
|
+
# Create a channel for a topic
|
|
67
|
+
channel = Channel(topic="news.sports")
|
|
68
|
+
|
|
69
|
+
# Use context manager to ensure proper cleanup
|
|
70
|
+
with channel:
|
|
71
|
+
# Publish a message
|
|
72
|
+
count = publish("news.sports", b"Team wins championship!")
|
|
73
|
+
print(f"Published to {count} channel(s)")
|
|
74
|
+
|
|
75
|
+
# Subscribe with a callback
|
|
76
|
+
def handle_message(message):
|
|
77
|
+
print(f"Received: {message.content.decode()}")
|
|
78
|
+
|
|
79
|
+
subscribe(channel, handle_message, timeout_seconds=5.0)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Fetching Messages Manually
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from pubsub import Channel, publish, fetch
|
|
86
|
+
|
|
87
|
+
channel = Channel(topic="alerts")
|
|
88
|
+
|
|
89
|
+
with channel:
|
|
90
|
+
# Publish some messages
|
|
91
|
+
publish("alerts", b"System starting")
|
|
92
|
+
publish("alerts", b"All systems operational")
|
|
93
|
+
|
|
94
|
+
# Fetch messages one at a time
|
|
95
|
+
message = fetch(channel)
|
|
96
|
+
while message:
|
|
97
|
+
print(f"{message.topic}: {message.content.decode()}")
|
|
98
|
+
message = fetch(channel)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Wildcard Topics
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
from pubsub import Channel, subscribe
|
|
105
|
+
|
|
106
|
+
# Single word wildcard (=) - matches one word
|
|
107
|
+
channel = Channel(topic="news.=") # Matches: news.sports, news.tech, news.world
|
|
108
|
+
|
|
109
|
+
# Multiple word wildcard (+) - matches one or more words
|
|
110
|
+
channel = Channel(topic="logs.+") # Matches: logs.error, logs.app.debug, logs.system.critical
|
|
111
|
+
|
|
112
|
+
with channel:
|
|
113
|
+
# This channel will receive all matching messages
|
|
114
|
+
def handle_message(msg):
|
|
115
|
+
print(f"[{msg.topic}] {msg.content.decode()}")
|
|
116
|
+
|
|
117
|
+
subscribe(channel, handle_message, timeout_seconds=10.0)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Multiple Subscribers
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
import threading
|
|
124
|
+
from pubsub import Channel, publish, subscribe
|
|
125
|
+
|
|
126
|
+
topic = "broadcast"
|
|
127
|
+
|
|
128
|
+
# Create two independent channels for the same topic
|
|
129
|
+
channel1 = Channel(topic=topic)
|
|
130
|
+
channel2 = Channel(topic=topic)
|
|
131
|
+
|
|
132
|
+
with channel1, channel2:
|
|
133
|
+
# Start two subscriber threads
|
|
134
|
+
def subscriber1():
|
|
135
|
+
subscribe(channel1, lambda msg: print(f"Sub1: {msg.content}"), timeout_seconds=5.0)
|
|
136
|
+
|
|
137
|
+
def subscriber2():
|
|
138
|
+
subscribe(channel2, lambda msg: print(f"Sub2: {msg.content}"), timeout_seconds=5.0)
|
|
139
|
+
|
|
140
|
+
thread1 = threading.Thread(target=subscriber1)
|
|
141
|
+
thread2 = threading.Thread(target=subscriber2)
|
|
142
|
+
thread1.start()
|
|
143
|
+
thread2.start()
|
|
144
|
+
|
|
145
|
+
# Publish - both subscribers receive the message
|
|
146
|
+
publish(topic, b"Hello everyone!")
|
|
147
|
+
|
|
148
|
+
thread1.join()
|
|
149
|
+
thread2.join()
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### Remote Procedure Call (RPC) Pattern
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
import threading
|
|
156
|
+
import time
|
|
157
|
+
from pubsub import Channel, publish, subscribe, fetch
|
|
158
|
+
|
|
159
|
+
# Server: Process requests and send responses
|
|
160
|
+
def rpc_server():
|
|
161
|
+
request_channel = Channel(topic="rpc.requests")
|
|
162
|
+
|
|
163
|
+
with request_channel:
|
|
164
|
+
def handle_request(request):
|
|
165
|
+
print(f"Server received: {request.content.decode()}")
|
|
166
|
+
|
|
167
|
+
# Extract response topic and correlation ID from headers
|
|
168
|
+
response_topic = request.headers.get("response-topic")
|
|
169
|
+
correlation_id = request.headers.get("correlation-id")
|
|
170
|
+
|
|
171
|
+
if response_topic and correlation_id:
|
|
172
|
+
# Process the request (simulate work)
|
|
173
|
+
result = f"Processed: {request.content.decode()}"
|
|
174
|
+
|
|
175
|
+
# Send response with correlation ID
|
|
176
|
+
response_headers = {
|
|
177
|
+
"correlation-id": correlation_id
|
|
178
|
+
}
|
|
179
|
+
publish(response_topic, result.encode(), headers=response_headers)
|
|
180
|
+
print(f"Server sent response with correlation-id: {correlation_id}")
|
|
181
|
+
|
|
182
|
+
subscribe(request_channel, handle_request, timeout_seconds=5.0)
|
|
183
|
+
|
|
184
|
+
# Client: Send request and wait for response
|
|
185
|
+
def rpc_client():
|
|
186
|
+
response_channel = Channel(topic="rpc.responses.client1")
|
|
187
|
+
|
|
188
|
+
with response_channel:
|
|
189
|
+
# Create request with response topic and correlation ID
|
|
190
|
+
request_data = b"Calculate 2 + 2"
|
|
191
|
+
request_headers = {
|
|
192
|
+
"response-topic": "rpc.responses.client1",
|
|
193
|
+
"correlation-id": str(int(time.time() * 1000000)) # Use timestamp as correlation ID
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
print(f"Client sending request with correlation-id: {request_headers['correlation-id']}")
|
|
197
|
+
publish("rpc.requests", request_data, headers=request_headers)
|
|
198
|
+
|
|
199
|
+
# Wait for response
|
|
200
|
+
response = fetch(response_channel)
|
|
201
|
+
if response:
|
|
202
|
+
correlation_id = response.headers.get("correlation-id")
|
|
203
|
+
print(f"Client received response with correlation-id: {correlation_id}")
|
|
204
|
+
print(f"Result: {response.content.decode()}")
|
|
205
|
+
|
|
206
|
+
# Start server in background thread
|
|
207
|
+
server_thread = threading.Thread(target=rpc_server)
|
|
208
|
+
server_thread.start()
|
|
209
|
+
|
|
210
|
+
# Give server time to start
|
|
211
|
+
time.sleep(0.1)
|
|
212
|
+
|
|
213
|
+
# Execute client request
|
|
214
|
+
rpc_client()
|
|
215
|
+
|
|
216
|
+
# Wait for server to finish
|
|
217
|
+
server_thread.join()
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
## API Reference
|
|
221
|
+
|
|
222
|
+
### Channel
|
|
223
|
+
|
|
224
|
+
Represents a topic subscription point with a dedicated FIFO queue.
|
|
225
|
+
|
|
226
|
+
```python
|
|
227
|
+
Channel(topic: str)
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
**Parameters:**
|
|
231
|
+
- `topic` (str): Topic string with dots separating terms. Supports wildcards `=` (single word) and `+` (multiple words). Valid characters: `[a-zA-Z0-9+=.-]`
|
|
232
|
+
|
|
233
|
+
**Methods:**
|
|
234
|
+
- `open()`: Opens the FIFO queue for reading (called automatically by context manager)
|
|
235
|
+
- `close()`: Closes the queue and cleans up resources
|
|
236
|
+
- `__enter__()`, `__exit__()`: Context manager support
|
|
237
|
+
|
|
238
|
+
**Usage:**
|
|
239
|
+
```python
|
|
240
|
+
channel = Channel(topic="app.logs")
|
|
241
|
+
with channel:
|
|
242
|
+
# Channel is open and ready
|
|
243
|
+
pass
|
|
244
|
+
# Channel is automatically closed
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
### publish()
|
|
248
|
+
|
|
249
|
+
Publishes a message to all matching channels.
|
|
250
|
+
|
|
251
|
+
```python
|
|
252
|
+
publish(topic: str, data: bytes, headers: dict = None) -> int
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
**Parameters:**
|
|
256
|
+
- `topic` (str): Topic to publish to (only alphanumeric characters, dots, and hyphens allowed: `[a-zA-Z0-9.-]`)
|
|
257
|
+
- `data` (bytes): Message payload
|
|
258
|
+
- `headers` (dict): Optional dictionary of string key-value pairs for metadata
|
|
259
|
+
|
|
260
|
+
**Returns:**
|
|
261
|
+
- `int`: Number of channels the message was published to
|
|
262
|
+
|
|
263
|
+
**Raises:**
|
|
264
|
+
- `ValueError`: If topic contains invalid characters (only `[a-zA-Z0-9.-]` allowed)
|
|
265
|
+
|
|
266
|
+
**Example with headers:**
|
|
267
|
+
```python
|
|
268
|
+
from pubsub import publish
|
|
269
|
+
|
|
270
|
+
# Publish with custom headers
|
|
271
|
+
headers = {
|
|
272
|
+
"priority": "high",
|
|
273
|
+
"correlation-id": "12345"
|
|
274
|
+
}
|
|
275
|
+
publish("app.events", b"Event data", headers=headers)
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
### fetch()
|
|
279
|
+
|
|
280
|
+
Fetches a single message from a channel (non-blocking).
|
|
281
|
+
|
|
282
|
+
```python
|
|
283
|
+
fetch(channel: Channel) -> Optional[Message]
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
**Parameters:**
|
|
287
|
+
- `channel` (Channel): Open channel to fetch from
|
|
288
|
+
|
|
289
|
+
**Returns:**
|
|
290
|
+
- `Message`: Message object if available, `None` if queue is empty
|
|
291
|
+
|
|
292
|
+
**Note:** The channel must be opened (use `with channel:`) before calling `fetch()`.
|
|
293
|
+
|
|
294
|
+
### subscribe()
|
|
295
|
+
|
|
296
|
+
Subscribes to a channel and processes messages with a callback.
|
|
297
|
+
|
|
298
|
+
```python
|
|
299
|
+
subscribe(channel: Channel, callback: Callable[[Message], None], timeout_seconds: float = 0) -> int
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
**Parameters:**
|
|
303
|
+
- `channel` (Channel): Open channel to subscribe to
|
|
304
|
+
- `callback` (Callable): Function called for each message received
|
|
305
|
+
- `timeout_seconds` (float): How long to listen (0 = indefinite)
|
|
306
|
+
|
|
307
|
+
**Returns:**
|
|
308
|
+
- `int`: Number of messages processed
|
|
309
|
+
|
|
310
|
+
**Raises:**
|
|
311
|
+
- `ValueError`: If timeout is negative
|
|
312
|
+
|
|
313
|
+
**Note:** The channel must be opened (use `with channel:`) before calling `subscribe()`.
|
|
314
|
+
|
|
315
|
+
### Message
|
|
316
|
+
|
|
317
|
+
Represents a pub/sub message.
|
|
318
|
+
|
|
319
|
+
**Attributes:**
|
|
320
|
+
- `id` (int): Unique message identifier (timestamp-based with random bits)
|
|
321
|
+
- `timestamp` (int): Message creation timestamp in microseconds
|
|
322
|
+
- `topic` (str): Message topic
|
|
323
|
+
- `content` (bytes): Message payload
|
|
324
|
+
- `content_length` (int): Length of content in bytes
|
|
325
|
+
- `headers` (dict): Dictionary of string key-value pairs containing message metadata
|
|
326
|
+
|
|
327
|
+
## Configuration
|
|
328
|
+
|
|
329
|
+
### Environment Variables
|
|
330
|
+
|
|
331
|
+
#### PUBSUB_HOME
|
|
332
|
+
|
|
333
|
+
Override the default storage location for pubsub channels and messages.
|
|
334
|
+
|
|
335
|
+
**Default behavior:**
|
|
336
|
+
- Linux/Unix: `/dev/shm/pubsub` (tmpfs for best performance)
|
|
337
|
+
- Other systems: `<system_temp>/pubsub`
|
|
338
|
+
|
|
339
|
+
**Usage:**
|
|
340
|
+
```bash
|
|
341
|
+
# Set custom storage location
|
|
342
|
+
export PUBSUB_HOME=/tmp/my-pubsub
|
|
343
|
+
|
|
344
|
+
# Run your application
|
|
345
|
+
python your_app.py
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
**Example:**
|
|
349
|
+
```python
|
|
350
|
+
import os
|
|
351
|
+
os.environ['PUBSUB_HOME'] = '/path/to/custom/location'
|
|
352
|
+
|
|
353
|
+
from pubsub import Channel, publish, subscribe
|
|
354
|
+
|
|
355
|
+
# Now all channels will use the custom location
|
|
356
|
+
channel = Channel(topic="app.logs")
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
**Note:** The base directory is cached after first use, so `PUBSUB_HOME` should be set before importing or using pubsub functions.
|
|
360
|
+
|
|
361
|
+
## Architecture
|
|
362
|
+
|
|
363
|
+
### Storage Location
|
|
364
|
+
|
|
365
|
+
Messages are stored in `/dev/shm/pubsub/` (tmpfs) by default for fast access. Each channel creates a directory containing:
|
|
366
|
+
- `queue`: FIFO pipe for message IDs
|
|
367
|
+
- `<message_id>`: Message content files
|
|
368
|
+
|
|
369
|
+
### Message Flow
|
|
370
|
+
|
|
371
|
+
1. **Publish**: Message written to tmp, then hard-linked to each matching channel directory, ID written to FIFO
|
|
372
|
+
2. **Fetch/Subscribe**: Read ID from FIFO, load message from file, delete message file
|
|
373
|
+
3. **Cleanup**: Channel cleanup removes directory and all unconsumed messages
|
|
374
|
+
|
|
375
|
+
### Wildcards
|
|
376
|
+
|
|
377
|
+
- `=` matches a single word: `logs.=.error` matches `logs.app.error` but not `logs.error`
|
|
378
|
+
- `+` matches one or more words: `logs.+` matches `logs.error`, `logs.app.error`, `logs.app.module.error`
|
|
379
|
+
|
|
380
|
+
Wildcards are converted to regex patterns for matching.
|
|
381
|
+
|
|
382
|
+
## Testing
|
|
383
|
+
|
|
384
|
+
Run the test suite:
|
|
385
|
+
|
|
386
|
+
```bash
|
|
387
|
+
# All tests
|
|
388
|
+
python -m unittest discover -s tests
|
|
389
|
+
|
|
390
|
+
# Specific test class
|
|
391
|
+
python -m unittest tests.test_pubsub.TestPublish -v
|
|
392
|
+
|
|
393
|
+
# Specific test
|
|
394
|
+
python -m unittest tests.test_channel.TestChannel.test_channel_creation -v
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
## Requirements
|
|
398
|
+
|
|
399
|
+
- Python 3.12+
|
|
400
|
+
- Linux/Unix with tmpfs support (`/dev/shm`)
|
|
401
|
+
- FIFO (named pipes) support
|
|
402
|
+
|
|
403
|
+
## License
|
|
404
|
+
|
|
405
|
+
See LICENSE file for details.
|