slick-stream-buffer-multiplexer-py 2.0.0__tar.gz → 2.2.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.
- {slick_stream_buffer_multiplexer_py-2.0.0 → slick_stream_buffer_multiplexer_py-2.2.0}/API_DIFFERENCES.md +58 -20
- {slick_stream_buffer_multiplexer_py-2.0.0 → slick_stream_buffer_multiplexer_py-2.2.0}/PKG-INFO +112 -30
- {slick_stream_buffer_multiplexer_py-2.0.0 → slick_stream_buffer_multiplexer_py-2.2.0}/README.md +110 -28
- {slick_stream_buffer_multiplexer_py-2.0.0 → slick_stream_buffer_multiplexer_py-2.2.0}/pyproject.toml +1 -1
- {slick_stream_buffer_multiplexer_py-2.0.0 → slick_stream_buffer_multiplexer_py-2.2.0}/requirements.txt +1 -1
- {slick_stream_buffer_multiplexer_py-2.0.0 → slick_stream_buffer_multiplexer_py-2.2.0}/setup.py +1 -1
- {slick_stream_buffer_multiplexer_py-2.0.0 → slick_stream_buffer_multiplexer_py-2.2.0}/slick_stream_buffer_multiplexer_py.egg-info/SOURCES.txt +1 -0
- {slick_stream_buffer_multiplexer_py-2.0.0 → slick_stream_buffer_multiplexer_py-2.2.0}/slick_stream_buffer_multiplexer_py.py +433 -89
- {slick_stream_buffer_multiplexer_py-2.0.0 → slick_stream_buffer_multiplexer_py-2.2.0}/tests/test_interop.py +92 -7
- {slick_stream_buffer_multiplexer_py-2.0.0 → slick_stream_buffer_multiplexer_py-2.2.0}/tests/test_multiplexer_mpmc.py +146 -1
- slick_stream_buffer_multiplexer_py-2.2.0/tests/test_multiplexer_queue.py +572 -0
- {slick_stream_buffer_multiplexer_py-2.0.0 → slick_stream_buffer_multiplexer_py-2.2.0}/tests/test_multiplexer_shm.py +249 -8
- {slick_stream_buffer_multiplexer_py-2.0.0 → slick_stream_buffer_multiplexer_py-2.2.0}/LICENSE +0 -0
- {slick_stream_buffer_multiplexer_py-2.0.0 → slick_stream_buffer_multiplexer_py-2.2.0}/MANIFEST.in +0 -0
- {slick_stream_buffer_multiplexer_py-2.0.0 → slick_stream_buffer_multiplexer_py-2.2.0}/setup.cfg +0 -0
- {slick_stream_buffer_multiplexer_py-2.0.0 → slick_stream_buffer_multiplexer_py-2.2.0}/tests/test_multiplexer.py +0 -0
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
The Python implementation is binary-compatible with the C++
|
|
4
4
|
`slick::stream_buffer_multiplexer` at the shared memory level — the shared record queue
|
|
5
|
-
('SLQ1', or 'SLQ0' with `enable_read_last` off; 16-byte elements)
|
|
6
|
-
('SSB1')
|
|
5
|
+
('SLQ1', or 'SLQ0' with `enable_read_last` off; 16-byte elements), every stream buffer
|
|
6
|
+
producer segment ('SSB1') and every queue producer segment (a slick-queue with 1-byte elements)
|
|
7
|
+
match byte-for-byte. Peers must agree on `enable_read_last`, the one trait recorded in
|
|
7
8
|
the queue's header (see [Traits](#traits-are-a-constructor-argument-not-a-template-parameter)).
|
|
8
9
|
The *language-level* API differs in the following ways.
|
|
9
10
|
|
|
@@ -17,6 +18,11 @@ The *language-level* API differs in the following ways.
|
|
|
17
18
|
| `add_producer(id, cap, ctl)` | `add_producer(id, capacity=cap, control_size=ctl)` |
|
|
18
19
|
| `add_producer(id, cap, ctl, "name")` | `add_producer(id, capacity=cap, control_size=ctl, name="name")` |
|
|
19
20
|
| `add_producer(id, "name")` | `add_producer(id, name="name")` |
|
|
21
|
+
| `add_queue_producer(id, size)` | `add_queue_producer(id, size=size)` |
|
|
22
|
+
| `add_queue_producer(id, size, items_per_slot)` | `add_queue_producer(id, size=size, items_per_slot=ips)` |
|
|
23
|
+
| `add_queue_producer(id, size, "name")` / `(id, size, items_per_slot, "name")` | `add_queue_producer(id, size=size, [items_per_slot=ips,] name="name")` |
|
|
24
|
+
| `add_queue_producer(id, "name")` | `add_queue_producer(id, name="name")` (an explicit `items_per_slot=` must match the segment) |
|
|
25
|
+
| `add_queue_producer(id, std::shared_ptr<queue_type>)` | `add_queue_producer(id, queue=q)` |
|
|
20
26
|
| throws `std::invalid_argument` (duplicate id, bad sizes) | raises `ValueError` |
|
|
21
27
|
| `uint32_t producer_id` (range enforced by the type) | raises `ValueError` outside `0..0xFFFFFFFF`, `TypeError` for a non-integer |
|
|
22
28
|
| throws `std::runtime_error` (shm failures) | raises `RuntimeError`, `ValueError` (queue geometry mismatch), or `FileNotFoundError` (missing segment) |
|
|
@@ -57,16 +63,42 @@ C++ exposes both `stream_buffer()` (reference) and `stream_buffer_ptr()`
|
|
|
57
63
|
Similarly, `get_producer_buffer()` and `find_producer()` both return the same
|
|
58
64
|
`ProducerBuffer` object (or `None`).
|
|
59
65
|
|
|
66
|
+
## Queue producers
|
|
67
|
+
|
|
68
|
+
C++ `producer_queue::operator[](index)` returns a `uint8_t*` to the reservation; Python's
|
|
69
|
+
`ProducerQueue[index]` returns a writable `memoryview` from `index` to the end of the ring's data
|
|
70
|
+
array, of which the reserved `n` bytes are the first `n` — write them as
|
|
71
|
+
`pq[index][:n] = payload`. (A reservation never splits across the end of the ring, so they are
|
|
72
|
+
always contiguous.) `SlickQueue`'s own `[]` is one element wide, so it cannot stand in for this.
|
|
73
|
+
|
|
74
|
+
C++ `queue_type` is `slick::queue<uint8_t, Traits>`, so adopting a queue with different traits
|
|
75
|
+
fails to compile. Python builds a created queue with the multiplexer's traits too, but accepts an
|
|
76
|
+
adopted `SlickQueue` whatever its traits — only `enable_read_last` reaches shared memory, and
|
|
77
|
+
slick-queue-py checks that against the segment — and requires only `element_size == 1`
|
|
78
|
+
(`ValueError` otherwise, `TypeError` for something that is not a `SlickQueue`).
|
|
79
|
+
|
|
80
|
+
`queue` is the single accessor for the underlying `SlickQueue`, as `stream_buffer` is for
|
|
81
|
+
`ProducerBuffer` (C++ has both `queue()` and `queue_ptr()`). `size`, `items_per_slot`,
|
|
82
|
+
`own_buffer` and `use_shm` are properties, matching `SlickQueue`'s attributes.
|
|
83
|
+
|
|
84
|
+
C++ `slick::producer_kind::stream_buffer` / `::queue` is `ProducerKind.STREAM_BUFFER` /
|
|
85
|
+
`ProducerKind.QUEUE`, on each handle as `.kind`.
|
|
86
|
+
|
|
87
|
+
The payload-retention check (a queue message is skipped once the reservation counter is more than
|
|
88
|
+
one ring past it) runs after `read()` has copied the bytes, so in Python it covers the copy handed
|
|
89
|
+
back; in C++, which hands back a pointer, a producer can still overwrite the bytes after the check.
|
|
90
|
+
|
|
60
91
|
## Producer lookup
|
|
61
92
|
|
|
62
|
-
C++ uses
|
|
63
|
-
plain dict for all ids (already O(1) — the dense-path
|
|
64
|
-
in Python)
|
|
93
|
+
C++ uses one dense vector of both kinds for producer ids < 4096 with a hash-map fallback;
|
|
94
|
+
Python uses one plain dict of both kinds for all ids (already O(1) — the dense-path
|
|
95
|
+
micro-optimization is meaningless in Python), and each handle dereferences its own records where
|
|
96
|
+
C++ branches on the kind.
|
|
65
97
|
|
|
66
|
-
`get_producer_buffers()`
|
|
67
|
-
read-only `types.MappingProxyType` over the same
|
|
68
|
-
|
|
69
|
-
|
|
98
|
+
`get_producer_buffers()` and `get_producer_queues()` return `const` map references in C++;
|
|
99
|
+
Python returns cached read-only `types.MappingProxyType`s over the same dicts — mutation raises,
|
|
100
|
+
later registrations still show through, and the call allocates nothing. Iteration order is
|
|
101
|
+
insertion order, which C++'s `unordered_map` does not promise.
|
|
70
102
|
|
|
71
103
|
## Traits are a constructor argument, not a template parameter
|
|
72
104
|
|
|
@@ -122,24 +154,30 @@ either way. All counters are per-instance, not shared through the segments.
|
|
|
122
154
|
traits with `count_loss` off, so that counter only moves for reads the caller makes directly
|
|
123
155
|
through `producer.stream_buffer.read(cursor, traits)`.
|
|
124
156
|
|
|
125
|
-
|
|
157
|
+
`ProducerQueue.loss_count()` is the inner queue's own counter on both sides, and the multiplexer's
|
|
158
|
+
lapped dereferences reach it on both sides. It moves by default in Python only because
|
|
159
|
+
slick-queue-py's default traits count; in a C++ release build of the default configuration it
|
|
160
|
+
reads 0.
|
|
161
|
+
|
|
162
|
+
## Producer handles are not movable in C++
|
|
126
163
|
|
|
127
|
-
C++ deletes
|
|
128
|
-
pointer to the same object its `shared_ptr` names, and moving one out
|
|
129
|
-
at an emptied object. Python has no moves and rebinding a name copies a
|
|
130
|
-
nothing to delete — `ProducerBuffer`
|
|
164
|
+
C++ deletes the copy and move operations of `producer_buffer` and `producer_queue`, because the
|
|
165
|
+
multiplexer keeps a raw pointer to the same object its `shared_ptr` names, and moving one out
|
|
166
|
+
would leave both pointing at an emptied object. Python has no moves and rebinding a name copies a
|
|
167
|
+
reference, so there is nothing to delete — `ProducerBuffer` and `ProducerQueue` have no
|
|
168
|
+
equivalent restriction.
|
|
131
169
|
|
|
132
170
|
## Lifecycle
|
|
133
171
|
|
|
134
172
|
Python adds explicit lifecycle management that C++ handles via RAII:
|
|
135
173
|
|
|
136
|
-
- `close()` — detach from all producer segments and the queue segment; the
|
|
137
|
-
kept, so a later `unlink()` still reaches every producer
|
|
138
|
-
- `unlink()` — delete the queue segment and all registered producers' segments
|
|
139
|
-
after `close()` (close every handle, then unlink once), and a no-op when repeated
|
|
174
|
+
- `close()` — detach from all producer segments, of both kinds, and the queue segment; the
|
|
175
|
+
registration tables are kept, so a later `unlink()` still reaches every producer
|
|
176
|
+
- `unlink()` — delete the queue segment and all registered producers' segments, of both kinds;
|
|
177
|
+
valid before or after `close()` (close every handle, then unlink once), and a no-op when repeated
|
|
140
178
|
- context manager support: `with StreamBufferMultiplexer(...) as mux: ...`
|
|
141
|
-
- `get_shm_name()` — on the multiplexer (queue segment) and on each `ProducerBuffer
|
|
142
|
-
the exact names to pass to C++ (`/`-prefixed on POSIX)
|
|
179
|
+
- `get_shm_name()` — on the multiplexer (queue segment) and on each `ProducerBuffer` and
|
|
180
|
+
`ProducerQueue`, the exact names to pass to C++ (`/`-prefixed on POSIX)
|
|
143
181
|
|
|
144
182
|
`remove(name)` is the one lifecycle call both sides share, with the same contract: unlink a
|
|
145
183
|
segment by name without attaching to it, returning `True` on success and, on Windows, always
|
{slick_stream_buffer_multiplexer_py-2.0.0 → slick_stream_buffer_multiplexer_py-2.2.0}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: slick-stream-buffer-multiplexer-py
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.2.0
|
|
4
4
|
Summary: Lock-free MPMC byte stream multiplexer with C++ interoperability via shared memory
|
|
5
5
|
Home-page: https://github.com/SlickQuant/slick-stream-buffer-multiplexer-py
|
|
6
6
|
Author: Slick Quant
|
|
@@ -28,7 +28,7 @@ Classifier: Operating System :: MacOS
|
|
|
28
28
|
Requires-Python: >=3.8
|
|
29
29
|
Description-Content-Type: text/markdown
|
|
30
30
|
License-File: LICENSE
|
|
31
|
-
Requires-Dist: slick-queue-py>=2.
|
|
31
|
+
Requires-Dist: slick-queue-py>=2.1.0
|
|
32
32
|
Requires-Dist: slick-stream-buffer-py>=2.0.0
|
|
33
33
|
Dynamic: author
|
|
34
34
|
Dynamic: home-page
|
|
@@ -45,29 +45,36 @@ consumers can be mixed across processes, fanning into the same shared memory seg
|
|
|
45
45
|
every peer of a shared queue agrees on `enable_read_last` (see
|
|
46
46
|
[`enable_read_last` must match on every peer](#enable_read_last-must-match-on-every-peer)).
|
|
47
47
|
|
|
48
|
-
Tracks C++ `slick/stream_buffer_multiplexer.hpp` **v2.
|
|
49
|
-
2.
|
|
48
|
+
Tracks C++ `slick/stream_buffer_multiplexer.hpp` **v2.2.0**, and requires slick-queue-py >=
|
|
49
|
+
2.1.0 and slick-stream-buffer-py >= 2.0.0. The shared-memory formats are unchanged
|
|
50
50
|
(`'SLQ1'` / `'SSB1'`), so segments written by v0.1.0 or by a C++ v1.x process are still
|
|
51
51
|
readable with the default traits.
|
|
52
52
|
|
|
53
53
|
## Concept
|
|
54
54
|
|
|
55
|
-
Each producer owns its own
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
[slick-
|
|
59
|
-
|
|
60
|
-
|
|
55
|
+
Each producer owns its own byte ring, of one of two kinds, freely mixed under one multiplexer and
|
|
56
|
+
one `producer_id` space:
|
|
57
|
+
|
|
58
|
+
- a [slick-stream-buffer](https://github.com/SlickQuant/slick-stream-buffer) (an
|
|
59
|
+
independently-sized byte ring with per-producer lap/loss detection), written with
|
|
60
|
+
`prepare`/`commit`/`consume` from one thread;
|
|
61
|
+
- a byte [slick-queue](https://github.com/SlickQuant/slick-queue) (`element_size=1`, the C++
|
|
62
|
+
`slick::queue<uint8_t>`), written with `reserve`/`publish`, which several threads may do at once.
|
|
63
|
+
|
|
64
|
+
Publishing through either additionally publishes a small 16-byte `{sequence, producer_id}` record
|
|
65
|
+
into one shared slick-queue, which acts as the lock-free MPMC fan-in / global ordering point.
|
|
66
|
+
Consumers read from the shared queue and dereference each record back into the matching
|
|
67
|
+
producer's ring, getting `(data, length, producer_id)` whichever kind published it.
|
|
61
68
|
|
|
62
69
|
```
|
|
63
70
|
Producer A ──prepare/commit/consume──▶ [StreamBuffer A] ──┐
|
|
64
71
|
Producer B ──prepare/commit/consume──▶ [StreamBuffer B] ──┼─▶ {sequence, producer_id}
|
|
65
|
-
Producer
|
|
72
|
+
Producer C ──reserve/write/publish───▶ [byte SlickQueue C]┘ 16-byte records
|
|
66
73
|
│
|
|
67
74
|
[shared SlickQueue]
|
|
68
75
|
│
|
|
69
76
|
consumers read(cursor) and dereference
|
|
70
|
-
into
|
|
77
|
+
into the ring of producer_id
|
|
71
78
|
```
|
|
72
79
|
|
|
73
80
|
Producers and the shared queue each independently choose local memory or shared memory (IPC).
|
|
@@ -77,7 +84,8 @@ records referencing other producer ids are silently skipped (not counted as loss
|
|
|
77
84
|
## Requirements
|
|
78
85
|
|
|
79
86
|
- Python 3.8+
|
|
80
|
-
- [slick-queue-py](https://github.com/SlickQuant/slick-queue-py) >= 2.
|
|
87
|
+
- [slick-queue-py](https://github.com/SlickQuant/slick-queue-py) >= 2.1.0 (queue producers need
|
|
88
|
+
its `items_per_slot`)
|
|
81
89
|
- [slick-stream-buffer-py](https://github.com/SlickQuant/slick-stream-buffer-py) >= 2.0.0
|
|
82
90
|
|
|
83
91
|
Both dependencies bundle a small C++ extension for `std::atomic` operations; this package itself
|
|
@@ -125,6 +133,45 @@ while True:
|
|
|
125
133
|
handle_message(rec.producer_id, rec.data, rec.length)
|
|
126
134
|
```
|
|
127
135
|
|
|
136
|
+
### Queue producers
|
|
137
|
+
|
|
138
|
+
A producer can be a byte slick-queue instead. Register it with `add_queue_producer()` and publish
|
|
139
|
+
through the `ProducerQueue` it returns, which has slick-queue's own producer interface:
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
# 1 MB byte queue, minimum 64 bytes per reservation (both must be powers of 2)
|
|
143
|
+
fills = mux.add_queue_producer(2, size=1 << 20, items_per_slot=64, name="fills")
|
|
144
|
+
|
|
145
|
+
index = fills.reserve(len(message)) # len(message) contiguous bytes; never blocks
|
|
146
|
+
fills[index][:len(message)] = message # fills[index] is a writable view from index onward
|
|
147
|
+
fills.publish(index, len(message)) # publish as ONE message + fan into the shared queue
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
One message is the bytes of one `reserve()`/`publish()` pair, and consumers get it exactly as they
|
|
151
|
+
get a stream buffer message. What differs is the producer side:
|
|
152
|
+
|
|
153
|
+
- **Several threads may publish through one `ProducerQueue`.** slick-queue is MPMC, and so is the
|
|
154
|
+
fan-in. Each thread's own messages stay in order.
|
|
155
|
+
- **Give a large queue an `items_per_slot`.** It is the minimum number of bytes one `reserve()`
|
|
156
|
+
consumes, and slick-queue keeps one 16-byte control slot per unit of it. Without it there is a
|
|
157
|
+
slot per byte - 16 MB of control for 1 MB of data. See
|
|
158
|
+
[slick-queue-py](https://github.com/SlickQuant/slick-queue-py#byte-buffers-and-items_per_slot).
|
|
159
|
+
- **A message is limited to the queue's size.** `reserve()` raises `RuntimeError` for a larger
|
|
160
|
+
one and `ValueError` for 0, as `SlickQueue.reserve()` does.
|
|
161
|
+
|
|
162
|
+
The forms mirror the C++ overloads: `add_queue_producer(id, size=...[, items_per_slot=...])` for
|
|
163
|
+
local memory, add `name=...` to create a segment, `add_queue_producer(id, name=...)` to open one
|
|
164
|
+
(its size and `items_per_slot` come from the segment), or `add_queue_producer(id, queue=q)` to
|
|
165
|
+
adopt a byte `SlickQueue` you already have. Only what is published through the handle is
|
|
166
|
+
multiplexed: a message put on an adopted queue directly reaches whoever reads that queue, but
|
|
167
|
+
`mux.read()` never returns it.
|
|
168
|
+
|
|
169
|
+
`producer_id` is one namespace across both kinds: an id registered with `add_producer()` cannot be
|
|
170
|
+
registered again with `add_queue_producer()`, or the other way round. `has_producer()` and
|
|
171
|
+
`producer_count()` cover both; `get_producer_queue()`, `find_producer_queue()` and
|
|
172
|
+
`get_producer_queues()` are the queue counterparts of the stream buffer accessors, and each kind's
|
|
173
|
+
accessors return `None` for the other kind's ids.
|
|
174
|
+
|
|
128
175
|
### Work-stealing consumers (each message to exactly one consumer)
|
|
129
176
|
|
|
130
177
|
```python
|
|
@@ -191,14 +238,17 @@ slick::basic_stream_buffer_multiplexer<no_read_last_traits> mux("mux_records");
|
|
|
191
238
|
|
|
192
239
|
The multiplexer itself never calls `read_last()` (`initial_reading_index()` reads the reservation
|
|
193
240
|
counter instead), so turning it off only drops one CAS from each `consume()`'s publish into the
|
|
194
|
-
shared queue.
|
|
241
|
+
shared queue. Stream buffer producer segments (`'SSB1'`) carry no such flag. Queue producers are
|
|
242
|
+
built with the multiplexer's traits, as in C++, so their segments carry the same marker and the
|
|
243
|
+
same rule applies to them.
|
|
195
244
|
|
|
196
245
|
## Three Loss Counters
|
|
197
246
|
|
|
198
247
|
| Counter | Meaning |
|
|
199
248
|
|---|---|
|
|
200
249
|
| `mux.loss_count()` | shared-queue wrap loss **plus** multiplexer-level loss (registered producers lapped before dereference); both terms switched by `traits.enable_loss_detection` |
|
|
201
|
-
| `
|
|
250
|
+
| `ProducerBuffer.loss_count()` | that producer's own inner-ring loss — **only** for reads the caller makes through `producer.stream_buffer.read(cursor, traits)` with counting traits |
|
|
251
|
+
| `ProducerQueue.loss_count()` | the inner queue's own counter — a diagnostic of that queue, **not** a record count (see below) |
|
|
202
252
|
| unregistered producer ids | **never counted** — silently skipped |
|
|
203
253
|
|
|
204
254
|
Only the multiplexer-level term is filtered by registration. The wrap-loss term counts entries
|
|
@@ -212,11 +262,30 @@ dereferences through traits with `count_loss` off, because `read()`'s counter me
|
|
|
212
262
|
a *sequential scan* jumped, which for a jump-read is a distance every later lapped dereference
|
|
213
263
|
would add all over again. The multiplexer counts one loss per lapped record instead.
|
|
214
264
|
|
|
215
|
-
`
|
|
216
|
-
|
|
265
|
+
`ProducerQueue.loss_count()` cannot be kept clean the same way: slick-queue configures loss
|
|
266
|
+
detection per queue, not per call, so with counting traits (slick-queue-py's default) every
|
|
267
|
+
dereference that finds its record lapped adds slick-queue's "how far has this slot moved on"
|
|
268
|
+
distance to it - and each later lapped dereference adds its own again. Ten one-byte messages
|
|
269
|
+
through a 4-byte queue lose 6 records, which `mux.loss_count()` reports as 6 while that queue's
|
|
270
|
+
own counter reads 32. Read loss off the multiplexer.
|
|
271
|
+
|
|
272
|
+
**A queue message is lost once its bytes may be overwritten, not only its slot.** Messages differ
|
|
273
|
+
in length, so a later reservation can cover an older message's bytes without landing on its
|
|
274
|
+
control slot: in an 8-byte queue, `"ijkl"` at index 8 overwrites `"AB"` at 0 and `"CD"` at 2 while
|
|
275
|
+
`"CD"`'s slot still names it. `read()` therefore also skips (and counts) any queue message the
|
|
276
|
+
reservation counter has moved more than one ring past. `read()` copies the bytes before it checks,
|
|
277
|
+
so the check covers the copy you get back. It is conservative, as in C++: a reservation that does
|
|
278
|
+
not fit before the end of the ring skips the rest of the lap, so the counter also passes indices
|
|
279
|
+
nothing wrote, and a message lying in that skipped tail is reported lost although its bytes are
|
|
280
|
+
intact.
|
|
281
|
+
|
|
282
|
+
`mux.get_producer_buffers()` returns every registered stream buffer producer keyed by
|
|
283
|
+
`producer_id`, and `mux.get_producer_queues()` every queue producer — read-only live views of the
|
|
284
|
+
registration tables, cheap enough for a monitoring loop:
|
|
217
285
|
|
|
218
286
|
```python
|
|
219
287
|
inner_loss = sum(p.loss_count() for p in mux.get_producer_buffers().values())
|
|
288
|
+
queues = {pid: p.size for pid, p in mux.get_producer_queues().items()}
|
|
220
289
|
```
|
|
221
290
|
|
|
222
291
|
## Shutdown
|
|
@@ -225,7 +294,7 @@ Close every handle, then unlink once — the usual `shared_memory` idiom:
|
|
|
225
294
|
|
|
226
295
|
```python
|
|
227
296
|
mux.close() # or leave a `with StreamBufferMultiplexer(...) as mux:` block
|
|
228
|
-
mux.unlink() # deletes the record queue and every registered producer's segment
|
|
297
|
+
mux.unlink() # deletes the record queue and every registered producer's segment, both kinds
|
|
229
298
|
```
|
|
230
299
|
|
|
231
300
|
`close()` keeps the registration table so `unlink()` can still reach each producer's segment, and
|
|
@@ -234,9 +303,9 @@ name. On Windows `unlink()` is a no-op — a section disappears once its last ha
|
|
|
234
303
|
|
|
235
304
|
## Stale-segment recovery
|
|
236
305
|
|
|
237
|
-
`StreamBufferMultiplexer.remove(name)` unlinks a segment by name — it works for
|
|
238
|
-
class creates, a shared record queue's
|
|
239
|
-
shared memory segments underneath. Use it when a previous run died mid-initialization and left a
|
|
306
|
+
`StreamBufferMultiplexer.remove(name)` unlinks a segment by name — it works for every kind this
|
|
307
|
+
class creates, a shared record queue's, a producer stream buffer's and a producer queue's, since
|
|
308
|
+
all are plain shared memory segments underneath. Use it when a previous run died mid-initialization and left a
|
|
240
309
|
segment wedged, which construction over that name reports by raising:
|
|
241
310
|
|
|
242
311
|
```python
|
|
@@ -256,6 +325,7 @@ The C++ side composes the identical primitives — either language can create an
|
|
|
256
325
|
|
|
257
326
|
slick::stream_buffer_multiplexer mux("mux_records"); // open queue created by Python
|
|
258
327
|
auto feed = mux.add_producer(0, "feed_a"); // open Python's producer segment
|
|
328
|
+
auto fills = mux.add_queue_producer(2, "fills"); // and its queue producer's
|
|
259
329
|
|
|
260
330
|
uint64_t cursor = mux.initial_reading_index();
|
|
261
331
|
for (;;) {
|
|
@@ -265,8 +335,12 @@ for (;;) {
|
|
|
265
335
|
}
|
|
266
336
|
```
|
|
267
337
|
|
|
268
|
-
Use `get_shm_name()` on the multiplexer (queue segment) and each producer
|
|
269
|
-
|
|
338
|
+
Use `get_shm_name()` on the multiplexer (queue segment) and each producer to get the exact names
|
|
339
|
+
to pass to C++ (POSIX names include the required leading `/`). Each side must register an id as
|
|
340
|
+
the kind it is: the record carries only `{sequence, producer_id}`, so the kind behind an id is part
|
|
341
|
+
of the same convention as its name. A consumer on C++ 2.1 or earlier, or on this package before
|
|
342
|
+
2.2.0, has no queue producers; it skips their records as unregistered, without counting them as
|
|
343
|
+
loss.
|
|
270
344
|
|
|
271
345
|
### Binary layout
|
|
272
346
|
|
|
@@ -274,16 +348,23 @@ The multiplexer adds no shared-memory structures of its own:
|
|
|
274
348
|
|
|
275
349
|
- The shared queue is a standard slick-queue segment (`'SLQ1'`, or `'SLQ0'` when created with
|
|
276
350
|
`enable_read_last` off; `element_size=16`) whose elements are
|
|
277
|
-
`uint64 sequence | uint32 producer_id | uint32 pad0`
|
|
278
|
-
|
|
351
|
+
`uint64 sequence | uint32 producer_id | uint32 pad0`; `sequence` is a stream buffer's record
|
|
352
|
+
sequence or a queue producer's reservation index
|
|
353
|
+
- Each stream buffer producer is a standard slick-stream-buffer segment (`'SSB1'`)
|
|
354
|
+
- Each queue producer is a standard slick-queue segment with `element_size=1`, marked like any
|
|
355
|
+
slick-queue segment: `'SLQ1'` by default, with the `items_per_slot` bit added (`'SLQ3'`) when
|
|
356
|
+
`items_per_slot` is not 1, and the read-last bit cleared (`'SLQ0'` / `'SLQ2'`) when created
|
|
357
|
+
with `enable_read_last` off
|
|
279
358
|
|
|
280
359
|
## Caveats (same as C++)
|
|
281
360
|
|
|
282
|
-
- `add_producer()`
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
361
|
+
- `add_producer()` and `add_queue_producer()` are single-threaded setup: call them before
|
|
362
|
+
producer/consumer threads start.
|
|
363
|
+
- A `producer_id` must fit the record's `uint32` field: both registration calls raise
|
|
364
|
+
`ValueError` for one outside `0..0xFFFFFFFF` (and `TypeError` for a non-integer), before any
|
|
365
|
+
segment is created.
|
|
366
|
+
- Each `ProducerBuffer`'s `prepare/commit/consume/...` must be called from a single thread. A
|
|
367
|
+
`ProducerQueue` has no such limit.
|
|
287
368
|
- Lossy semantics: slow consumers skip overwritten data (see the loss counters above).
|
|
288
369
|
Records are skipped correctly whatever the traits say; only the counters go quiet.
|
|
289
370
|
- A single message (one `consume()` call) is limited to < 4 GiB: `consume()` raises
|
|
@@ -298,6 +379,7 @@ See [API_DIFFERENCES.md](API_DIFFERENCES.md) for exact deviations from the C++ A
|
|
|
298
379
|
# Pure-Python tests (sibling repos slick-queue-py / slick-stream-buffer-py are
|
|
299
380
|
# picked up automatically from ../ if not pip-installed)
|
|
300
381
|
python tests/test_multiplexer.py
|
|
382
|
+
python tests/test_multiplexer_queue.py
|
|
301
383
|
python tests/test_multiplexer_mpmc.py
|
|
302
384
|
python tests/test_multiplexer_shm.py
|
|
303
385
|
|
{slick_stream_buffer_multiplexer_py-2.0.0 → slick_stream_buffer_multiplexer_py-2.2.0}/README.md
RENAMED
|
@@ -8,29 +8,36 @@ consumers can be mixed across processes, fanning into the same shared memory seg
|
|
|
8
8
|
every peer of a shared queue agrees on `enable_read_last` (see
|
|
9
9
|
[`enable_read_last` must match on every peer](#enable_read_last-must-match-on-every-peer)).
|
|
10
10
|
|
|
11
|
-
Tracks C++ `slick/stream_buffer_multiplexer.hpp` **v2.
|
|
12
|
-
2.
|
|
11
|
+
Tracks C++ `slick/stream_buffer_multiplexer.hpp` **v2.2.0**, and requires slick-queue-py >=
|
|
12
|
+
2.1.0 and slick-stream-buffer-py >= 2.0.0. The shared-memory formats are unchanged
|
|
13
13
|
(`'SLQ1'` / `'SSB1'`), so segments written by v0.1.0 or by a C++ v1.x process are still
|
|
14
14
|
readable with the default traits.
|
|
15
15
|
|
|
16
16
|
## Concept
|
|
17
17
|
|
|
18
|
-
Each producer owns its own
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
[slick-
|
|
22
|
-
|
|
23
|
-
|
|
18
|
+
Each producer owns its own byte ring, of one of two kinds, freely mixed under one multiplexer and
|
|
19
|
+
one `producer_id` space:
|
|
20
|
+
|
|
21
|
+
- a [slick-stream-buffer](https://github.com/SlickQuant/slick-stream-buffer) (an
|
|
22
|
+
independently-sized byte ring with per-producer lap/loss detection), written with
|
|
23
|
+
`prepare`/`commit`/`consume` from one thread;
|
|
24
|
+
- a byte [slick-queue](https://github.com/SlickQuant/slick-queue) (`element_size=1`, the C++
|
|
25
|
+
`slick::queue<uint8_t>`), written with `reserve`/`publish`, which several threads may do at once.
|
|
26
|
+
|
|
27
|
+
Publishing through either additionally publishes a small 16-byte `{sequence, producer_id}` record
|
|
28
|
+
into one shared slick-queue, which acts as the lock-free MPMC fan-in / global ordering point.
|
|
29
|
+
Consumers read from the shared queue and dereference each record back into the matching
|
|
30
|
+
producer's ring, getting `(data, length, producer_id)` whichever kind published it.
|
|
24
31
|
|
|
25
32
|
```
|
|
26
33
|
Producer A ──prepare/commit/consume──▶ [StreamBuffer A] ──┐
|
|
27
34
|
Producer B ──prepare/commit/consume──▶ [StreamBuffer B] ──┼─▶ {sequence, producer_id}
|
|
28
|
-
Producer
|
|
35
|
+
Producer C ──reserve/write/publish───▶ [byte SlickQueue C]┘ 16-byte records
|
|
29
36
|
│
|
|
30
37
|
[shared SlickQueue]
|
|
31
38
|
│
|
|
32
39
|
consumers read(cursor) and dereference
|
|
33
|
-
into
|
|
40
|
+
into the ring of producer_id
|
|
34
41
|
```
|
|
35
42
|
|
|
36
43
|
Producers and the shared queue each independently choose local memory or shared memory (IPC).
|
|
@@ -40,7 +47,8 @@ records referencing other producer ids are silently skipped (not counted as loss
|
|
|
40
47
|
## Requirements
|
|
41
48
|
|
|
42
49
|
- Python 3.8+
|
|
43
|
-
- [slick-queue-py](https://github.com/SlickQuant/slick-queue-py) >= 2.
|
|
50
|
+
- [slick-queue-py](https://github.com/SlickQuant/slick-queue-py) >= 2.1.0 (queue producers need
|
|
51
|
+
its `items_per_slot`)
|
|
44
52
|
- [slick-stream-buffer-py](https://github.com/SlickQuant/slick-stream-buffer-py) >= 2.0.0
|
|
45
53
|
|
|
46
54
|
Both dependencies bundle a small C++ extension for `std::atomic` operations; this package itself
|
|
@@ -88,6 +96,45 @@ while True:
|
|
|
88
96
|
handle_message(rec.producer_id, rec.data, rec.length)
|
|
89
97
|
```
|
|
90
98
|
|
|
99
|
+
### Queue producers
|
|
100
|
+
|
|
101
|
+
A producer can be a byte slick-queue instead. Register it with `add_queue_producer()` and publish
|
|
102
|
+
through the `ProducerQueue` it returns, which has slick-queue's own producer interface:
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
# 1 MB byte queue, minimum 64 bytes per reservation (both must be powers of 2)
|
|
106
|
+
fills = mux.add_queue_producer(2, size=1 << 20, items_per_slot=64, name="fills")
|
|
107
|
+
|
|
108
|
+
index = fills.reserve(len(message)) # len(message) contiguous bytes; never blocks
|
|
109
|
+
fills[index][:len(message)] = message # fills[index] is a writable view from index onward
|
|
110
|
+
fills.publish(index, len(message)) # publish as ONE message + fan into the shared queue
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
One message is the bytes of one `reserve()`/`publish()` pair, and consumers get it exactly as they
|
|
114
|
+
get a stream buffer message. What differs is the producer side:
|
|
115
|
+
|
|
116
|
+
- **Several threads may publish through one `ProducerQueue`.** slick-queue is MPMC, and so is the
|
|
117
|
+
fan-in. Each thread's own messages stay in order.
|
|
118
|
+
- **Give a large queue an `items_per_slot`.** It is the minimum number of bytes one `reserve()`
|
|
119
|
+
consumes, and slick-queue keeps one 16-byte control slot per unit of it. Without it there is a
|
|
120
|
+
slot per byte - 16 MB of control for 1 MB of data. See
|
|
121
|
+
[slick-queue-py](https://github.com/SlickQuant/slick-queue-py#byte-buffers-and-items_per_slot).
|
|
122
|
+
- **A message is limited to the queue's size.** `reserve()` raises `RuntimeError` for a larger
|
|
123
|
+
one and `ValueError` for 0, as `SlickQueue.reserve()` does.
|
|
124
|
+
|
|
125
|
+
The forms mirror the C++ overloads: `add_queue_producer(id, size=...[, items_per_slot=...])` for
|
|
126
|
+
local memory, add `name=...` to create a segment, `add_queue_producer(id, name=...)` to open one
|
|
127
|
+
(its size and `items_per_slot` come from the segment), or `add_queue_producer(id, queue=q)` to
|
|
128
|
+
adopt a byte `SlickQueue` you already have. Only what is published through the handle is
|
|
129
|
+
multiplexed: a message put on an adopted queue directly reaches whoever reads that queue, but
|
|
130
|
+
`mux.read()` never returns it.
|
|
131
|
+
|
|
132
|
+
`producer_id` is one namespace across both kinds: an id registered with `add_producer()` cannot be
|
|
133
|
+
registered again with `add_queue_producer()`, or the other way round. `has_producer()` and
|
|
134
|
+
`producer_count()` cover both; `get_producer_queue()`, `find_producer_queue()` and
|
|
135
|
+
`get_producer_queues()` are the queue counterparts of the stream buffer accessors, and each kind's
|
|
136
|
+
accessors return `None` for the other kind's ids.
|
|
137
|
+
|
|
91
138
|
### Work-stealing consumers (each message to exactly one consumer)
|
|
92
139
|
|
|
93
140
|
```python
|
|
@@ -154,14 +201,17 @@ slick::basic_stream_buffer_multiplexer<no_read_last_traits> mux("mux_records");
|
|
|
154
201
|
|
|
155
202
|
The multiplexer itself never calls `read_last()` (`initial_reading_index()` reads the reservation
|
|
156
203
|
counter instead), so turning it off only drops one CAS from each `consume()`'s publish into the
|
|
157
|
-
shared queue.
|
|
204
|
+
shared queue. Stream buffer producer segments (`'SSB1'`) carry no such flag. Queue producers are
|
|
205
|
+
built with the multiplexer's traits, as in C++, so their segments carry the same marker and the
|
|
206
|
+
same rule applies to them.
|
|
158
207
|
|
|
159
208
|
## Three Loss Counters
|
|
160
209
|
|
|
161
210
|
| Counter | Meaning |
|
|
162
211
|
|---|---|
|
|
163
212
|
| `mux.loss_count()` | shared-queue wrap loss **plus** multiplexer-level loss (registered producers lapped before dereference); both terms switched by `traits.enable_loss_detection` |
|
|
164
|
-
| `
|
|
213
|
+
| `ProducerBuffer.loss_count()` | that producer's own inner-ring loss — **only** for reads the caller makes through `producer.stream_buffer.read(cursor, traits)` with counting traits |
|
|
214
|
+
| `ProducerQueue.loss_count()` | the inner queue's own counter — a diagnostic of that queue, **not** a record count (see below) |
|
|
165
215
|
| unregistered producer ids | **never counted** — silently skipped |
|
|
166
216
|
|
|
167
217
|
Only the multiplexer-level term is filtered by registration. The wrap-loss term counts entries
|
|
@@ -175,11 +225,30 @@ dereferences through traits with `count_loss` off, because `read()`'s counter me
|
|
|
175
225
|
a *sequential scan* jumped, which for a jump-read is a distance every later lapped dereference
|
|
176
226
|
would add all over again. The multiplexer counts one loss per lapped record instead.
|
|
177
227
|
|
|
178
|
-
`
|
|
179
|
-
|
|
228
|
+
`ProducerQueue.loss_count()` cannot be kept clean the same way: slick-queue configures loss
|
|
229
|
+
detection per queue, not per call, so with counting traits (slick-queue-py's default) every
|
|
230
|
+
dereference that finds its record lapped adds slick-queue's "how far has this slot moved on"
|
|
231
|
+
distance to it - and each later lapped dereference adds its own again. Ten one-byte messages
|
|
232
|
+
through a 4-byte queue lose 6 records, which `mux.loss_count()` reports as 6 while that queue's
|
|
233
|
+
own counter reads 32. Read loss off the multiplexer.
|
|
234
|
+
|
|
235
|
+
**A queue message is lost once its bytes may be overwritten, not only its slot.** Messages differ
|
|
236
|
+
in length, so a later reservation can cover an older message's bytes without landing on its
|
|
237
|
+
control slot: in an 8-byte queue, `"ijkl"` at index 8 overwrites `"AB"` at 0 and `"CD"` at 2 while
|
|
238
|
+
`"CD"`'s slot still names it. `read()` therefore also skips (and counts) any queue message the
|
|
239
|
+
reservation counter has moved more than one ring past. `read()` copies the bytes before it checks,
|
|
240
|
+
so the check covers the copy you get back. It is conservative, as in C++: a reservation that does
|
|
241
|
+
not fit before the end of the ring skips the rest of the lap, so the counter also passes indices
|
|
242
|
+
nothing wrote, and a message lying in that skipped tail is reported lost although its bytes are
|
|
243
|
+
intact.
|
|
244
|
+
|
|
245
|
+
`mux.get_producer_buffers()` returns every registered stream buffer producer keyed by
|
|
246
|
+
`producer_id`, and `mux.get_producer_queues()` every queue producer — read-only live views of the
|
|
247
|
+
registration tables, cheap enough for a monitoring loop:
|
|
180
248
|
|
|
181
249
|
```python
|
|
182
250
|
inner_loss = sum(p.loss_count() for p in mux.get_producer_buffers().values())
|
|
251
|
+
queues = {pid: p.size for pid, p in mux.get_producer_queues().items()}
|
|
183
252
|
```
|
|
184
253
|
|
|
185
254
|
## Shutdown
|
|
@@ -188,7 +257,7 @@ Close every handle, then unlink once — the usual `shared_memory` idiom:
|
|
|
188
257
|
|
|
189
258
|
```python
|
|
190
259
|
mux.close() # or leave a `with StreamBufferMultiplexer(...) as mux:` block
|
|
191
|
-
mux.unlink() # deletes the record queue and every registered producer's segment
|
|
260
|
+
mux.unlink() # deletes the record queue and every registered producer's segment, both kinds
|
|
192
261
|
```
|
|
193
262
|
|
|
194
263
|
`close()` keeps the registration table so `unlink()` can still reach each producer's segment, and
|
|
@@ -197,9 +266,9 @@ name. On Windows `unlink()` is a no-op — a section disappears once its last ha
|
|
|
197
266
|
|
|
198
267
|
## Stale-segment recovery
|
|
199
268
|
|
|
200
|
-
`StreamBufferMultiplexer.remove(name)` unlinks a segment by name — it works for
|
|
201
|
-
class creates, a shared record queue's
|
|
202
|
-
shared memory segments underneath. Use it when a previous run died mid-initialization and left a
|
|
269
|
+
`StreamBufferMultiplexer.remove(name)` unlinks a segment by name — it works for every kind this
|
|
270
|
+
class creates, a shared record queue's, a producer stream buffer's and a producer queue's, since
|
|
271
|
+
all are plain shared memory segments underneath. Use it when a previous run died mid-initialization and left a
|
|
203
272
|
segment wedged, which construction over that name reports by raising:
|
|
204
273
|
|
|
205
274
|
```python
|
|
@@ -219,6 +288,7 @@ The C++ side composes the identical primitives — either language can create an
|
|
|
219
288
|
|
|
220
289
|
slick::stream_buffer_multiplexer mux("mux_records"); // open queue created by Python
|
|
221
290
|
auto feed = mux.add_producer(0, "feed_a"); // open Python's producer segment
|
|
291
|
+
auto fills = mux.add_queue_producer(2, "fills"); // and its queue producer's
|
|
222
292
|
|
|
223
293
|
uint64_t cursor = mux.initial_reading_index();
|
|
224
294
|
for (;;) {
|
|
@@ -228,8 +298,12 @@ for (;;) {
|
|
|
228
298
|
}
|
|
229
299
|
```
|
|
230
300
|
|
|
231
|
-
Use `get_shm_name()` on the multiplexer (queue segment) and each producer
|
|
232
|
-
|
|
301
|
+
Use `get_shm_name()` on the multiplexer (queue segment) and each producer to get the exact names
|
|
302
|
+
to pass to C++ (POSIX names include the required leading `/`). Each side must register an id as
|
|
303
|
+
the kind it is: the record carries only `{sequence, producer_id}`, so the kind behind an id is part
|
|
304
|
+
of the same convention as its name. A consumer on C++ 2.1 or earlier, or on this package before
|
|
305
|
+
2.2.0, has no queue producers; it skips their records as unregistered, without counting them as
|
|
306
|
+
loss.
|
|
233
307
|
|
|
234
308
|
### Binary layout
|
|
235
309
|
|
|
@@ -237,16 +311,23 @@ The multiplexer adds no shared-memory structures of its own:
|
|
|
237
311
|
|
|
238
312
|
- The shared queue is a standard slick-queue segment (`'SLQ1'`, or `'SLQ0'` when created with
|
|
239
313
|
`enable_read_last` off; `element_size=16`) whose elements are
|
|
240
|
-
`uint64 sequence | uint32 producer_id | uint32 pad0`
|
|
241
|
-
|
|
314
|
+
`uint64 sequence | uint32 producer_id | uint32 pad0`; `sequence` is a stream buffer's record
|
|
315
|
+
sequence or a queue producer's reservation index
|
|
316
|
+
- Each stream buffer producer is a standard slick-stream-buffer segment (`'SSB1'`)
|
|
317
|
+
- Each queue producer is a standard slick-queue segment with `element_size=1`, marked like any
|
|
318
|
+
slick-queue segment: `'SLQ1'` by default, with the `items_per_slot` bit added (`'SLQ3'`) when
|
|
319
|
+
`items_per_slot` is not 1, and the read-last bit cleared (`'SLQ0'` / `'SLQ2'`) when created
|
|
320
|
+
with `enable_read_last` off
|
|
242
321
|
|
|
243
322
|
## Caveats (same as C++)
|
|
244
323
|
|
|
245
|
-
- `add_producer()`
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
324
|
+
- `add_producer()` and `add_queue_producer()` are single-threaded setup: call them before
|
|
325
|
+
producer/consumer threads start.
|
|
326
|
+
- A `producer_id` must fit the record's `uint32` field: both registration calls raise
|
|
327
|
+
`ValueError` for one outside `0..0xFFFFFFFF` (and `TypeError` for a non-integer), before any
|
|
328
|
+
segment is created.
|
|
329
|
+
- Each `ProducerBuffer`'s `prepare/commit/consume/...` must be called from a single thread. A
|
|
330
|
+
`ProducerQueue` has no such limit.
|
|
250
331
|
- Lossy semantics: slow consumers skip overwritten data (see the loss counters above).
|
|
251
332
|
Records are skipped correctly whatever the traits say; only the counters go quiet.
|
|
252
333
|
- A single message (one `consume()` call) is limited to < 4 GiB: `consume()` raises
|
|
@@ -261,6 +342,7 @@ See [API_DIFFERENCES.md](API_DIFFERENCES.md) for exact deviations from the C++ A
|
|
|
261
342
|
# Pure-Python tests (sibling repos slick-queue-py / slick-stream-buffer-py are
|
|
262
343
|
# picked up automatically from ../ if not pip-installed)
|
|
263
344
|
python tests/test_multiplexer.py
|
|
345
|
+
python tests/test_multiplexer_queue.py
|
|
264
346
|
python tests/test_multiplexer_mpmc.py
|
|
265
347
|
python tests/test_multiplexer_shm.py
|
|
266
348
|
|
{slick_stream_buffer_multiplexer_py-2.0.0 → slick_stream_buffer_multiplexer_py-2.2.0}/setup.py
RENAMED
|
@@ -52,7 +52,7 @@ setup(
|
|
|
52
52
|
keywords='stream-buffer, multiplexer, lock-free, atomic, shared-memory, ipc, multiprocessing, mpmc',
|
|
53
53
|
py_modules=['slick_stream_buffer_multiplexer_py'],
|
|
54
54
|
install_requires=[
|
|
55
|
-
'slick-queue-py>=2.
|
|
55
|
+
'slick-queue-py>=2.1.0',
|
|
56
56
|
'slick-stream-buffer-py>=2.0.0',
|
|
57
57
|
],
|
|
58
58
|
python_requires='>=3.8',
|