uhttp-workers 1.1.0__tar.gz → 1.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.
Files changed (25) hide show
  1. {uhttp_workers-1.1.0/uhttp_workers.egg-info → uhttp_workers-1.2.0}/PKG-INFO +94 -1
  2. {uhttp_workers-1.1.0 → uhttp_workers-1.2.0}/README.md +93 -0
  3. uhttp_workers-1.2.0/examples/sse_workers.py +80 -0
  4. {uhttp_workers-1.1.0 → uhttp_workers-1.2.0}/tests/test_dispatcher.py +139 -0
  5. uhttp_workers-1.2.0/tests/test_worker.py +629 -0
  6. {uhttp_workers-1.1.0 → uhttp_workers-1.2.0}/uhttp/workers.py +156 -10
  7. {uhttp_workers-1.1.0 → uhttp_workers-1.2.0/uhttp_workers.egg-info}/PKG-INFO +94 -1
  8. {uhttp_workers-1.1.0 → uhttp_workers-1.2.0}/uhttp_workers.egg-info/SOURCES.txt +1 -0
  9. uhttp_workers-1.1.0/tests/test_worker.py +0 -327
  10. {uhttp_workers-1.1.0 → uhttp_workers-1.2.0}/.github/workflows/publish.yml +0 -0
  11. {uhttp_workers-1.1.0 → uhttp_workers-1.2.0}/.github/workflows/tests.yml +0 -0
  12. {uhttp_workers-1.1.0 → uhttp_workers-1.2.0}/.gitignore +0 -0
  13. {uhttp_workers-1.1.0 → uhttp_workers-1.2.0}/examples/simple_workers.py +0 -0
  14. {uhttp_workers-1.1.0 → uhttp_workers-1.2.0}/examples/static/index.html +0 -0
  15. {uhttp_workers-1.1.0 → uhttp_workers-1.2.0}/pyproject.toml +0 -0
  16. {uhttp_workers-1.1.0 → uhttp_workers-1.2.0}/setup.cfg +0 -0
  17. {uhttp_workers-1.1.0 → uhttp_workers-1.2.0}/tests/__init__.py +0 -0
  18. {uhttp_workers-1.1.0 → uhttp_workers-1.2.0}/tests/test_api_handler.py +0 -0
  19. {uhttp_workers-1.1.0 → uhttp_workers-1.2.0}/tests/test_decorators.py +0 -0
  20. {uhttp_workers-1.1.0 → uhttp_workers-1.2.0}/tests/test_pattern_matching.py +0 -0
  21. {uhttp_workers-1.1.0 → uhttp_workers-1.2.0}/tests/test_request_response.py +0 -0
  22. {uhttp_workers-1.1.0 → uhttp_workers-1.2.0}/tests/test_worker_pool.py +0 -0
  23. {uhttp_workers-1.1.0 → uhttp_workers-1.2.0}/uhttp_workers.egg-info/dependency_links.txt +0 -0
  24. {uhttp_workers-1.1.0 → uhttp_workers-1.2.0}/uhttp_workers.egg-info/requires.txt +0 -0
  25. {uhttp_workers-1.1.0 → uhttp_workers-1.2.0}/uhttp_workers.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: uhttp-workers
3
- Version: 1.1.0
3
+ Version: 1.2.0
4
4
  Summary: Multi-process worker dispatcher built on uhttp-server
5
5
  Author-email: Pavel Revak <pavelrevak@gmail.com>
6
6
  License-Expression: MIT
@@ -277,8 +277,101 @@ def process(self, request):
277
277
 
278
278
  # return data with status
279
279
  return {'error': 'not found'}, 404
280
+
281
+ # defer response — worker continues accepting requests
282
+ return _workers.DEFERRED
283
+ ```
284
+
285
+ ### Deferred Responses
286
+
287
+ Return `DEFERRED` to skip immediate response. The worker stays in the select loop, accepts new requests, and sends the response later via `request.respond()`:
288
+
289
+ ```python
290
+ class MyWorker(_workers.Worker):
291
+ def setup(self):
292
+ self._jobs = {}
293
+
294
+ @_workers.api('/process', 'POST')
295
+ def process(self, request):
296
+ job_id = start_background_work(request.data)
297
+ self._jobs[job_id] = request
298
+ return _workers.DEFERRED
299
+
300
+ def on_work_done(self, job_id, result):
301
+ request = self._jobs.pop(job_id)
302
+ request.respond(data={'result': result})
303
+ ```
304
+
305
+ Note: deferred requests are still subject to dispatcher timeout — call `self.keep_alive()` periodically to prevent 504.
306
+
307
+ ### Keep Alive
308
+
309
+ Call `self.keep_alive()` during long operations to reset both the request timeout and stuck worker detection:
310
+
311
+ ```python
312
+ @_workers.api('/export', 'POST')
313
+ def export(self, request):
314
+ for chunk in generate_large_export():
315
+ self.keep_alive()
316
+ return {'status': 'done'}
317
+ ```
318
+
319
+ ### Server-Sent Events (SSE)
320
+
321
+ Stream events to clients using the same API as `uhttp.server.Client`:
322
+
323
+ ```python
324
+ class MyWorker(_workers.Worker):
325
+ def setup(self):
326
+ self._subscribers = {}
327
+
328
+ @_workers.api('/events', 'GET')
329
+ def events(self, request):
330
+ request.response_stream() # sends headers, keeps connection open
331
+ self._subscribers[request.request_id] = request
332
+ return _workers.DEFERRED
333
+
334
+ def notify(self, data):
335
+ for req in self._subscribers.values():
336
+ req.send_event(data=data, event='update')
337
+
338
+ def on_disconnect(self, request_id):
339
+ self._subscribers.pop(request_id, None)
340
+ ```
341
+
342
+ Available streaming methods on `Request`:
343
+
344
+ | Method | Description |
345
+ |--------|-------------|
346
+ | `response_stream(content_type, headers, cookies)` | Start streaming (default: `text/event-stream`) |
347
+ | `send_event(data, event, event_id, retry)` | Send SSE event |
348
+ | `send_chunk(data)` | Send raw data chunk |
349
+ | `response_stream_end()` | End stream and close connection |
350
+
351
+ Streaming requests are excluded from dispatcher timeout expiration. When the client disconnects, the dispatcher notifies the worker via `on_disconnect(request_id)`.
352
+
353
+ ### Flow Control
354
+
355
+ Workers can stop accepting new requests when busy. Requests stay in the shared pool queue for other workers to pick up:
356
+
357
+ ```python
358
+ class MyWorker(_workers.Worker):
359
+ @_workers.api('/events', 'GET')
360
+ def subscribe(self, request):
361
+ request.response_stream()
362
+ self._subscribers[request.request_id] = request
363
+ if len(self._subscribers) >= 100:
364
+ self.pause()
365
+ return _workers.DEFERRED
366
+
367
+ def on_disconnect(self, request_id):
368
+ self._subscribers.pop(request_id, None)
369
+ if not self._accepting and len(self._subscribers) < 100:
370
+ self.resume()
280
371
  ```
281
372
 
373
+ `pause()` excludes the request queue from `select()` — the worker continues processing control messages, custom fd events, and `on_idle()`. `resume()` re-enables request acceptance.
374
+
282
375
  ## URL Patterns
283
376
 
284
377
  Dispatcher uses prefix matching to route requests to pools:
@@ -263,8 +263,101 @@ def process(self, request):
263
263
 
264
264
  # return data with status
265
265
  return {'error': 'not found'}, 404
266
+
267
+ # defer response — worker continues accepting requests
268
+ return _workers.DEFERRED
269
+ ```
270
+
271
+ ### Deferred Responses
272
+
273
+ Return `DEFERRED` to skip immediate response. The worker stays in the select loop, accepts new requests, and sends the response later via `request.respond()`:
274
+
275
+ ```python
276
+ class MyWorker(_workers.Worker):
277
+ def setup(self):
278
+ self._jobs = {}
279
+
280
+ @_workers.api('/process', 'POST')
281
+ def process(self, request):
282
+ job_id = start_background_work(request.data)
283
+ self._jobs[job_id] = request
284
+ return _workers.DEFERRED
285
+
286
+ def on_work_done(self, job_id, result):
287
+ request = self._jobs.pop(job_id)
288
+ request.respond(data={'result': result})
289
+ ```
290
+
291
+ Note: deferred requests are still subject to dispatcher timeout — call `self.keep_alive()` periodically to prevent 504.
292
+
293
+ ### Keep Alive
294
+
295
+ Call `self.keep_alive()` during long operations to reset both the request timeout and stuck worker detection:
296
+
297
+ ```python
298
+ @_workers.api('/export', 'POST')
299
+ def export(self, request):
300
+ for chunk in generate_large_export():
301
+ self.keep_alive()
302
+ return {'status': 'done'}
303
+ ```
304
+
305
+ ### Server-Sent Events (SSE)
306
+
307
+ Stream events to clients using the same API as `uhttp.server.Client`:
308
+
309
+ ```python
310
+ class MyWorker(_workers.Worker):
311
+ def setup(self):
312
+ self._subscribers = {}
313
+
314
+ @_workers.api('/events', 'GET')
315
+ def events(self, request):
316
+ request.response_stream() # sends headers, keeps connection open
317
+ self._subscribers[request.request_id] = request
318
+ return _workers.DEFERRED
319
+
320
+ def notify(self, data):
321
+ for req in self._subscribers.values():
322
+ req.send_event(data=data, event='update')
323
+
324
+ def on_disconnect(self, request_id):
325
+ self._subscribers.pop(request_id, None)
326
+ ```
327
+
328
+ Available streaming methods on `Request`:
329
+
330
+ | Method | Description |
331
+ |--------|-------------|
332
+ | `response_stream(content_type, headers, cookies)` | Start streaming (default: `text/event-stream`) |
333
+ | `send_event(data, event, event_id, retry)` | Send SSE event |
334
+ | `send_chunk(data)` | Send raw data chunk |
335
+ | `response_stream_end()` | End stream and close connection |
336
+
337
+ Streaming requests are excluded from dispatcher timeout expiration. When the client disconnects, the dispatcher notifies the worker via `on_disconnect(request_id)`.
338
+
339
+ ### Flow Control
340
+
341
+ Workers can stop accepting new requests when busy. Requests stay in the shared pool queue for other workers to pick up:
342
+
343
+ ```python
344
+ class MyWorker(_workers.Worker):
345
+ @_workers.api('/events', 'GET')
346
+ def subscribe(self, request):
347
+ request.response_stream()
348
+ self._subscribers[request.request_id] = request
349
+ if len(self._subscribers) >= 100:
350
+ self.pause()
351
+ return _workers.DEFERRED
352
+
353
+ def on_disconnect(self, request_id):
354
+ self._subscribers.pop(request_id, None)
355
+ if not self._accepting and len(self._subscribers) < 100:
356
+ self.resume()
266
357
  ```
267
358
 
359
+ `pause()` excludes the request queue from `select()` — the worker continues processing control messages, custom fd events, and `on_idle()`. `resume()` re-enables request acceptance.
360
+
268
361
  ## URL Patterns
269
362
 
270
363
  Dispatcher uses prefix matching to route requests to pools:
@@ -0,0 +1,80 @@
1
+ """SSE (Server-Sent Events) example with deferred responses.
2
+
3
+ Demonstrates:
4
+ - SSE streaming from worker to client
5
+ - Deferred responses (DEFERRED sentinel)
6
+ - Client disconnect handling (on_disconnect)
7
+ - keep_alive() for long operations
8
+
9
+ Test with:
10
+ curl -N http://localhost:8080/events
11
+ curl -N http://localhost:8080/events?channel=news
12
+ curl -X POST http://localhost:8080/publish -d '{"msg": "hello"}'
13
+ curl -X POST http://localhost:8080/publish -d '{"channel": "news", "msg": "breaking"}'
14
+ """
15
+
16
+ import uhttp.workers as _workers
17
+
18
+
19
+ class EventWorker(_workers.Worker):
20
+ """Worker that manages SSE subscribers and publishes events."""
21
+
22
+ def setup(self):
23
+ self._subscribers = {} # request_id → (request, channel)
24
+
25
+ @_workers.api('/events', 'GET')
26
+ def subscribe(self, request):
27
+ channel = (request.query or {}).get('channel', 'default')
28
+ request.response_stream()
29
+ request.send_event(
30
+ data={'status': 'connected', 'channel': channel},
31
+ event='open')
32
+ self._subscribers[request.request_id] = (request, channel)
33
+ if len(self._subscribers) >= self.kwargs.get('max_subscribers', 100):
34
+ self.pause()
35
+ return _workers.DEFERRED
36
+
37
+ @_workers.api('/publish', 'POST')
38
+ def publish(self, request):
39
+ channel = (request.data or {}).get('channel', 'default')
40
+ msg = (request.data or {}).get('msg', '')
41
+ count = 0
42
+ for req, ch in self._subscribers.values():
43
+ if ch == channel:
44
+ req.send_event(data={'msg': msg}, event='message')
45
+ count += 1
46
+ return {'published': count, 'channel': channel}
47
+
48
+ def on_disconnect(self, request_id):
49
+ sub = self._subscribers.pop(request_id, None)
50
+ if sub:
51
+ self.log.info("Client disconnected: %d", request_id)
52
+ max_subs = self.kwargs.get('max_subscribers', 100)
53
+ if not self._accepting and len(self._subscribers) < max_subs:
54
+ self.resume()
55
+
56
+ def on_idle(self):
57
+ # periodic keepalive to all subscribers
58
+ for req, _ in list(self._subscribers.values()):
59
+ req.send_event(event='ping')
60
+
61
+
62
+ class MyDispatcher(_workers.Dispatcher):
63
+ @_workers.sync('/health')
64
+ def health(self, client, path_params):
65
+ client.respond({'status': 'ok'})
66
+
67
+
68
+ def main():
69
+ pool = _workers.WorkerPool(
70
+ EventWorker, num_workers=2,
71
+ log_level=_workers.LOG_INFO,
72
+ timeout=30)
73
+ dispatcher = MyDispatcher(
74
+ port=8080,
75
+ pools=[pool])
76
+ dispatcher.run()
77
+
78
+
79
+ if __name__ == '__main__':
80
+ main()
@@ -10,6 +10,8 @@ from uhttp.workers import (
10
10
  Dispatcher, Worker, WorkerPool, Request, Response,
11
11
  api, sync, RejectRequest,
12
12
  MSG_RESPONSE, MSG_HEARTBEAT,
13
+ MSG_SSE_OPEN, MSG_SSE_EVENT, MSG_SSE_CLOSE,
14
+ CTL_DISCONNECT,
13
15
  _PendingRequest,
14
16
  )
15
17
 
@@ -52,6 +54,30 @@ class MockClient:
52
54
  self.responded = True
53
55
  self.file_path = path
54
56
 
57
+ def response_stream(self, content_type=None, headers=None, cookies=None):
58
+ self.streaming = True
59
+ self.stream_content_type = content_type
60
+ self.stream_headers = headers
61
+ self.stream_cookies = cookies
62
+ return True
63
+
64
+ def send_event(self, data=None, event=None, event_id=None, retry=None):
65
+ if not hasattr(self, '_events'):
66
+ self._events = []
67
+ self._events.append({
68
+ 'data': data, 'event': event,
69
+ 'event_id': event_id, 'retry': retry})
70
+ return getattr(self, '_connected', True)
71
+
72
+ def send_chunk(self, data):
73
+ if not hasattr(self, '_chunks'):
74
+ self._chunks = []
75
+ self._chunks.append(data)
76
+ return getattr(self, '_connected', True)
77
+
78
+ def response_stream_end(self):
79
+ self.stream_ended = True
80
+
55
81
 
56
82
  class TestDispatcherSyncRoutes(unittest.TestCase):
57
83
 
@@ -413,5 +439,118 @@ class TestDispatcherRoutePriority(unittest.TestCase):
413
439
  self.assertEqual(len(d._pending), 0)
414
440
 
415
441
 
442
+ class TestDispatcherSSE(unittest.TestCase):
443
+
444
+ def _make_dispatcher(self):
445
+ pool = WorkerPool(DummyWorker, routes=['/api/**'])
446
+ d = Dispatcher.__new__(Dispatcher)
447
+ d._sync_routes = []
448
+ d._static_routes = {}
449
+ d._pools = [pool]
450
+ d._pending = {}
451
+ d._max_pending = 1000
452
+ d._next_request_id = 0
453
+ d._response_queue = mp.Queue()
454
+ d._log_is_tty = False
455
+ d.on_log = lambda *_: None
456
+ return d, pool
457
+
458
+ def test_sse_open(self):
459
+ d, pool = self._make_dispatcher()
460
+ client = MockClient('GET', '/api/events')
461
+ pending = _PendingRequest(client, pool)
462
+ pending.worker_id = 0
463
+ d._pending[1] = pending
464
+ d._process_response(
465
+ (MSG_SSE_OPEN, 1, 'text/event-stream', None, None))
466
+ self.assertTrue(client.streaming)
467
+ self.assertEqual(client.stream_content_type, 'text/event-stream')
468
+ self.assertTrue(pending.streaming)
469
+ # still in pending
470
+ self.assertIn(1, d._pending)
471
+
472
+ def test_sse_send_event(self):
473
+ d, pool = self._make_dispatcher()
474
+ client = MockClient('GET', '/api/events')
475
+ pending = _PendingRequest(client, pool)
476
+ pending.streaming = True
477
+ d._pending[1] = pending
478
+ d._process_response(
479
+ (MSG_SSE_EVENT, 1, {'count': 5}, 'update', '3', None))
480
+ self.assertEqual(len(client._events), 1)
481
+ self.assertEqual(client._events[0]['data'], {'count': 5})
482
+ self.assertEqual(client._events[0]['event'], 'update')
483
+ self.assertEqual(client._events[0]['event_id'], '3')
484
+
485
+ def test_sse_send_chunk(self):
486
+ d, pool = self._make_dispatcher()
487
+ client = MockClient('GET', '/api/stream')
488
+ pending = _PendingRequest(client, pool)
489
+ pending.streaming = True
490
+ d._pending[1] = pending
491
+ # send_chunk: event/event_id/retry are all None
492
+ d._process_response(
493
+ (MSG_SSE_EVENT, 1, b'raw data', None, None, None))
494
+ self.assertEqual(len(client._chunks), 1)
495
+ self.assertEqual(client._chunks[0], b'raw data')
496
+
497
+ def test_sse_close(self):
498
+ d, pool = self._make_dispatcher()
499
+ client = MockClient('GET', '/api/events')
500
+ pending = _PendingRequest(client, pool)
501
+ pending.streaming = True
502
+ d._pending[1] = pending
503
+ d._process_response((MSG_SSE_CLOSE, 1))
504
+ self.assertTrue(client.stream_ended)
505
+ # removed from pending
506
+ self.assertNotIn(1, d._pending)
507
+
508
+ def test_sse_client_disconnect(self):
509
+ d, pool = self._make_dispatcher()
510
+ pool.start(d._response_queue)
511
+ client = MockClient('GET', '/api/events')
512
+ client._connected = False # simulate disconnected client
513
+ pending = _PendingRequest(client, pool)
514
+ pending.streaming = True
515
+ pending.worker_id = 0
516
+ d._pending[1] = pending
517
+ d._process_response(
518
+ (MSG_SSE_EVENT, 1, {'data': 'test'}, 'ping', None, None))
519
+ # removed from pending
520
+ self.assertNotIn(1, d._pending)
521
+ # CTL_DISCONNECT sent to worker's control queue
522
+ msg = pool._control_queues[0].get(timeout=1)
523
+ self.assertEqual(msg, (CTL_DISCONNECT, 1))
524
+ pool.shutdown(timeout=2)
525
+
526
+ def test_streaming_excluded_from_timeout(self):
527
+ d, pool = self._make_dispatcher()
528
+ client = MockClient('GET', '/api/events')
529
+ pending = _PendingRequest(client, pool)
530
+ pending.streaming = True
531
+ pending.timestamp = 0 # very old
532
+ d._pending[1] = pending
533
+ d._expire_pending()
534
+ # streaming request should NOT be expired
535
+ self.assertIn(1, d._pending)
536
+
537
+ def test_non_streaming_expires(self):
538
+ d, pool = self._make_dispatcher()
539
+ client = MockClient('GET', '/api/test')
540
+ pending = _PendingRequest(client, pool)
541
+ pending.timestamp = 0 # very old
542
+ d._pending[1] = pending
543
+ d._expire_pending()
544
+ # non-streaming should be expired
545
+ self.assertNotIn(1, d._pending)
546
+
547
+ def test_sse_event_ignored_after_close(self):
548
+ d, pool = self._make_dispatcher()
549
+ # no pending request with id 99
550
+ d._process_response(
551
+ (MSG_SSE_EVENT, 99, {'data': 'test'}, None, None, None))
552
+ # should not raise, just ignore
553
+
554
+
416
555
  if __name__ == '__main__':
417
556
  unittest.main()