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 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
@@ -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
@@ -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()])}"