slowlette 0.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.
@@ -0,0 +1,590 @@
1
+ Metadata-Version: 2.4
2
+ Name: slowlette
3
+ Version: 0.1.0
4
+ Summary: Micro Web Framework
5
+ Author: Sanshiro Enomoto
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: uvicorn
10
+ Provides-Extra: auth
11
+ Requires-Dist: bcrypt; extra == "auth"
12
+
13
+ # Slowlette
14
+
15
+ Slowlette is a Web-server micro-framework in Python. Like FastAPI (or Flask), URLs are parsed, parameters are extracted, and the requests are routed to user code. Unlike FastAPI or Flask, requests in Slowlette can be bound to methods of multiple class instances, not just to functions or a single instance. However, binding to standalone functions (as in FastAPI/Flask) is also supported. One HTTP request can be handled by multiple user handlers, for example, multiple instances of a user class or a combination of different classes and functions, and the responses are aggregated in a customizable way. This is designed for dynamic plug-in systems (where each plugin might return partial data) with the chain-of-responsibility scheme. Slowlette implements both ASGI and WSGI.
16
+
17
+
18
+ ## Dependencies
19
+ - Python >=3.9
20
+ - uvicorn to use ASGI
21
+ - (nothing is necessary for WSGI, though gunicorn can be used)
22
+
23
+ ## Usage
24
+ ### A Complete Web App with Simple GET
25
+
26
+ ```python
27
+ # testapp.py
28
+
29
+ import slowlette
30
+
31
+ class App(slowlette.App):
32
+ @slowlette.get('/')
33
+ def home(self):
34
+ return 'feel at home'
35
+
36
+ @slowlette.get('/hello')
37
+ def say_hello(self):
38
+ return 'Hello, Slowlette!'
39
+
40
+ app = App()
41
+
42
+ if __name__ == '__main__':
43
+ app.run()
44
+ ```
45
+ - Very similar to FastAPI, except that URLs are associated with class methods. Unlike FastAPI, the `app` instance is created after the binding is described. (Important for creating multiple handler instances.)
46
+
47
+ #### Running the example
48
+ Like FastAPI/Flask, running the script above will start an HTTP server at port 8000.
49
+ ```bash
50
+ python3 testapp.py
51
+ ```
52
+ Now open `http://localhost:8000/hello` in your browser, or run:
53
+ ```bash
54
+ curl http://localhost:8000/hello
55
+ ```
56
+ And you should see the response:
57
+ ```text
58
+ Hello, Slowlette!
59
+ ```
60
+
61
+ #### Running via external ASGI server
62
+ Like FastAPI, Slowlette App object implements ASGI, and any external ASGI server can be used.
63
+ ```bash
64
+ uvicorn testapp:app
65
+ ```
66
+
67
+ #### Not inheriting from slowlette.App
68
+ The base class, `slowlette.App`, has only three attributes, listed below:
69
+
70
+ - `slowlette`: Slowlette connection point
71
+ - `__call__(self, scope, receive, send)`: ASGI entry point
72
+ - `run(self, port, **kwargs)`: Execution start point
73
+
74
+ Given this small number of attributes, the likelihood of name conflicts with user classes should be minimal.
75
+ Nevertheless, it is also possible to create a user class independently from Slowlette and pass it to Slowlette later.
76
+ ```python
77
+ import slowlette
78
+
79
+ class MyApp:
80
+ @slowlette.get('/hello')
81
+ def say_hello(self):
82
+ return 'hello, how are you?'
83
+
84
+ app = slowlette.App(MyApp())
85
+
86
+ if __name__ == '__main__':
87
+ app.run()
88
+ ```
89
+ Once you have created the `app` instance, the usage is essentially the same as before.
90
+
91
+ #### Performance overhead
92
+ Whether the user class is inherited from `slowlette.App` or not, the Slowlette decorators (such as `@slowlette.get()`) do not modify the function signature, and the decorated user methods can be used as they are defined in the user code. There is no additional performance overhead with the Slowlette decorators.
93
+
94
+
95
+ ### Binding to functions
96
+ By creating an instance of Slowlette, functions, instead of class methods, can be bound to URL endpoints, in a very similar way as FastAPI and Flask.
97
+ ```python
98
+ import slowlette
99
+
100
+ app = slowlette.Slowlette()
101
+
102
+ @app.get('/hello')
103
+ def say_hello():
104
+ return 'hello, how are you?'
105
+
106
+ if __name__ == '__main__':
107
+ app.run()
108
+ ```
109
+
110
+ ### GET with URL path parameters
111
+ ```python
112
+ import slowlette
113
+
114
+ class App(slowlette.App):
115
+ @slowlette.get('/hello/{name}')
116
+ def hello(self, name:str):
117
+ return f'hello, {name}'
118
+
119
+ app = App()
120
+ ```
121
+
122
+ - FastAPI style parameter binding with types, optionally with a default value
123
+ - If a parameter for an argument without a default value is not in the URL, the URL will not match and the handler (`hello()` method in the example) will not be called.
124
+ - Return value of a handler must be:
125
+ - `str` for a `text/plain` reply
126
+ - `list` or `dict` for an `application/json` reply
127
+ - `slowlette.FileResponse` object for file fetching
128
+ - `slowlette.Response` object for full flexibility
129
+ - `None` if the request is not applicable
130
+
131
+
132
+ ### GET with URL query parameters
133
+ ```python
134
+ import slowlette
135
+
136
+ class App(slowlette.App):
137
+ @slowlette.get('/hello/{name}')
138
+ def hello(self, name:str, message:str='how are you', repeat:int=3):
139
+ return f'hello, {name}.' + f' {message}' * repeat
140
+
141
+ app = App()
142
+ ```
143
+
144
+ ### Async handlers
145
+ With ASGI, if the bound method is `async`, requests are handled asynchronously.
146
+ ```python
147
+ import slowlette
148
+
149
+ class App(slowlette.App):
150
+ @slowlette.get('/hello')
151
+ async def hello(self, delay:float=0):
152
+ if delay > 0:
153
+ await asyncio.sleep(delay)
154
+ return f"hello after {delay} sleep"
155
+
156
+ app = App()
157
+ ```
158
+
159
+
160
+ ### Receiving the full path and/or query parameters
161
+ ```python
162
+ import slowlette
163
+
164
+ class App(slowlette.App):
165
+ @slowlette.get('/echo/{*}')
166
+ def echo(self, path:list, query:dict):
167
+ return f'path: {path}, query: {query}'
168
+
169
+ app = App()
170
+ ```
171
+
172
+ - `{*}` matches any path elements.
173
+ - A list of decoded URL path is set to the (last; should be only one) argument of a type `list`.
174
+ - A dict of decoded URL query is set to the (last) argument of a type `dict`.
175
+
176
+
177
+ ### Receiving the entire request
178
+ ```python
179
+ import slowlette
180
+
181
+ class App(slowlette.App):
182
+ @slowlette.get('/{*}')
183
+ def header(self, request:slowlette.Request):
184
+ return f'header: {request.headers}'
185
+
186
+ app = App()
187
+ ```
188
+ The `Request` object has the following attributes:
189
+
190
+ - `method` (str): request method (`GET` etc.)
191
+ - `path` (list[str]): URL path (also, `path_str` for raw decoded path string)
192
+ - `query` (dict[str,str]): URL query (also, `query_str` for raw decoded query string)
193
+ - `headers` (dict[str,str]): HTTP request header items
194
+ - `body` (bytes): request body
195
+
196
+
197
+ ### Simple POST
198
+ ```python
199
+ import slowlette
200
+
201
+ class App(slowlette.App):
202
+ @slowlette.post('/hello/{name}')
203
+ def hello(self, name:str, message:bytes):
204
+ return f'hello, {name}. You sent me "{message.decode()}"'
205
+
206
+ app = App()
207
+ ```
208
+
209
+ - The request body is set to the (last) argument of a type of `bytes`.
210
+
211
+
212
+ ### POST with JSON document body
213
+ #### for dict data
214
+ ```python
215
+ import slowlette
216
+
217
+ class App(slowlette.App):
218
+ @slowlette.post('/hello/{name}')
219
+ def hello(self, name:str, doc:DictJSON): # if body in not a dict in JSON, a response 400 (Bad Request) will be returned
220
+ item = doc.get('item', 'nothing')
221
+ return f'hello, {name}. You gave me {item}'
222
+
223
+ app = App()
224
+ ```
225
+ - The request body is parsed as `dict` in JSON and the value is set to the (last) argument of a type `slowlette.DictJSON`.
226
+ - If the content cannot be parsed as a dict, the handler will not be called and an error response (400) will be returned.
227
+ - DictJSON is a subclass of dict, therefore all the dict functions are available.
228
+
229
+
230
+ #### for any data in JSON
231
+ ```python
232
+ import slowlette
233
+
234
+ class App(slowlette.App):
235
+ @slowlette.post('/hello/{name}')
236
+ def hello(self, name:str, doc:JSON):
237
+ item = doc.get('item', 'nothing') # this will make a runtime error if the body is not dict
238
+ return f'hello, {name}. You gave me {item}'
239
+
240
+ app = App()
241
+ ```
242
+
243
+ - The request body is parsed as JSON and the value is set to the (last) argument of a type `slowlette.JSON`.
244
+ - If the content cannot be parsed as JSON, the handler will not be called and an error response (400) will be returned.
245
+ - Use `JSON.value()` to get a value of the native Python types (`dict`, `list`, `str`, ...).
246
+ - Use `dict(doc)` or `list(doc)` to convert to native Python dict or list.
247
+ - If the content is dict (or list), most common dict (list) methods are available in JSON-type data:
248
+ - For dict: `doc[key]`, `key in doc`, `for key in doc:`, `doc.get(value, default)`, `doc.items()`, ...
249
+ - For list: `doc[index]`,`len(doc)`, `for v in doc:`, ...
250
+
251
+
252
+ ### Lifespan Events
253
+ The structure is basically the same as FastAPI:
254
+ ```python
255
+ import slowlette
256
+
257
+ class App(slowlette.App):
258
+ @slowlette.on_event('startup')
259
+ async def startup(self):
260
+ print("SlowApp Server started")
261
+
262
+ @slowlette.on_event('shutdown')
263
+ async def shutdown(self):
264
+ print("SlowApp Server stopped")
265
+
266
+ app = App()
267
+ ```
268
+ - Within the ASGI Lifespan events, currently only `startup` and `shutdown` are implemented.
269
+ - As an extention, `pre_startup`, `post_startup`, `pre_shutdown`, `post_shutdown` are added:
270
+ - `post_startup` will be called after completing the startup process; therefore in parallel to accepting requests.
271
+ - `post_shutdown` will be called after completing the shutdown process.
272
+ - HTTP request handling starts after `await`-ing the startup coroutine. If you want to start a task here, use `asyncio.create_task()` or similar not to block this.
273
+
274
+
275
+ ### WebSocket
276
+ The structure is basically the same as FastAPI:
277
+ ```python
278
+ import slowlette
279
+
280
+ class App(slowlette.App):
281
+ @slowlette.websocket('/ws')
282
+ async def ws_echo(self, websocket:slowlette.WebSocket):
283
+ await websocket.accept()
284
+ try:
285
+ while True:
286
+ message = await websocket.receive()
287
+ await websocket.send(f'Received: {message}')
288
+ except slowlette.WebSocketConnectionClosed:
289
+ print("WebSocket Closed")
290
+
291
+ app = App()
292
+ ```
293
+ - WebSocket is available only with ASGI.
294
+
295
+
296
+ ### Event-Stream / Server-Sent Events (SSE)
297
+ ```python
298
+ import slowlette
299
+
300
+ class App(slowlette.App):
301
+ @slowlette.eventstream('/events')
302
+ async def events(self, eventstream:slowlette.EventStream):
303
+ await eventstream.accept()
304
+ disconnect_task = asyncio.create_task(eventstream.wait_disconnected())
305
+ try:
306
+ while not disconnect_task.done():
307
+ data = { 'time': time.strftime('%H:%M:%S') }
308
+ await eventstream.send(data, event='tick')
309
+ await asyncio.sleep(1)
310
+ await disconnect_task
311
+ except slowlette.EventStreamConnectionClosed:
312
+ print("EventStream Closed by client")
313
+
314
+ app = App()
315
+ ```
316
+ - SSE is available only with ASGI.
317
+
318
+
319
+
320
+ ### Multiple Handlers for the same URL
321
+ ```python
322
+ import slowlette
323
+
324
+ class Fruit():
325
+ def __init__(self, name:str):
326
+ self.name = name
327
+
328
+ @slowlette.get('/hello')
329
+ def hello(self):
330
+ return [f'I am a {self.name}']
331
+
332
+ class App(slowlette.App):
333
+ def __init__(self):
334
+ super().__init__()
335
+ self.slowlette.include(Fruit('peach'))
336
+ self.slowlette.include(Fruit('melon'))
337
+
338
+ @slowlette.get('/hello')
339
+ def hello(self):
340
+ return ['Hello.']
341
+
342
+ app = App()
343
+ ```
344
+
345
+ By sending a request to the `/hello` endpoint, to which three App instances are bound:
346
+ ```bash
347
+ curl http://localhost:8000/hello | jq
348
+ ```
349
+ You will get a result of three responses aggregated:
350
+ ```text
351
+ [
352
+ "Hello.",
353
+ "I am a peach",
354
+ "I am a melon"
355
+ ]
356
+ ```
357
+
358
+ - If responses are all `list`, they are combined with `append()`.
359
+ - If responses are all `dict`, they are combined with `update()` (recursively; sub-dicts with the same key will be combined with `update()`).
360
+ - If responses are all `str`, they are concatenated with a new-line in between.
361
+ - If a response is `None`, it will not be included.
362
+ - If all the responses are `None`, a 404 status (Not Found) is replied.
363
+ - If one of the responses is an error (code >= 400), an error is replied without content; the largest status code is taken.
364
+
365
+ The behavior is customizable by providing a user response aggregator, as explained below.
366
+
367
+ Instances of `slowlette.Slowlette` used to bind functions in the example above can also be included or include other sub-apps.
368
+
369
+
370
+ ### Middleware
371
+ As a Slowlette app can already have multiple handlers (sub-app) in a chain, there is no difference between a (sub)app and a middleware; if the (sub)app behaves like a middleware, such as modifying the requests for the subsequent (sub)apps and/or modifying the responses from the (sub)apps, it is a middleware.
372
+
373
+ The middleware example below drops the path prefix of `/api` from all the requests:
374
+ ```python
375
+ import slowlette
376
+
377
+ class DropPrefix:
378
+ def __init__(self, prefix):
379
+ self.prefix = 'api'
380
+
381
+ @slowlette.route('/{*}')
382
+ def handle(self, request: slowlette.Request):
383
+ if len(request.path) > 0 and request.path[0] == self.prefix:
384
+ request.path = request.path[1:]
385
+ return slowlette.Response()
386
+
387
+ class App(slowlette.App):
388
+ def __init__(self):
389
+ super().__init__()
390
+ self.slowlette.add_middleware(DropPrefix('api'))
391
+
392
+ @slowlette.get('/hello')
393
+ def hello(self):
394
+ return 'Hello, Middleware.'
395
+
396
+ app = App()
397
+ ```
398
+
399
+ In this example, access to `/api/hello/` will be routed to the method bound to `/hello`, after the middleware that drops the `/api` prefix:
400
+ ```bash
401
+ curl http://localhost:8000/api/hello
402
+ ```
403
+
404
+ The `@route()` decorator can be used to handle all the request methods, not specific to one such as `@get()`. The path rule of `/{*}` will capture all the URL.
405
+
406
+ The empty response returned here will be replaced with an aggregated responses from the subsequent handlers.
407
+
408
+ If a (sub)app is added by `app.add_middleware(subapp)`, the `subapp` handlers are inserted before the `app` handlers, whereas `app.include(subapp)` appends `subapp` handlers to `app`.
409
+
410
+ Multiple middlewares can be appended, and they will be processed in the order of appending, before the main `app` handlers and sub-app handlers are called.
411
+
412
+ A middleware that modifies responses can be implemented by returning a custom response with an overridden aggregation method (`Response.merge_response(self, response:Response)`).
413
+
414
+
415
+ ### Ready-to-use Middlewares
416
+
417
+ #### HTTP Basic Authentication
418
+ `BasicAuthentication(auth_list: list[str])`
419
+
420
+ The `auth_list` is a list of keys, where each key looks like:
421
+ `api:$2a$12$D2.....`, which is the same key format used by Apache (type "2a").
422
+ A key can be generated by:
423
+ ```
424
+ key = slowlette.BasicAuthentication.generate_key('api', 'slow')
425
+ ```
426
+
427
+ #### File Server
428
+ `FileServer(filedir, *, prefix='', index_file=None, exclude=None, ext_allow=None, ext_deny=None)`
429
+
430
+ The file server handles GET requests to send back files stored in `filedir`. The request path, optionally with `prefix` that will be dropped, is the relative path from the `filedir`. For security reasons, file names cannot contain special characters other than a few selected ones (`_`, `-`, `+`, `=`, `,`, `.`, `:`), and the first letter of each path element must be an alphabet or digit. Also, the path cannot start with a Windows drive letter (like `c:`), even if Slowlette runs on non-Windows. POST and DELETE are not implemented.
431
+
432
+ - `filedir` (str): path to a filesystem directory
433
+ - `prefix` (str): URL path to bind this app (e.g., `/webfile`)
434
+ - `index_file` (str): index file when the path is empty (i.e., `/`)
435
+ - `exclude` (str): URL path not to be handled (e.g., prefix=`/app`, exclude=`/app/api`)
436
+ - `not_found_is_error` (bool): if true return an error response (404) if file does not exist, otherwise propagate
437
+ - `ext_allow` (list[str]): a list of file extensions to allow accessing
438
+ - `ext_deny` (list[str]): a list of file extensions not to allow accessing
439
+
440
+ ### Custom Response Aggregation
441
+ A handler can make a user aggregator by returning an instance of a custom Response class with an overridden `merge_response()` method, as explained above.
442
+
443
+ ```python
444
+ import slowlette
445
+
446
+ class MyExclusiveApp:
447
+ class MyExclusiveResponse(slowlette.Response):
448
+ def __init__(self, status_code=0, *, content_type=None, content=None):
449
+ super().__init__(satus_code, conent_type=content_type, content=conent)
450
+
451
+ def merge_response(self, response:slowlette.Response)->None:
452
+ # example: do not merge the responses from the subsequent handlers
453
+ pass
454
+
455
+ @slowlette.route('/hello')
456
+ def hello(self):
457
+ return self.MyExclusiveResponse(content='hello, there is no one else here.')
458
+ ```
459
+ This method is useful if the method returns a data structure that requires a certain way to merge other data.
460
+
461
+ In addition to that, a user app class can override a method to aggregate all the individual responses from all the handlers within the class, to provide full flexibility. To do this, make a custom `Router` with an overridden `merge_responses()` method:
462
+
463
+ ```python
464
+ import slowlette
465
+
466
+ class MyRouter(slowlette.Router):
467
+ def __init__(self, app):
468
+ super().__init__(app)
469
+
470
+ def merge_responses(self, responses: list[Response]) -> Response:
471
+ response = Response()
472
+ for r in responses:
473
+ response = .... # aggregate responses here
474
+ return response
475
+ ```
476
+
477
+ Then use this as a `slowlette` of the user app:
478
+ ```python
479
+ class MyApp(slowlette.App):
480
+ def __init__(self):
481
+ self.slowlette = MyRouter(self)
482
+ super().__init__()
483
+ ```
484
+ Calling `super().__init__()` later is a little bit more efficient, as it does not replace `self.slowlette` if it is already defined.
485
+
486
+
487
+ ### Basic Authentication
488
+ ```python
489
+ import slowlette
490
+
491
+ class App(slowlette.App):
492
+ @slowlette.get('/hello')
493
+ def hello(self):
494
+ return 'hello, how are you?'
495
+
496
+ # test authentication username and password
497
+ # once generated, store the key separately
498
+ key = slowlette.BasicAuthentication.generate_key(username='api', password='slow')
499
+
500
+ app = App()
501
+ app.slowlette.add_middleware(slowlette.BasicAuthentication(auth_list=[key]))
502
+ ```
503
+
504
+ If HTTP is used, nothing will be returned, as the access is denied. (Add `-v` option to see details.)
505
+ ```bash
506
+ curl http://localhost:8000/hello -v
507
+ ```
508
+ ```text
509
+ ...
510
+ > GET /hello HTTP/1.1
511
+ > Host: localhost:8000
512
+ > User-Agent: curl/8.5.0
513
+ ...
514
+ < HTTP/1.1 401 Unauthorized
515
+ ```
516
+
517
+ Now with the credentials:
518
+ ```bash
519
+ curl http://api:slow@localhost:8000/hello
520
+ ```
521
+ You will get the expected result:
522
+ ```text
523
+ hello, how are you?
524
+ ```
525
+
526
+
527
+ ### HTTPS and HTTP/2
528
+ HTTP/2 is enabled only with TLS. Provide TLS key files to use HTTPS.
529
+ ```python
530
+ if __name__ == '__main__':
531
+ app.run(ssl_keyfile='key.pem', ssl_certfile='cert.pem')
532
+ ```
533
+
534
+ #### Memo: Generating a certificate
535
+ ##### Temporary Self-Signed
536
+ ```bash
537
+ openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout key.pem -out cert.pem
538
+ ```
539
+ ##### Let's Encrypt if you have a domain:
540
+ ```bash
541
+ sudo apt install certbot
542
+ sudo certbot certonly --standalone -d YOUR.DOMAIN.NAME
543
+ ```
544
+ This will create files under `/etc/letsencript/live/YOUR.DOMAIN.NAME`
545
+
546
+ - private key: `privkey.pem`
547
+ - server certificate: `cert.pem`
548
+ - full chain: `fullchain.pem`
549
+
550
+ ### WSGI
551
+ In addition to ASGI, WSGI can be used. The `slowlette.WSGI(app)` function wraps the ASGI App (standard Slowlette App) and returns a WSGI app.
552
+ ```python
553
+ # testapp.py
554
+
555
+ import slowlette
556
+
557
+ class App(slowlette.App):
558
+ @slowlette.get('/hello')
559
+ def hello(self):
560
+ return 'hello, how are you?'
561
+
562
+ app = App() # ASGI App
563
+ wsgi_app = slowlette.WSGI(app)
564
+
565
+ if __name__ == '__main__':
566
+ wsgi_app.run()
567
+ ```
568
+ The script can be executed as an HTTP server with WSGI:
569
+ ```bash
570
+ python3 ./testapp.py
571
+ ```
572
+
573
+ Or it can be used with any WSGI server:
574
+ ```bash
575
+ gunicorn testapp:wsgi_app
576
+ ```
577
+
578
+ Note:
579
+
580
+ - Every HTTP request is handled sequentially, even with async handlers.
581
+ - A dedicated async event loop is created for each request.
582
+ Code that assumes the same event loop among requests (typical in DB connection pool) cannot be used.
583
+
584
+
585
+ ## TODOs
586
+ - File templates
587
+ - App.mount('path', app)
588
+ - OpenAPI document generation
589
+ - GraphQL chain processing (thanks ChatGPT for the idea!)
590
+