slowlette 0.1.0__py3-none-any.whl
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.
- slowlette/__init__.py +10 -0
- slowlette/app.py +117 -0
- slowlette/eventstream.py +91 -0
- slowlette/middleware.py +197 -0
- slowlette/model.py +107 -0
- slowlette/request.py +32 -0
- slowlette/response.py +249 -0
- slowlette/router.py +465 -0
- slowlette/server.py +343 -0
- slowlette/websocket.py +49 -0
- slowlette-0.1.0.dist-info/METADATA +590 -0
- slowlette-0.1.0.dist-info/RECORD +14 -0
- slowlette-0.1.0.dist-info/WHEEL +5 -0
- slowlette-0.1.0.dist-info/top_level.txt +1 -0
slowlette/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
|
|
2
|
+
from .model import JSON, DictJSON
|
|
3
|
+
from .request import Request
|
|
4
|
+
from .response import Response, FileResponse
|
|
5
|
+
from .router import Router, get, post, delete, route, on_event, websocket, eventstream
|
|
6
|
+
from .websocket import WebSocket, WebSocketConnectionClosed
|
|
7
|
+
from .eventstream import EventStream, EventStreamConnectionClosed
|
|
8
|
+
from .middleware import BasicAuthentication, FileServer
|
|
9
|
+
from .server import serve_asgi, serve_wsgi, serve_wsgi_ref, WSGI
|
|
10
|
+
from .app import App, Slowlette
|
slowlette/app.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# Created by Sanshiro Enomoto on 19 January 2025 #
|
|
2
|
+
|
|
3
|
+
import asyncio, inspect, logging
|
|
4
|
+
|
|
5
|
+
from .router import Router, PathRule
|
|
6
|
+
from .server import dispatch_asgi, dispatch_wsgi, serve_asgi, serve_wsgi
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class App:
|
|
10
|
+
"""Slowlette App for ASGI
|
|
11
|
+
Note:
|
|
12
|
+
- User App can be derived from this class, or can be passed to the constructor parameter:
|
|
13
|
+
- Method 1: class MyApp(slowlette.App) then app = MyApp()
|
|
14
|
+
- Method 2: app = slowlette.App(MyApp())
|
|
15
|
+
- In either case,
|
|
16
|
+
- MyApp can use the Slowlette routing decorators.
|
|
17
|
+
- The app object implements ASGI.
|
|
18
|
+
- Sub-apps can be added by app.slowlette.include(MySubApp()).
|
|
19
|
+
- Middlewares can be added by app.slowlette.add_middleware(...)
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, subapp=None):
|
|
23
|
+
if not hasattr(self, 'slowlette'): # subclass might have defnied a custom router
|
|
24
|
+
self.slowlette = Router(self)
|
|
25
|
+
|
|
26
|
+
if subapp is not None:
|
|
27
|
+
self.slowlette.include(subapp)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def __call__(self, scope, receive, send):
|
|
31
|
+
"""ASGI entry point
|
|
32
|
+
"""
|
|
33
|
+
if not hasattr(self, 'slowlette'): # __init__() might not have been called
|
|
34
|
+
self.slowlette = Router(self)
|
|
35
|
+
|
|
36
|
+
await dispatch_asgi(self, scope, receive, send)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def run(self, port=8000, **kwargs):
|
|
40
|
+
"""Run HTTP Server
|
|
41
|
+
"""
|
|
42
|
+
serve_asgi(self, port, **kwargs)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Slowlette(App):
|
|
47
|
+
"""Slowlette App that can assign URL endpoints to functions, instead of class methods
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
class FunctionAdapter:
|
|
51
|
+
def __init__(self, func):
|
|
52
|
+
self.func = func
|
|
53
|
+
async def __call__(self, app, *args, **kwargs):
|
|
54
|
+
result = self.func(*args, **kwargs)
|
|
55
|
+
if inspect.isawaitable(result):
|
|
56
|
+
return await result
|
|
57
|
+
else:
|
|
58
|
+
return result
|
|
59
|
+
|
|
60
|
+
def __init__(self):
|
|
61
|
+
super().__init__()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def get(self, path_rule:str, status_code:int=200):
|
|
65
|
+
"""decorator to make a GET-request handler (Python function)
|
|
66
|
+
Args:
|
|
67
|
+
- path_rule: path pattern to match
|
|
68
|
+
- status_code: default status code for success
|
|
69
|
+
"""
|
|
70
|
+
def wrapper(func):
|
|
71
|
+
adapter = self.FunctionAdapter(func)
|
|
72
|
+
adapter.slowlette_path_rule = PathRule(path_rule, 'GET', func, status_code=status_code)
|
|
73
|
+
self.slowlette.handlers.append(adapter)
|
|
74
|
+
return func
|
|
75
|
+
return wrapper
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def post(self, path_rule:str, status_code:int=201):
|
|
79
|
+
"""decorator to make a POST-request handler (Python function)
|
|
80
|
+
Args:
|
|
81
|
+
- path_rule: path pattern to match
|
|
82
|
+
- status_code: default status code for success
|
|
83
|
+
"""
|
|
84
|
+
def wrapper(func):
|
|
85
|
+
adapter = self.FunctionAdapter(func)
|
|
86
|
+
adapter.slowlette_path_rule = PathRule(path_rule, 'POST', func, status_code=status_code)
|
|
87
|
+
self.slowlette.handlers.append(adapter)
|
|
88
|
+
return func
|
|
89
|
+
return wrapper
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def delete(self, path_rule:str, status_code:int=200):
|
|
93
|
+
"""decorator to make a DELETE-request handler (Python function)
|
|
94
|
+
Args:
|
|
95
|
+
- path_rule: path pattern to match
|
|
96
|
+
- status_code: default status code for success
|
|
97
|
+
"""
|
|
98
|
+
def wrapper(func):
|
|
99
|
+
adapter = self.FunctionAdapter(func)
|
|
100
|
+
adapter.slowlette_path_rule = PathRule(path_rule, 'DELETE', func, status_code=status_code)
|
|
101
|
+
self.slowlette.handlers.append(adapter)
|
|
102
|
+
return func
|
|
103
|
+
return wrapper
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def route(self, path_rule:str, status_code:int=200):
|
|
107
|
+
"""decorator to make a request handler (Python function) for all request methods
|
|
108
|
+
Args:
|
|
109
|
+
- path_rule: path pattern to match
|
|
110
|
+
- status_code: default status code for success
|
|
111
|
+
"""
|
|
112
|
+
def wrapper(func):
|
|
113
|
+
adapter = self.FunctionAdapter(func)
|
|
114
|
+
adapter.slowlette_path_rule = PathRule(path_rule, '*', func, status_code=status_code)
|
|
115
|
+
self.slowlette.handlers.append(adapter)
|
|
116
|
+
return func
|
|
117
|
+
return wrapper
|
slowlette/eventstream.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Created by Sanshiro Enomoto on 9 July 2026 #
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class EventStream:
|
|
7
|
+
def __init__(self, receive_func, send_func):
|
|
8
|
+
self.receive_func = receive_func
|
|
9
|
+
self.send_func = send_func
|
|
10
|
+
self.is_accepted = False
|
|
11
|
+
self.is_closed = False
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
async def accept(self):
|
|
15
|
+
if self.is_accepted:
|
|
16
|
+
return
|
|
17
|
+
await self.send_func({
|
|
18
|
+
'type': 'http.response.start',
|
|
19
|
+
'status': 200,
|
|
20
|
+
'headers': [
|
|
21
|
+
(b'content-type', b'text/event-stream; charset=utf-8'),
|
|
22
|
+
(b'cache-control', b'no-cache'),
|
|
23
|
+
(b'x-accel-buffering', b'no'),
|
|
24
|
+
],
|
|
25
|
+
})
|
|
26
|
+
self.is_accepted = True
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def close(self, code=1000):
|
|
30
|
+
if self.is_closed:
|
|
31
|
+
return
|
|
32
|
+
await self.accept()
|
|
33
|
+
await self.send_func({
|
|
34
|
+
'type': 'http.response.body',
|
|
35
|
+
'body': b'',
|
|
36
|
+
'more_body': False,
|
|
37
|
+
})
|
|
38
|
+
self.is_closed = True
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def send(self, data, *, event=None, id=None, retry=None):
|
|
42
|
+
await self.accept()
|
|
43
|
+
|
|
44
|
+
lines = []
|
|
45
|
+
if id is not None:
|
|
46
|
+
lines.append(f'id: {id}')
|
|
47
|
+
if event is not None:
|
|
48
|
+
lines.append(f'event: {event}')
|
|
49
|
+
if retry is not None:
|
|
50
|
+
lines.append(f'retry: {retry}')
|
|
51
|
+
|
|
52
|
+
if type(data) is bytes:
|
|
53
|
+
text = data.decode()
|
|
54
|
+
elif type(data) is str:
|
|
55
|
+
text = data
|
|
56
|
+
else:
|
|
57
|
+
text = json.dumps(data)
|
|
58
|
+
|
|
59
|
+
for line in text.splitlines() or ['']:
|
|
60
|
+
lines.append(f'data: {line}')
|
|
61
|
+
|
|
62
|
+
payload = ('\n'.join(lines) + '\n\n').encode()
|
|
63
|
+
|
|
64
|
+
await self.send_func({
|
|
65
|
+
'type': 'http.response.body',
|
|
66
|
+
'body': payload,
|
|
67
|
+
'more_body': True,
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def comment(self, text=''):
|
|
72
|
+
await self.accept()
|
|
73
|
+
lines = str(text).splitlines() or ['']
|
|
74
|
+
body = ''.join(f': {line}\n' for line in lines) + '\n'
|
|
75
|
+
await self.send_func({
|
|
76
|
+
'type': 'http.response.body',
|
|
77
|
+
'body': body.encode(),
|
|
78
|
+
'more_body': True,
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def wait_disconnected(self):
|
|
83
|
+
while True:
|
|
84
|
+
message = await self.receive_func()
|
|
85
|
+
if message['type'] == 'http.disconnect':
|
|
86
|
+
raise EventStreamConnectionClosed()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class EventStreamConnectionClosed(Exception):
|
|
91
|
+
pass
|
slowlette/middleware.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# Created by Sanshiro Enomoto on 18 January 2025 #
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
import sys, os, base64, logging
|
|
5
|
+
|
|
6
|
+
from .request import Request
|
|
7
|
+
from .response import Response, FileResponse
|
|
8
|
+
from .router import route
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BasicAuthentication():
|
|
12
|
+
def __init__(self, realm='Slowlette', auth_list=[]):
|
|
13
|
+
self.realm = realm
|
|
14
|
+
|
|
15
|
+
self.auth_list = {}
|
|
16
|
+
for auth in auth_list:
|
|
17
|
+
try:
|
|
18
|
+
(user, key) = tuple(auth.split(':', 1))
|
|
19
|
+
except:
|
|
20
|
+
# auth is already encrypted
|
|
21
|
+
logging.error('Bad authentication entry: %s' % auth)
|
|
22
|
+
continue
|
|
23
|
+
self.auth_list[user] = key
|
|
24
|
+
|
|
25
|
+
if len(self.auth_list) > 0:
|
|
26
|
+
try:
|
|
27
|
+
global bcrypt
|
|
28
|
+
import bcrypt
|
|
29
|
+
except:
|
|
30
|
+
logging.critical('Slowlette_BasicAuthentication: missing python module "bcrypt"')
|
|
31
|
+
sys.exit(-1)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def require_auth(self, request:Request) -> Response:
|
|
35
|
+
request.abort()
|
|
36
|
+
response = Response(401) # Unauthorized
|
|
37
|
+
response.headers['WWW-Authenticate'] = f'Basic realm="{self.realm}"'
|
|
38
|
+
return response
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@route('/{*}')
|
|
42
|
+
def dispatch(self, request:Request, body:bytes=None) -> Response:
|
|
43
|
+
if len(self.auth_list) == 0:
|
|
44
|
+
return Response()
|
|
45
|
+
|
|
46
|
+
auth = request.headers.get('authorization', request.headers.get('Authorization', None))
|
|
47
|
+
if auth == '' or auth is None:
|
|
48
|
+
return self.require_auth(request)
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
user, word = tuple(base64.b64decode(auth.split(' ')[1]).decode().split(':'))
|
|
52
|
+
true_key = self.auth_list.get(user, None)
|
|
53
|
+
if word is None or true_key is None:
|
|
54
|
+
return self.require_auth(request)
|
|
55
|
+
|
|
56
|
+
# key is hashed and this is safe against timing attack; but this is very slow...
|
|
57
|
+
key = bcrypt.hashpw(word.encode("utf-8"), true_key.encode()).decode("utf-8")
|
|
58
|
+
if key == true_key:
|
|
59
|
+
request.user = user
|
|
60
|
+
return Response()
|
|
61
|
+
|
|
62
|
+
except Exception as e:
|
|
63
|
+
logging.warning(f'Slowlette_BasicAuthentication: Authentication Error: {str(e)}')
|
|
64
|
+
|
|
65
|
+
return self.require_auth(request)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@staticmethod
|
|
69
|
+
def generate_key(username:str, password:str) -> str:
|
|
70
|
+
import bcrypt
|
|
71
|
+
return username + ':' + bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12, prefix=b"2a")).decode()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class FileServer():
|
|
76
|
+
def __init__(self, filedir, *, prefix="", index_file=None, exclude=None, not_found_is_error=True, ext_allow=None, ext_deny=None):
|
|
77
|
+
"""File Server App (Middleware)
|
|
78
|
+
Args:
|
|
79
|
+
- filedir (str): path to a filesystem directory
|
|
80
|
+
- prefix (str): URL path to bind this app (e.g., "/webfile")
|
|
81
|
+
- index_file (str): index file when the path is empty (i.e., '/')
|
|
82
|
+
- exclude (str or list[str]): URL path(s) not to be handled
|
|
83
|
+
(e.g., prefix="/app" and exclude="/api" to exclude "/app/api")
|
|
84
|
+
- not_found_is_error (bool): if true return an error response (404) if file does not exist, otherwise propagate
|
|
85
|
+
- ext_allow (list[str]): a list of file extensions to allow accessing
|
|
86
|
+
- ext_deny (list[str]): a list of file extensions not to allow accessing
|
|
87
|
+
Note:
|
|
88
|
+
- file/dir names are restricted to:
|
|
89
|
+
- start with an alphabet or digit
|
|
90
|
+
- consist of only alphabets, digits, and selected special characters (_, -, +, ., =, ',', ':')
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
self.filedir = filedir
|
|
94
|
+
self.prefix = None
|
|
95
|
+
self.index_file = index_file
|
|
96
|
+
self.exclude = None
|
|
97
|
+
self.not_found_is_error = not_found_is_error
|
|
98
|
+
self.ext_allow = ext_allow
|
|
99
|
+
self.ext_deny = ext_deny
|
|
100
|
+
|
|
101
|
+
if prefix is not None:
|
|
102
|
+
self.prefix = [ p for p in prefix.split('/') if len(p) > 0 ]
|
|
103
|
+
if exclude is None:
|
|
104
|
+
self.excludes = []
|
|
105
|
+
else:
|
|
106
|
+
self.excludes = [
|
|
107
|
+
[ p for p in entry.split('/') if len(p) > 0 ]
|
|
108
|
+
for entry in (exclude if type(exclude) is list else [ exclude ])
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@route('/{*}')
|
|
113
|
+
async def dispatch(self, request:Request, body:bytes=None) -> Response:
|
|
114
|
+
# sanity check
|
|
115
|
+
path = []
|
|
116
|
+
is_dirty = False
|
|
117
|
+
for p in request.path:
|
|
118
|
+
if len(p) == 0:
|
|
119
|
+
continue
|
|
120
|
+
if not p[0].isalnum():
|
|
121
|
+
is_dirty = True
|
|
122
|
+
if not p.replace('_','').replace('-','').replace('+','').replace('=','').replace('.','').replace(',','').replace(':','').isalnum():
|
|
123
|
+
is_dirty = True
|
|
124
|
+
path.append(p)
|
|
125
|
+
|
|
126
|
+
# prefix match
|
|
127
|
+
if self.prefix is not None:
|
|
128
|
+
if len(path) < len(self.prefix):
|
|
129
|
+
# no match -> propagate
|
|
130
|
+
return Response()
|
|
131
|
+
else:
|
|
132
|
+
for i,p in enumerate(self.prefix):
|
|
133
|
+
if path[i] != p:
|
|
134
|
+
# no match -> propagate
|
|
135
|
+
return Response()
|
|
136
|
+
path = path[len(self.prefix):]
|
|
137
|
+
|
|
138
|
+
# exclude-path match
|
|
139
|
+
for exclude in self.excludes:
|
|
140
|
+
if len(path) >= len(exclude):
|
|
141
|
+
for i,p in enumerate(exclude):
|
|
142
|
+
if path[i] != p:
|
|
143
|
+
break
|
|
144
|
+
else:
|
|
145
|
+
# match to exclusion
|
|
146
|
+
return Response()
|
|
147
|
+
|
|
148
|
+
# method match
|
|
149
|
+
# We apply URL rewriting even for non-GET methods (like POST api/command -> /command),
|
|
150
|
+
# so this method check must be after the exclusion match.
|
|
151
|
+
if request.method != 'GET':
|
|
152
|
+
return Response() # propagate
|
|
153
|
+
|
|
154
|
+
# matched -> my responsibility (can return an error status) #
|
|
155
|
+
|
|
156
|
+
if self.not_found_is_error:
|
|
157
|
+
request.abort()
|
|
158
|
+
|
|
159
|
+
if is_dirty:
|
|
160
|
+
logging.warning(f'Slowlette File GET: Forbidden: sanity check failed')
|
|
161
|
+
return Response(403) # Forbidden
|
|
162
|
+
if len(path) == 0:
|
|
163
|
+
if self.index_file is not None:
|
|
164
|
+
path = [ self.index_file ]
|
|
165
|
+
else:
|
|
166
|
+
logging.warning(f'Slowlette File GET: Forbidden: no directory indexing')
|
|
167
|
+
return Response(403) # Forbidden
|
|
168
|
+
|
|
169
|
+
# file extension check
|
|
170
|
+
ext = os.path.splitext(path[-1])[1]
|
|
171
|
+
if self.ext_allow is not None:
|
|
172
|
+
if ext not in self.ext_allow:
|
|
173
|
+
logging.warning(f'Slowlette File GET: Forbidden: file type not allowed')
|
|
174
|
+
return Response(403) # Forbidden
|
|
175
|
+
elif self.ext_deny is not None:
|
|
176
|
+
if ext in self.ext_deny:
|
|
177
|
+
logging.warning(f'Slowlette File GET: Forbidden: file type denied')
|
|
178
|
+
return Response(403) # Forbidden
|
|
179
|
+
|
|
180
|
+
filepath = os.path.join(*(path))
|
|
181
|
+
if len(os.path.splitdrive(filepath)[0]) > 0: # Windows drive letter :-(
|
|
182
|
+
logging.warning(f'Slowlette File GET: Forbidden: includes Windows drive letter')
|
|
183
|
+
return Response(403) # Forbidden
|
|
184
|
+
|
|
185
|
+
filepath = os.path.join(self.filedir, filepath)
|
|
186
|
+
|
|
187
|
+
if not os.path.isfile(filepath):
|
|
188
|
+
logging.warning(f'Slowlette File GET: file not found: {filepath} in {self.filedir}')
|
|
189
|
+
if self.not_found_is_error:
|
|
190
|
+
return Response(404) # Not found
|
|
191
|
+
else:
|
|
192
|
+
return Response() # propagate
|
|
193
|
+
|
|
194
|
+
logging.debug(f'Slowlette_FileServer: file request: {filepath}')
|
|
195
|
+
|
|
196
|
+
return await FileResponse().load(filepath)
|
|
197
|
+
|
slowlette/model.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# Created by Sanshiro Enomoto on 10 January 2025 #
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
import json, logging
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class JSON:
|
|
8
|
+
"""Custom type for Slowlette to recognize that the request-body is a JSON string
|
|
9
|
+
Note:
|
|
10
|
+
- If a request-handler parameter is of this type, Slowlette interprets the body as JSON.
|
|
11
|
+
- Example:
|
|
12
|
+
| @Slowlette.post('/doc')
|
|
13
|
+
| def process_doc(doc: JSON):
|
|
14
|
+
| param1 = doc.get('param1', 0)
|
|
15
|
+
- If the parsing fails, it returns response 400 (Bad Request) and the handler will not be called.
|
|
16
|
+
- If the content data is a dict or list, JSON object can be used as dict or list for common methods,
|
|
17
|
+
- Or use the "value()" method to get the native Python type object.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, body):
|
|
21
|
+
try:
|
|
22
|
+
if type(body) is bytes:
|
|
23
|
+
self.data = json.loads(body.decode())
|
|
24
|
+
else:
|
|
25
|
+
self.data = body
|
|
26
|
+
except Exception as e:
|
|
27
|
+
logging.error('Slowlette: JSON decoding error: %s' % str(e))
|
|
28
|
+
self.data = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def value(self):
|
|
32
|
+
return self.data
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def __iter__(self):
|
|
36
|
+
if type(self.data) is dict:
|
|
37
|
+
for k, v in self.data.items():
|
|
38
|
+
yield k, v
|
|
39
|
+
elif type(self.data) is list:
|
|
40
|
+
for v in self.data:
|
|
41
|
+
yield v
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def __str__(self):
|
|
45
|
+
return str(self.data)
|
|
46
|
+
|
|
47
|
+
def __repr__(self):
|
|
48
|
+
return repr(self.data)
|
|
49
|
+
|
|
50
|
+
def __len__(self):
|
|
51
|
+
return len(self.data)
|
|
52
|
+
|
|
53
|
+
def __getitem__(self, index):
|
|
54
|
+
return self.data[index]
|
|
55
|
+
|
|
56
|
+
def __setitem__(self, index, value):
|
|
57
|
+
self.data[index] = value
|
|
58
|
+
|
|
59
|
+
def __delitem__(self, index):
|
|
60
|
+
del self.data[index]
|
|
61
|
+
|
|
62
|
+
def __contains__(self, item):
|
|
63
|
+
return item in self.data
|
|
64
|
+
|
|
65
|
+
def get(self, key, default_value=None):
|
|
66
|
+
# no type check on purpose: letting the error messages (and stack trace) displayed
|
|
67
|
+
return self.data.get(key, default_value)
|
|
68
|
+
|
|
69
|
+
def keys(self):
|
|
70
|
+
# no type check on purpose
|
|
71
|
+
return self.data.keys()
|
|
72
|
+
|
|
73
|
+
def values(self):
|
|
74
|
+
# no type check on purpose
|
|
75
|
+
return self.data.values()
|
|
76
|
+
|
|
77
|
+
def items(self):
|
|
78
|
+
# no type check on purpose
|
|
79
|
+
return self.data.items()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class DictJSON(dict):
|
|
84
|
+
"""same as JSON, but the content must be a valid dict, and the instance is a dict
|
|
85
|
+
"""
|
|
86
|
+
def __init__(self, body):
|
|
87
|
+
try:
|
|
88
|
+
if type(body) is bytes:
|
|
89
|
+
data = json.loads(body.decode())
|
|
90
|
+
else:
|
|
91
|
+
data = body
|
|
92
|
+
except Exception as e:
|
|
93
|
+
logging.error(f'Slowlette: JSON decoding error: {e}: {body}')
|
|
94
|
+
self._is_valid = False
|
|
95
|
+
return
|
|
96
|
+
|
|
97
|
+
if type(data) is not dict:
|
|
98
|
+
logging.error(f'Slowlette: DictJSON content is not a dict: {e}')
|
|
99
|
+
self._is_valid = False
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
super().__init__(data)
|
|
103
|
+
self._is_valid = True
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def value(self):
|
|
107
|
+
return dict(self) if self._is_valid else None
|
slowlette/request.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Created by Sanshiro Enomoto on 11 January 2025 #
|
|
2
|
+
|
|
3
|
+
import copy, logging
|
|
4
|
+
from urllib.parse import urlparse, parse_qsl, unquote
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Request:
|
|
8
|
+
is_async = True # True for ASGI; WSGI will change this directly
|
|
9
|
+
|
|
10
|
+
def __init__(self, url, method="GET", *, headers={}, body=None):
|
|
11
|
+
self.method = method.upper()
|
|
12
|
+
self.headers = copy.deepcopy(headers)
|
|
13
|
+
self.body = body
|
|
14
|
+
|
|
15
|
+
self.aborted = False
|
|
16
|
+
|
|
17
|
+
u = urlparse(url)
|
|
18
|
+
self.path_str = u.path
|
|
19
|
+
self.query_str = u.query
|
|
20
|
+
|
|
21
|
+
self.path = [ unquote(p) for p in self.path_str.split('/') ]
|
|
22
|
+
self.query = { unquote(key): unquote(value) for key, value in parse_qsl(self.query_str) }
|
|
23
|
+
while self.path.count(''):
|
|
24
|
+
self.path.remove('')
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def abort(self):
|
|
28
|
+
self.aborted = True
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def __str__(self):
|
|
32
|
+
return f"{self.method} /{'/'.join(self.path)}{'?' if len(self.query)>0 else ''}{'&'.join(['%s=%s'%(k,v) for k,v in self.query.items()])}"
|