uhttp-workers 1.0.0__tar.gz → 1.1.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 (23) hide show
  1. {uhttp_workers-1.0.0/uhttp_workers.egg-info → uhttp_workers-1.1.0}/PKG-INFO +48 -1
  2. {uhttp_workers-1.0.0 → uhttp_workers-1.1.0}/README.md +47 -0
  3. {uhttp_workers-1.0.0 → uhttp_workers-1.1.0}/tests/test_worker.py +98 -1
  4. {uhttp_workers-1.0.0 → uhttp_workers-1.1.0}/uhttp/workers.py +78 -10
  5. {uhttp_workers-1.0.0 → uhttp_workers-1.1.0/uhttp_workers.egg-info}/PKG-INFO +48 -1
  6. {uhttp_workers-1.0.0 → uhttp_workers-1.1.0}/.github/workflows/publish.yml +0 -0
  7. {uhttp_workers-1.0.0 → uhttp_workers-1.1.0}/.github/workflows/tests.yml +0 -0
  8. {uhttp_workers-1.0.0 → uhttp_workers-1.1.0}/.gitignore +0 -0
  9. {uhttp_workers-1.0.0 → uhttp_workers-1.1.0}/examples/simple_workers.py +0 -0
  10. {uhttp_workers-1.0.0 → uhttp_workers-1.1.0}/examples/static/index.html +0 -0
  11. {uhttp_workers-1.0.0 → uhttp_workers-1.1.0}/pyproject.toml +0 -0
  12. {uhttp_workers-1.0.0 → uhttp_workers-1.1.0}/setup.cfg +0 -0
  13. {uhttp_workers-1.0.0 → uhttp_workers-1.1.0}/tests/__init__.py +0 -0
  14. {uhttp_workers-1.0.0 → uhttp_workers-1.1.0}/tests/test_api_handler.py +0 -0
  15. {uhttp_workers-1.0.0 → uhttp_workers-1.1.0}/tests/test_decorators.py +0 -0
  16. {uhttp_workers-1.0.0 → uhttp_workers-1.1.0}/tests/test_dispatcher.py +0 -0
  17. {uhttp_workers-1.0.0 → uhttp_workers-1.1.0}/tests/test_pattern_matching.py +0 -0
  18. {uhttp_workers-1.0.0 → uhttp_workers-1.1.0}/tests/test_request_response.py +0 -0
  19. {uhttp_workers-1.0.0 → uhttp_workers-1.1.0}/tests/test_worker_pool.py +0 -0
  20. {uhttp_workers-1.0.0 → uhttp_workers-1.1.0}/uhttp_workers.egg-info/SOURCES.txt +0 -0
  21. {uhttp_workers-1.0.0 → uhttp_workers-1.1.0}/uhttp_workers.egg-info/dependency_links.txt +0 -0
  22. {uhttp_workers-1.0.0 → uhttp_workers-1.1.0}/uhttp_workers.egg-info/requires.txt +0 -0
  23. {uhttp_workers-1.0.0 → uhttp_workers-1.1.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.0.0
3
+ Version: 1.1.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
@@ -269,6 +269,7 @@ def process(self, request):
269
269
  # request.query — {'page': '1'} or None
270
270
  # request.data — dict (JSON), bytes (binary), or None
271
271
  # request.headers — dict
272
+ # request.cookies — dict (lazy-parsed from Cookie header)
272
273
  # request.content_type — 'application/json'
273
274
 
274
275
  # return data (status 200)
@@ -315,6 +316,43 @@ class MyDispatcher(_workers.Dispatcher):
315
316
 
316
317
  `do_check()` is only called for requests going to worker pools — static files and sync handlers are not affected.
317
318
 
319
+ ### Worker-Level Validation
320
+
321
+ Override `do_check()` on the worker — runs before routing to handler:
322
+
323
+ ```python
324
+ class MyWorker(_workers.Worker):
325
+ def do_check(self, request):
326
+ token = request.cookies.get('session')
327
+ if not token:
328
+ return {'error': 'unauthorized'}, 401
329
+ ```
330
+
331
+ Return `(data, status)` tuple to reject, or `None` to continue. You can also raise `RejectRequest`:
332
+
333
+ ```python
334
+ def do_check(self, request):
335
+ if not self.validate_token(request.cookies.get('session')):
336
+ raise _workers.RejectRequest(
337
+ data={'error': 'forbidden'}, status=403)
338
+ ```
339
+
340
+ `RejectRequest` accepts optional `data` (default: `{'error': 'Rejected'}`) and `status` (default: `403`).
341
+
342
+ ### Error Handling
343
+
344
+ Override `on_request_error()` on the worker to customize error handling when a request handler raises an exception:
345
+
346
+ ```python
347
+ class MyWorker(_workers.Worker):
348
+ def on_request_error(self, request, err):
349
+ if isinstance(err, DatabaseError):
350
+ self.db.reconnect()
351
+ return super().on_request_error(request, err)
352
+ ```
353
+
354
+ Default behavior logs the error with traceback and returns 500.
355
+
318
356
  ## Post-Response Hook
319
357
 
320
358
  Override `on_response()` on the dispatcher to post-process after a response is sent to the client — e.g., forward data to another worker pool:
@@ -394,6 +432,15 @@ Log messages are sent to the dispatcher via the shared response queue and printe
394
432
 
395
433
  **Log levels:** `LOG_DEBUG` (10), `LOG_INFO` (20), `LOG_WARNING` (30), `LOG_ERROR` (40), `LOG_CRITICAL` (50)
396
434
 
435
+ Check current level with `is_*` properties to skip expensive formatting:
436
+
437
+ ```python
438
+ if self.log.is_debug:
439
+ self.log.debug("Details: %s", expensive_computation())
440
+ ```
441
+
442
+ Available: `is_debug`, `is_info`, `is_warning`, `is_error`.
443
+
397
444
  Set minimum level per pool:
398
445
 
399
446
  ```python
@@ -255,6 +255,7 @@ def process(self, request):
255
255
  # request.query — {'page': '1'} or None
256
256
  # request.data — dict (JSON), bytes (binary), or None
257
257
  # request.headers — dict
258
+ # request.cookies — dict (lazy-parsed from Cookie header)
258
259
  # request.content_type — 'application/json'
259
260
 
260
261
  # return data (status 200)
@@ -301,6 +302,43 @@ class MyDispatcher(_workers.Dispatcher):
301
302
 
302
303
  `do_check()` is only called for requests going to worker pools — static files and sync handlers are not affected.
303
304
 
305
+ ### Worker-Level Validation
306
+
307
+ Override `do_check()` on the worker — runs before routing to handler:
308
+
309
+ ```python
310
+ class MyWorker(_workers.Worker):
311
+ def do_check(self, request):
312
+ token = request.cookies.get('session')
313
+ if not token:
314
+ return {'error': 'unauthorized'}, 401
315
+ ```
316
+
317
+ Return `(data, status)` tuple to reject, or `None` to continue. You can also raise `RejectRequest`:
318
+
319
+ ```python
320
+ def do_check(self, request):
321
+ if not self.validate_token(request.cookies.get('session')):
322
+ raise _workers.RejectRequest(
323
+ data={'error': 'forbidden'}, status=403)
324
+ ```
325
+
326
+ `RejectRequest` accepts optional `data` (default: `{'error': 'Rejected'}`) and `status` (default: `403`).
327
+
328
+ ### Error Handling
329
+
330
+ Override `on_request_error()` on the worker to customize error handling when a request handler raises an exception:
331
+
332
+ ```python
333
+ class MyWorker(_workers.Worker):
334
+ def on_request_error(self, request, err):
335
+ if isinstance(err, DatabaseError):
336
+ self.db.reconnect()
337
+ return super().on_request_error(request, err)
338
+ ```
339
+
340
+ Default behavior logs the error with traceback and returns 500.
341
+
304
342
  ## Post-Response Hook
305
343
 
306
344
  Override `on_response()` on the dispatcher to post-process after a response is sent to the client — e.g., forward data to another worker pool:
@@ -380,6 +418,15 @@ Log messages are sent to the dispatcher via the shared response queue and printe
380
418
 
381
419
  **Log levels:** `LOG_DEBUG` (10), `LOG_INFO` (20), `LOG_WARNING` (30), `LOG_ERROR` (40), `LOG_CRITICAL` (50)
382
420
 
421
+ Check current level with `is_*` properties to skip expensive formatting:
422
+
423
+ ```python
424
+ if self.log.is_debug:
425
+ self.log.debug("Details: %s", expensive_computation())
426
+ ```
427
+
428
+ Available: `is_debug`, `is_info`, `is_warning`, `is_error`.
429
+
383
430
  Set minimum level per pool:
384
431
 
385
432
  ```python
@@ -4,7 +4,9 @@ import time
4
4
  import unittest
5
5
  import multiprocessing as mp
6
6
 
7
- from uhttp.workers import Worker, Request, Response, api, MSG_RESPONSE, MSG_HEARTBEAT
7
+ from uhttp.workers import (
8
+ Worker, Request, Response, api, RejectRequest,
9
+ MSG_RESPONSE, MSG_HEARTBEAT)
8
10
 
9
11
 
10
12
  class SimpleWorker(Worker):
@@ -95,6 +97,101 @@ class TestWorkerHandleRequest(unittest.TestCase):
95
97
  self.assertEqual(resp.status, 405)
96
98
 
97
99
 
100
+ class CheckWorker(Worker):
101
+ """Worker with do_check that rejects unauthorized requests."""
102
+
103
+ def do_check(self, request):
104
+ token = request.cookies.get('session')
105
+ if not token:
106
+ return {'error': 'unauthorized'}, 401
107
+
108
+ @api('/secret', 'GET')
109
+ def secret(self, request):
110
+ return {'data': 'secret'}
111
+
112
+
113
+ class RejectWorker(Worker):
114
+ """Worker with do_check that raises RejectRequest."""
115
+
116
+ def do_check(self, request):
117
+ raise RejectRequest()
118
+
119
+ @api('/data', 'GET')
120
+ def data(self, request):
121
+ return {'data': 'ok'}
122
+
123
+
124
+ class FailCheckWorker(Worker):
125
+ """Worker with do_check that raises unexpected exception."""
126
+
127
+ def do_check(self, request):
128
+ raise RuntimeError("check failed")
129
+
130
+ @api('/data', 'GET')
131
+ def data(self, request):
132
+ return {'data': 'ok'}
133
+
134
+
135
+ class TestWorkerDoCheck(unittest.TestCase):
136
+
137
+ def test_do_check_reject(self):
138
+ queues = [mp.Queue() for _ in range(3)]
139
+ worker = CheckWorker(0, *queues)
140
+ worker._build_routes()
141
+ req = Request(1, 'GET', '/secret')
142
+ resp = worker._handle_request(req)
143
+ self.assertEqual(resp.status, 401)
144
+
145
+ def test_do_check_pass(self):
146
+ queues = [mp.Queue() for _ in range(3)]
147
+ worker = CheckWorker(0, *queues)
148
+ worker._build_routes()
149
+ req = Request(1, 'GET', '/secret',
150
+ headers={'cookie': 'session=abc123'})
151
+ resp = worker._handle_request(req)
152
+ self.assertEqual(resp.status, 200)
153
+ self.assertEqual(resp.data, {'data': 'secret'})
154
+
155
+ def test_do_check_reject_request(self):
156
+ queues = [mp.Queue() for _ in range(3)]
157
+ worker = RejectWorker(0, *queues)
158
+ worker._build_routes()
159
+ req = Request(1, 'GET', '/data')
160
+ resp = worker._handle_request(req)
161
+ self.assertEqual(resp.status, 403)
162
+
163
+ def test_do_check_exception(self):
164
+ queues = [mp.Queue() for _ in range(3)]
165
+ worker = FailCheckWorker(0, *queues)
166
+ worker._build_routes()
167
+ req = Request(1, 'GET', '/data')
168
+ resp = worker._handle_request(req)
169
+ self.assertEqual(resp.status, 500)
170
+
171
+
172
+ class TestRequestCookies(unittest.TestCase):
173
+
174
+ def test_cookies_parsed(self):
175
+ req = Request(1, 'GET', '/',
176
+ headers={'cookie': 'session=abc; user=john'})
177
+ self.assertEqual(req.cookies, {'session': 'abc', 'user': 'john'})
178
+
179
+ def test_cookies_empty(self):
180
+ req = Request(1, 'GET', '/')
181
+ self.assertEqual(req.cookies, {})
182
+
183
+ def test_cookies_no_cookie_header(self):
184
+ req = Request(1, 'GET', '/', headers={'content-type': 'text/html'})
185
+ self.assertEqual(req.cookies, {})
186
+
187
+ def test_cookies_cached(self):
188
+ req = Request(1, 'GET', '/',
189
+ headers={'cookie': 'a=1'})
190
+ c1 = req.cookies
191
+ c2 = req.cookies
192
+ self.assertIs(c1, c2)
193
+
194
+
98
195
  class TestWorkerMatchRoute(unittest.TestCase):
99
196
 
100
197
  def setUp(self):
@@ -69,7 +69,11 @@ class ApiException(Exception):
69
69
 
70
70
 
71
71
  class RejectRequest(ApiException):
72
- """Raised in do_check() to reject request (response already sent)."""
72
+ """Raised in do_check() to reject request with custom status/data."""
73
+
74
+ def __init__(self, data=None, status=403):
75
+ self.data = data if data is not None else {'error': 'Rejected'}
76
+ self.status = status
73
77
 
74
78
 
75
79
  # Route decorator
@@ -199,7 +203,8 @@ class Request:
199
203
 
200
204
  __slots__ = (
201
205
  'request_id', 'method', 'path', 'query',
202
- 'data', 'headers', 'content_type', 'path_params')
206
+ 'data', 'headers', 'content_type', 'path_params',
207
+ '_cookies')
203
208
 
204
209
  def __init__(
205
210
  self, request_id, method, path, query=None,
@@ -212,6 +217,16 @@ class Request:
212
217
  self.headers = headers or {}
213
218
  self.content_type = content_type
214
219
  self.path_params = {}
220
+ self._cookies = None
221
+
222
+ @property
223
+ def cookies(self):
224
+ """Cookies dict, parsed from Cookie header."""
225
+ if self._cookies is None:
226
+ raw = self.headers.get('cookie', '')
227
+ self._cookies = (
228
+ _uhttp_server.parse_cookies(raw) if raw else {})
229
+ return self._cookies
215
230
 
216
231
 
217
232
  class Response:
@@ -271,6 +286,22 @@ class Logger:
271
286
  self.level = level
272
287
  self._queue = queue
273
288
 
289
+ @property
290
+ def is_debug(self):
291
+ return self.level <= LOG_DEBUG
292
+
293
+ @property
294
+ def is_info(self):
295
+ return self.level <= LOG_INFO
296
+
297
+ @property
298
+ def is_warning(self):
299
+ return self.level <= LOG_WARNING
300
+
301
+ @property
302
+ def is_error(self):
303
+ return self.level <= LOG_ERROR
304
+
274
305
  def _log(self, level, msg, *args, **kwargs):
275
306
  if level >= self.level:
276
307
  try:
@@ -453,8 +484,37 @@ class Worker(_mp.Process):
453
484
  if isinstance(msg, tuple) and msg[0] == CTL_CONFIG:
454
485
  self.on_config(msg[1])
455
486
 
487
+ def do_check(self, request):
488
+ """Validation hook called before routing request to handler.
489
+
490
+ Override for authentication, session validation, etc.
491
+ Raise RejectRequest to reject with custom response.
492
+
493
+ Args:
494
+ request: Request object with headers, cookies, etc.
495
+
496
+ Returns:
497
+ None to continue, or (data, status) tuple to reject.
498
+ """
499
+
456
500
  def _handle_request(self, request):
457
501
  """Route and handle a single request, return Response."""
502
+ try:
503
+ result = self.do_check(request)
504
+ if result is not None:
505
+ data, status = result
506
+ return Response(request.request_id, data=data, status=status)
507
+ except RejectRequest as err:
508
+ return Response(
509
+ request.request_id,
510
+ data=err.data,
511
+ status=err.status)
512
+ except Exception as err:
513
+ self.log.error("do_check: %s\n%s", err, _traceback.format_exc())
514
+ return Response(
515
+ request.request_id,
516
+ data={'error': 'Internal server error'},
517
+ status=500)
458
518
  handler = self._match_route(request)
459
519
  if handler is None:
460
520
  # check if path matches but method doesn't
@@ -477,14 +537,22 @@ class Worker(_mp.Process):
477
537
  data, status = result, 200
478
538
  return Response(request.request_id, data=data, status=status)
479
539
  except Exception as err:
480
- self.log.error(
481
- "%s %s: %s\n%s",
482
- request.method, request.path, err,
483
- _traceback.format_exc())
484
- return Response(
485
- request.request_id,
486
- data={'error': str(err)},
487
- status=500)
540
+ return self.on_request_error(request, err)
541
+
542
+ def on_request_error(self, request, err):
543
+ """Called when request handler raises an exception.
544
+
545
+ Override to customize error handling (e.g., DB reconnect).
546
+ Default logs the error with traceback and returns 500 response.
547
+ """
548
+ self.log.error(
549
+ "%s %s: %s\n%s",
550
+ request.method, request.path, err,
551
+ _traceback.format_exc())
552
+ return Response(
553
+ request.request_id,
554
+ data={'error': str(err)},
555
+ status=500)
488
556
 
489
557
  def run(self):
490
558
  """Worker main loop using select for multiplexing."""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: uhttp-workers
3
- Version: 1.0.0
3
+ Version: 1.1.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
@@ -269,6 +269,7 @@ def process(self, request):
269
269
  # request.query — {'page': '1'} or None
270
270
  # request.data — dict (JSON), bytes (binary), or None
271
271
  # request.headers — dict
272
+ # request.cookies — dict (lazy-parsed from Cookie header)
272
273
  # request.content_type — 'application/json'
273
274
 
274
275
  # return data (status 200)
@@ -315,6 +316,43 @@ class MyDispatcher(_workers.Dispatcher):
315
316
 
316
317
  `do_check()` is only called for requests going to worker pools — static files and sync handlers are not affected.
317
318
 
319
+ ### Worker-Level Validation
320
+
321
+ Override `do_check()` on the worker — runs before routing to handler:
322
+
323
+ ```python
324
+ class MyWorker(_workers.Worker):
325
+ def do_check(self, request):
326
+ token = request.cookies.get('session')
327
+ if not token:
328
+ return {'error': 'unauthorized'}, 401
329
+ ```
330
+
331
+ Return `(data, status)` tuple to reject, or `None` to continue. You can also raise `RejectRequest`:
332
+
333
+ ```python
334
+ def do_check(self, request):
335
+ if not self.validate_token(request.cookies.get('session')):
336
+ raise _workers.RejectRequest(
337
+ data={'error': 'forbidden'}, status=403)
338
+ ```
339
+
340
+ `RejectRequest` accepts optional `data` (default: `{'error': 'Rejected'}`) and `status` (default: `403`).
341
+
342
+ ### Error Handling
343
+
344
+ Override `on_request_error()` on the worker to customize error handling when a request handler raises an exception:
345
+
346
+ ```python
347
+ class MyWorker(_workers.Worker):
348
+ def on_request_error(self, request, err):
349
+ if isinstance(err, DatabaseError):
350
+ self.db.reconnect()
351
+ return super().on_request_error(request, err)
352
+ ```
353
+
354
+ Default behavior logs the error with traceback and returns 500.
355
+
318
356
  ## Post-Response Hook
319
357
 
320
358
  Override `on_response()` on the dispatcher to post-process after a response is sent to the client — e.g., forward data to another worker pool:
@@ -394,6 +432,15 @@ Log messages are sent to the dispatcher via the shared response queue and printe
394
432
 
395
433
  **Log levels:** `LOG_DEBUG` (10), `LOG_INFO` (20), `LOG_WARNING` (30), `LOG_ERROR` (40), `LOG_CRITICAL` (50)
396
434
 
435
+ Check current level with `is_*` properties to skip expensive formatting:
436
+
437
+ ```python
438
+ if self.log.is_debug:
439
+ self.log.debug("Details: %s", expensive_computation())
440
+ ```
441
+
442
+ Available: `is_debug`, `is_info`, `is_warning`, `is_error`.
443
+
397
444
  Set minimum level per pool:
398
445
 
399
446
  ```python
File without changes
File without changes