uring-api 0.1.0rc0__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.
@@ -0,0 +1,3 @@
1
+ include src/_uring_api.pyi
2
+ include src/uring_api/include/uring_api_capi.h
3
+ include tests/capi_client/uring_api_capi_client.c
@@ -0,0 +1,470 @@
1
+ Metadata-Version: 2.4
2
+ Name: uring-api
3
+ Version: 0.1.0rc0
4
+ Summary: Small Python wrapper around Linux io_uring
5
+ Author-email: Kristjan Valur Jonsson <sweskman@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/kristjanvalur/pytealet/tree/main/packages/uring_api
8
+ Project-URL: Repository, https://github.com/kristjanvalur/pytealet
9
+ Project-URL: Source, https://github.com/kristjanvalur/pytealet/tree/main/packages/uring_api
10
+ Project-URL: Issues, https://github.com/kristjanvalur/pytealet/issues
11
+ Keywords: io_uring,linux,async,proactor
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: POSIX :: Linux
15
+ Classifier: Programming Language :: C
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Classifier: Programming Language :: Python :: 3.15
23
+ Requires-Python: >=3.10
24
+ Description-Content-Type: text/markdown
25
+
26
+ # uring-api
27
+
28
+ `uring-api` is a small Python wrapper around Linux `io_uring`.
29
+
30
+ The goal is deliberately modest: expose enough of the native ring lifecycle,
31
+ socket send/recv submission, completion waiting, and callback delivery to build
32
+ higher-level completion abstractions in Python. It does not implement an event
33
+ loop, scheduler, or asyncio compatibility layer.
34
+
35
+ Future work is tracked in [ROADMAP.md](ROADMAP.md), including queue resizing,
36
+ optional zero-copy receive models, and specialised kernel tuning.
37
+
38
+ ## Quick Check
39
+
40
+ ```python
41
+ import uring_api
42
+
43
+ print(uring_api.probe())
44
+
45
+ with uring_api.Ring() as ring:
46
+ print(ring.fd)
47
+ ```
48
+
49
+ ## Socket I/O
50
+
51
+ `Ring` currently exposes `submit_recv()`, `submit_recv_multishot()`,
52
+ `submit_send()`, `submit_send_zc()`, `submit_recvmsg()`, `submit_sendto()`,
53
+ `submit_sendmsg()`, `submit_sendmsg_zc()`, `submit_accept()`,
54
+ `submit_accept_multishot()`, `submit_connect()`, `submit_shutdown()`,
55
+ `submit_close()`, `submit_socket()`, and `wait()`. This is the complete baseline
56
+ for Python-oriented socket I/O in `uring-api`: normal sends and receives,
57
+ message-oriented operations, listener accept paths, connection setup, orderly
58
+ shutdown, fd creation/close, cancellation, and the practical multishot server
59
+ cases all have direct wrappers. Each submitted operation carries a Python
60
+ `user_data` object which comes back with its completion.
61
+
62
+ ```python
63
+ import socket
64
+ import uring_api
65
+
66
+ reader, writer = socket.socketpair()
67
+ try:
68
+ reader.setblocking(False)
69
+ writer.setblocking(False)
70
+
71
+ with uring_api.Ring() as ring:
72
+ token = {"operation": "greeting"}
73
+ buf = bytearray(5)
74
+ ring.submit_recv(reader.fileno(), buf, token)
75
+ writer.send(b"hello")
76
+
77
+ completion = ring.wait(1.0)
78
+
79
+ assert completion is not None
80
+ assert completion.user_data is token
81
+ assert bytes(buf) == b"hello"
82
+ print(completion.res, completion.result)
83
+ finally:
84
+ reader.close()
85
+ writer.close()
86
+ ```
87
+
88
+ For sends, `uring-api` keeps the exported buffer alive until the kernel reports
89
+ the completion. That avoids copying the outgoing payload into an internal bytes
90
+ object just to keep memory valid. `submit_send_zc()` uses
91
+ `IORING_OP_SEND_ZC`, while `submit_sendmsg_zc()` uses `IORING_OP_SENDMSG_ZC` for
92
+ the `sendmsg` shape. Their ordinary operation CQE is delivered as the submitted
93
+ `Completion`; the later `IORING_CQE_F_NOTIF` buffer-lifetime CQE is consumed
94
+ internally and releases the retained buffer.
95
+
96
+ `submit_shutdown()` is a socket operation and mirrors `shutdown(fd, how)`.
97
+ `submit_accept()` and `submit_accept_multishot()` accept optional accept flags;
98
+ pass `socket.SOCK_NONBLOCK | socket.SOCK_CLOEXEC` when accepted sockets should
99
+ be ready for proactor ownership without a follow-up `fcntl()` call.
100
+ `submit_close()` is lower-level: pass only a raw fd whose ownership has already
101
+ been transferred away from Python objects such as `socket.socket`, for example
102
+ with `detach()`. Otherwise, Python and the kernel may both believe they own the
103
+ same descriptor.
104
+
105
+ `submit_recv_multishot()` owns an internal provided-buffer ring for the pending
106
+ operation. Each receive CQE is copied into a new Python `bytes` object, the
107
+ selected kernel buffer is recycled right away, and the delivered completion gets
108
+ a `sequence` number so callback users can reconstruct receive order even when
109
+ worker threads dispatch completions out of order. Multishot completions are
110
+ numbered from `0`; normal one-shot completions also report `sequence == 0`.
111
+
112
+ The local liburing headers expose more socket-adjacent operations than this
113
+ wrapper publishes, but those are intentionally outside the core Python-oriented
114
+ surface. Readiness polling is optional for a completion proactor, fixed-buffer
115
+ send variants and public provided-buffer ownership are a poor fit for normal
116
+ Python buffer lifetimes, and socket command or NAPI controls are specialised
117
+ tuning hooks. The one receive-side extension still worth exploring is a
118
+ zero-copy multishot receive model with explicit leased-buffer ownership. Those
119
+ items are tracked in [ROADMAP.md](ROADMAP.md) rather than implied by `probe()`,
120
+ which remains a compact runtime availability check.
121
+
122
+ If the submission queue cannot provide another entry after flushing already
123
+ prepared work to the kernel, submit methods raise `SubmissionQueueFull`. Treat
124
+ that as backpressure rather than as a permanent ring failure: wait for
125
+ completions, then retry or let a higher-level proactor defer the submission.
126
+
127
+ ## Checking Availability
128
+
129
+ `io_uring` availability depends on more than the Python package importing
130
+ successfully. The kernel, container sandbox, seccomp profile, and process limits
131
+ can all affect whether a ring can actually be created.
132
+
133
+ Use `probe()` when you want a compact availability and capability dictionary:
134
+
135
+ ```python
136
+ import uring_api
137
+
138
+ probe = uring_api.probe()
139
+
140
+ if probe:
141
+ print("io_uring is available")
142
+ print("capabilities:", probe)
143
+ else:
144
+ print("io_uring is not available")
145
+ ```
146
+
147
+ Use `is_available()` when you only need a boolean:
148
+
149
+ ```python
150
+ import uring_api
151
+
152
+ if not uring_api.is_available():
153
+ raise RuntimeError("io_uring is not available in this environment")
154
+ ```
155
+
156
+ `probe()` creates a tiny temporary ring and closes it right away. If that fails,
157
+ it returns an empty dictionary. If it succeeds, the dictionary contains
158
+ `"available": True` plus named optional capabilities such as
159
+ `"IORING_ACCEPT_MULTISHOT"`, `"IORING_RECV_MULTISHOT"`, and
160
+ `"IORING_OP_SEND_ZC"` and `"IORING_OP_SENDMSG_ZC"`. Production code should
161
+ still handle `OSError` when it creates the real ring because limits or sandbox
162
+ policy may differ for larger settings.
163
+
164
+ Pass setup flags to `probe(flags=...)` to check whether this build and kernel
165
+ combination accepts a ring mode before using it for the real ring:
166
+
167
+ ```python
168
+ import uring_api
169
+
170
+ flags = uring_api.IORING_SETUP_SINGLE_ISSUER
171
+ probe = uring_api.probe(flags=flags)
172
+
173
+ if probe:
174
+ print("setup flags accepted")
175
+ else:
176
+ print("setup flags rejected")
177
+ ```
178
+
179
+ Some flags also impose application-level contracts. For example,
180
+ `IORING_SETUP_SINGLE_ISSUER` means callers must submit SQEs from a single owning
181
+ thread even on kernels that accept the flag.
182
+
183
+ The compiled liburing version fields report the header version used to build the
184
+ binary extension. This is useful in CI because Linux distribution images can
185
+ compile the same Python package against different liburing development packages
186
+ while still running on the hosted runner's kernel.
187
+
188
+ `submit_send_zc()` is best gated with `probe()["IORING_OP_SEND_ZC"]`. Unsupported
189
+ systems may accept the submission and then report `ENOTSUP` or `EOPNOTSUPP` in
190
+ the operation CQE, so checking a kernel version is less useful than submitting a
191
+ small runtime probe. `probe()` reports both `"IORING_OP_SEND_ZC"` and
192
+ `"IORING_OP_SENDMSG_ZC"` for caller convenience, and derives both from the
193
+ simpler `sendmsg_zc` UDP loopback probe with a bound local receiver. If your CI
194
+ image is expected to support these operations, make that expectation explicit:
195
+
196
+ ```bash
197
+ uv run --active python - <<'PY'
198
+ import uring_api
199
+
200
+ probe = uring_api.probe()
201
+ print(probe)
202
+ raise SystemExit(0 if probe.get("IORING_OP_SEND_ZC") and probe.get("IORING_OP_SENDMSG_ZC") else 1)
203
+ PY
204
+ ```
205
+
206
+ If the native extension cannot be imported after installation, importing
207
+ `uring_api` still succeeds and `probe()` returns `{}`. Source builds with
208
+ unsupported native dependencies warn and install the pure Python wrapper without
209
+ `_uring_api`.
210
+
211
+ The `IORING_ACCEPT_MULTISHOT` capability uses a runtime operation probe rather
212
+ than a kernel version check. It creates a private temporary ring and loopback
213
+ listener, submits one multishot accept request, connects a local client, and
214
+ checks whether the first accept completion keeps the request armed. If the build
215
+ headers do not expose the helper flag, the capability simply reports `False`.
216
+
217
+ The `IORING_RECV_MULTISHOT` capability is also checked with a runtime operation
218
+ probe because it requires newer kernel support than multishot accept. It creates
219
+ a private socket pair and provided-buffer ring, submits one multishot receive,
220
+ sends one byte, and reports `True` only if the first completion selects a buffer
221
+ and keeps the request armed with `IORING_CQE_F_MORE`.
222
+
223
+ ## Initialising a Ring
224
+
225
+ The current wrapper exposes the native ring lifecycle. A ring is a file
226
+ descriptor plus shared submission/completion queues owned by the process.
227
+
228
+ ```python
229
+ import uring_api
230
+
231
+ with uring_api.Ring(entries=8) as ring:
232
+ print("fd:", ring.fd)
233
+ print("kernel features:", ring.features)
234
+ print("submission entries:", ring.sq_entries)
235
+ print("completion entries:", ring.cq_entries)
236
+ ```
237
+
238
+ `entries` is the requested submission queue depth. The kernel may round or size
239
+ the actual submission and completion queues, so inspect `sq_entries` and
240
+ `cq_entries` after initialisation if the exact capacity matters.
241
+
242
+ Pass `flags=` to request setup modes that were accepted by `probe(flags=...)`:
243
+
244
+ ```python
245
+ import uring_api
246
+
247
+ flags = uring_api.IORING_SETUP_SINGLE_ISSUER
248
+
249
+ if uring_api.probe(flags=flags):
250
+ with uring_api.Ring(entries=8, flags=flags) as ring:
251
+ ...
252
+ ```
253
+
254
+ The constructor passes these flags to `io_uring_queue_init_params()` for the
255
+ real ring. The application is still responsible for the contracts implied by
256
+ each flag; for example, `IORING_SETUP_SINGLE_ISSUER` requires all submissions to
257
+ come from the owning thread.
258
+
259
+ If initialisation fails, the constructor raises `OSError`:
260
+
261
+ ```python
262
+ import errno
263
+ import uring_api
264
+
265
+ try:
266
+ ring = uring_api.Ring(entries=256)
267
+ except OSError as exc:
268
+ if exc.errno == errno.EPERM:
269
+ raise RuntimeError("io_uring is blocked by seccomp or policy") from exc
270
+ if exc.errno == errno.ENOMEM:
271
+ raise RuntimeError("io_uring could not allocate or pin the requested resources") from exc
272
+ raise
273
+ else:
274
+ try:
275
+ print(ring.fd)
276
+ finally:
277
+ ring.close()
278
+ ```
279
+
280
+ ## Threading Model
281
+
282
+ `Ring` deliberately stays close to liburing's shared-ring model, but the Python
283
+ object adds native locking around the parts that matter for normal use.
284
+
285
+ The intended baseline is simple:
286
+
287
+ - one thread may reap completions with `wait()`;
288
+ - other threads may call submit-side methods such as `submit_recv()`,
289
+ `submit_recv_multishot()`, `submit_send()`, `submit_send_zc()`,
290
+ `submit_recvmsg()`, `submit_sendto()`, `submit_sendmsg_zc()`,
291
+ `submit_accept()`, `submit_accept_multishot()`, `submit_connect()`, and
292
+ `break_wait()`;
293
+ - `break_wait()` is safe to call while another thread is blocked in `wait()`;
294
+ - multiple concurrent `wait()` calls are serialised by the `Ring` object;
295
+ - alternatively, callers may start their own Python threads and have each one
296
+ call `serve_completions()` to wait for completions and call the callback
297
+ directly.
298
+
299
+ `break_wait()` prepares and submits an internal NOP. When the reaper consumes that
300
+ completion, `wait()` returns `None` rather than a user completion.
301
+
302
+ Serving workers use the same receive side as `wait()`, so public `wait()` calls
303
+ raise `RuntimeError` while they are running. Each worker calls
304
+ `serve_completions()`, then loops until `stop_serving()` asks the service to
305
+ exit. Workers compete for an internal wait lock, so only one worker is inside
306
+ `io_uring_wait_cqe()` at a time, while another worker can dispatch a completion
307
+ callback.
308
+
309
+ `stop_serving()` asks workers to exit and wakes the active waiter with
310
+ `break_wait()`. The caller owns the threads, so the caller must join them before
311
+ closing the ring; `close()` and `__exit__()` raise while completion service is
312
+ still active. `reset_serving()` clears the stop flag so a fresh set of workers
313
+ can enter `serve_completions()` again. If a callback raises, the exception is
314
+ reported as unraisable and the worker group exits.
315
+
316
+ Native C clients can register a worker-thread callback through the C API. When a
317
+ C callback is present, the serving worker calls it instead of `Ring.callback`;
318
+ otherwise it falls back to the Python callback property.
319
+
320
+ ```python
321
+ import uring_api
322
+ import threading
323
+
324
+
325
+ def delivered(completion):
326
+ print(completion.user_data, completion.res, completion.result)
327
+
328
+
329
+ with uring_api.Ring() as ring:
330
+ ring.callback = delivered
331
+ threads = [threading.Thread(target=ring.serve_completions) for _ in range(2)]
332
+ for thread in threads:
333
+ thread.start()
334
+ try:
335
+ ring.submit_recv(fd, bytearray(4096), 200)
336
+ finally:
337
+ ring.stop_serving()
338
+ for thread in threads:
339
+ thread.join()
340
+ ```
341
+
342
+ `close()` is still an owner-coordinated shutdown operation for submissions. Do
343
+ not close a ring while another thread may submit new user operations.
344
+
345
+ ## C API
346
+
347
+ Native clients can include `uring_api_capi.h` and import `_uring_api._C_API` with
348
+ `PyCapsule_Import()`. Use `uring_api.get_include()` to find the installed header
349
+ directory when compiling an extension module.
350
+
351
+ The capsule currently exposes:
352
+
353
+ - `abi_version`, `struct_size`, and `feature_flags` for compatibility checks;
354
+ - `compiled_liburing_major` and `compiled_liburing_minor` for build-time header
355
+ visibility;
356
+ - `probe(entries, flags)`, which returns a new reference to the same flat
357
+ availability and capability dictionary as `_uring_api.probe()`;
358
+ - `ring_new()`, lifecycle helpers, metadata helpers, `ring_submit_recv()`,
359
+ `ring_submit_recv_multishot()`, `ring_submit_send()`,
360
+ `ring_submit_send_zc()`, `ring_submit_recvmsg()`, `ring_submit_sendto()`,
361
+ `ring_submit_sendmsg()`, `ring_submit_sendmsg_zc()`, `ring_submit_accept()`,
362
+ `ring_submit_accept_multishot()`, `ring_submit_connect()`,
363
+ `ring_submit_shutdown()`, `ring_submit_close()`, `ring_submit_socket()`,
364
+ `ring_break_wait()`, and `ring_wait()`;
365
+ - `ring_set_callback()`, `ring_set_c_callback()`, `ring_serve_completions()`,
366
+ `ring_stop_serving()`, and `ring_reset_serving()` for completion-service
367
+ control;
368
+ - `completion_check()`, `completion_user_data()`, `completion_res()`,
369
+ `completion_flags()`, `completion_sequence()`, and `completion_result()`
370
+ for native completion inspection.
371
+
372
+ Check `URING_API_CAPI_FEATURE_CORE` before calling the function table. The flag
373
+ describes the capsule API surface, not runtime kernel support for individual
374
+ operations. Use `probe()` to check whether this process can create a ring and to
375
+ read runtime support for optional operation helpers from the returned flat
376
+ dictionary. A C completion callback receives the ring object, the completion
377
+ object, and the supplied `user_data`. Return `0` for success; return a negative
378
+ value with a Python exception set to report an unraisable error and stop the
379
+ serving worker group.
380
+
381
+ ## Choosing Ring Sizes
382
+
383
+ Ring sizing is about queue depth, not payload buffer size. A modest application
384
+ can start with a small number of in-flight operations; a server usually wants
385
+ enough entries to cover its expected concurrent I/O without constantly draining
386
+ and refilling the ring.
387
+
388
+ Typical starting points:
389
+
390
+ | Use case | Suggested entries | Notes |
391
+ | --- | ---: | --- |
392
+ | Availability probe | 2 | Enough to prove the kernel will create a ring. |
393
+ | Modest local I/O | 8-32 | Good for simple tools and initial experiments. |
394
+ | Concurrent client work | 64-256 | Enough room for batches without large memory pressure. |
395
+ | Server-style I/O | 512-4096 | Needs deliberate resource-limit checks and backpressure. |
396
+
397
+ For now, `uring-api` does not register fixed buffers. When those are added, ring
398
+ entries and registered buffers should be configured separately:
399
+
400
+ - ring entries control how many operations can be submitted or completed at
401
+ once;
402
+ - registered buffers control how much memory the kernel pins for direct I/O or
403
+ zero-copy style operation;
404
+ - large registered buffer pools can exceed `RLIMIT_MEMLOCK` even when ring
405
+ creation itself succeeds.
406
+
407
+ That distinction matters. During probing, a 64 MiB fixed-buffer pool exceeded a
408
+ default 64 MiB memlock limit because the limit must cover the pinned payload
409
+ memory plus kernel/accounting overhead.
410
+
411
+ You can inspect the process limit before choosing future buffer-pool sizes:
412
+
413
+ ```python
414
+ import resource
415
+
416
+ soft, hard = resource.getrlimit(resource.RLIMIT_MEMLOCK)
417
+
418
+ print("memlock soft limit:", soft)
419
+ print("memlock hard limit:", hard)
420
+ ```
421
+
422
+ For a future registered-buffer API, size the pool explicitly rather than
423
+ assuming the largest useful value is safe:
424
+
425
+ ```python
426
+ buffer_size = 16 * 1024
427
+ buffer_count = 256
428
+ pool_bytes = buffer_size * buffer_count
429
+
430
+ print("planned pinned buffer pool:", pool_bytes)
431
+ ```
432
+
433
+ Good default profiles for that future layer would look something like:
434
+
435
+ | Profile | Ring entries | Buffer size | Buffer count | Pinned bytes |
436
+ | --- | ---: | ---: | ---: | ---: |
437
+ | modest | 32 | 16 KiB | 64 | 1 MiB |
438
+ | interactive | 128 | 16 KiB | 256 | 4 MiB |
439
+ | server | 1024 | 64 KiB | 1024 | 64 MiB |
440
+
441
+ The server profile is intentionally near the common default memlock limit on
442
+ some systems. In practice, leave headroom or raise the limit before registering
443
+ that much memory.
444
+
445
+ ## Containers and Limits
446
+
447
+ Containers may block `io_uring_setup()` even when the host kernel supports it.
448
+ For example, Docker's default seccomp profile commonly rejects ring creation
449
+ with `EPERM`. A less restricted profile may be required for development.
450
+
451
+ Large future registered-buffer pools may also require raising `RLIMIT_MEMLOCK`.
452
+ Prefer smaller buffers while developing the operation model, then make server
453
+ profiles opt-in and explicit.
454
+
455
+ ## Build Requirements
456
+
457
+ `uring-api` links against system `liburing`:
458
+
459
+ ```bash
460
+ sudo apt install liburing-dev
461
+ ```
462
+
463
+ The native extension requires `liburing >= 2.4`. Older headers do not expose the
464
+ version macros we use for build-time validation, and they also predate the data
465
+ and ring entry helpers used by the extension. On Ubuntu, that means
466
+ `ubuntu-23.10` or newer from distro packages; `ubuntu-22.04` needs a newer
467
+ liburing installed from another source to build `_uring_api`.
468
+
469
+ The extension uses multi-phase module initialisation and declares itself safe to
470
+ import without enabling the GIL on free-threaded CPython builds.