mallo 0.3.0a3__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.
- mallo-0.3.0a3/LICENSE +21 -0
- mallo-0.3.0a3/MANIFEST.in +4 -0
- mallo-0.3.0a3/PKG-INFO +53 -0
- mallo-0.3.0a3/README.md +22 -0
- mallo-0.3.0a3/mallo/__init__.py +11 -0
- mallo-0.3.0a3/mallo/app.py +497 -0
- mallo-0.3.0a3/mallo/cli.py +134 -0
- mallo-0.3.0a3/mallo/defaults/404.html +61 -0
- mallo-0.3.0a3/mallo/defaults/500.html +55 -0
- mallo-0.3.0a3/mallo/hot_reload.py +232 -0
- mallo-0.3.0a3/mallo/request.py +146 -0
- mallo-0.3.0a3/mallo/response.py +158 -0
- mallo-0.3.0a3/mallo/router.py +158 -0
- mallo-0.3.0a3/mallo/template.py +164 -0
- mallo-0.3.0a3/mallo/utils.py +185 -0
- mallo-0.3.0a3/mallo.egg-info/PKG-INFO +53 -0
- mallo-0.3.0a3/mallo.egg-info/SOURCES.txt +30 -0
- mallo-0.3.0a3/mallo.egg-info/dependency_links.txt +1 -0
- mallo-0.3.0a3/mallo.egg-info/entry_points.txt +2 -0
- mallo-0.3.0a3/mallo.egg-info/not-zip-safe +1 -0
- mallo-0.3.0a3/mallo.egg-info/requires.txt +1 -0
- mallo-0.3.0a3/mallo.egg-info/top_level.txt +2 -0
- mallo-0.3.0a3/pyproject.toml +3 -0
- mallo-0.3.0a3/requirements.txt +3 -0
- mallo-0.3.0a3/setup.cfg +4 -0
- mallo-0.3.0a3/setup.py +46 -0
- mallo-0.3.0a3/tests/__init__.py +0 -0
- mallo-0.3.0a3/tests/test_app.py +53 -0
- mallo-0.3.0a3/tests/test_request.py +61 -0
- mallo-0.3.0a3/tests/test_router.py +11 -0
- mallo-0.3.0a3/tests/test_sessions.py +83 -0
- mallo-0.3.0a3/tests/test_template.py +10 -0
mallo-0.3.0a3/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Akum Bertrand
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
mallo-0.3.0a3/PKG-INFO
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mallo
|
|
3
|
+
Version: 0.3.0a3
|
|
4
|
+
Summary: A lightweight web framework with hot reload
|
|
5
|
+
Home-page: https://github.com/Betrand-dev/mallo-fr.git
|
|
6
|
+
Author: Akum betrand
|
|
7
|
+
Author-email: betrandojong146@gmail.com
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: WSGI
|
|
16
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: WSGI :: Application
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: watchdog>=2.1.0
|
|
21
|
+
Dynamic: author
|
|
22
|
+
Dynamic: author-email
|
|
23
|
+
Dynamic: classifier
|
|
24
|
+
Dynamic: description
|
|
25
|
+
Dynamic: description-content-type
|
|
26
|
+
Dynamic: home-page
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
Dynamic: requires-dist
|
|
29
|
+
Dynamic: requires-python
|
|
30
|
+
Dynamic: summary
|
|
31
|
+
|
|
32
|
+
**Mallo Framework**
|
|
33
|
+
|
|
34
|
+
Lightweight web framework with hot reload and a tiny core.
|
|
35
|
+
|
|
36
|
+
Docs index: `docs/index.md`
|
|
37
|
+
|
|
38
|
+
**Install (from source)**
|
|
39
|
+
```powershell
|
|
40
|
+
pip install -e .
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
**CLI**
|
|
44
|
+
```powershell
|
|
45
|
+
mallo create myapp
|
|
46
|
+
mallo run app:app
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Note: Gunicorn is not supported on Windows. Use `python app.py` on Windows.
|
|
50
|
+
On Linux, install gunicorn:
|
|
51
|
+
```powershell
|
|
52
|
+
pip install gunicorn
|
|
53
|
+
```
|
mallo-0.3.0a3/README.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
**Mallo Framework**
|
|
2
|
+
|
|
3
|
+
Lightweight web framework with hot reload and a tiny core.
|
|
4
|
+
|
|
5
|
+
Docs index: `docs/index.md`
|
|
6
|
+
|
|
7
|
+
**Install (from source)**
|
|
8
|
+
```powershell
|
|
9
|
+
pip install -e .
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
**CLI**
|
|
13
|
+
```powershell
|
|
14
|
+
mallo create myapp
|
|
15
|
+
mallo run app:app
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Note: Gunicorn is not supported on Windows. Use `python app.py` on Windows.
|
|
19
|
+
On Linux, install gunicorn:
|
|
20
|
+
```powershell
|
|
21
|
+
pip install gunicorn
|
|
22
|
+
```
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Mallo - A lightweight Web framework with Hot reload
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from mallo.app import Mallo
|
|
6
|
+
from mallo.request import Request
|
|
7
|
+
from mallo.response import Response
|
|
8
|
+
from mallo.cli import cli
|
|
9
|
+
|
|
10
|
+
__version__ = "0.3.0a3"
|
|
11
|
+
__all__ = ['Mallo', 'Request', 'Response', 'cli' ]
|
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core Application for Mallo framework
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
import hmac
|
|
9
|
+
import hashlib
|
|
10
|
+
import secrets
|
|
11
|
+
import logging
|
|
12
|
+
from email.utils import formatdate
|
|
13
|
+
from wsgiref.simple_server import make_server, WSGIRequestHandler
|
|
14
|
+
from mallo.router import Router
|
|
15
|
+
from mallo.request import Request
|
|
16
|
+
from mallo.response import Response
|
|
17
|
+
from mallo.template import render_template_file
|
|
18
|
+
from mallo.utils import generate_etag
|
|
19
|
+
from mallo.hot_reload import HotReloader
|
|
20
|
+
|
|
21
|
+
class Mallo:
|
|
22
|
+
"""
|
|
23
|
+
Main application class for Mallo framework
|
|
24
|
+
Example:
|
|
25
|
+
app = Mallo(__name__)
|
|
26
|
+
@app.route("/")
|
|
27
|
+
def home(request):
|
|
28
|
+
return "<h1>Hello, Mallo!</h1>"
|
|
29
|
+
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, import_name, static_url_path="/static", **config):
|
|
33
|
+
self.import_name = import_name
|
|
34
|
+
self.static_url_path = static_url_path
|
|
35
|
+
self.config = config
|
|
36
|
+
self.router = Router()
|
|
37
|
+
self.hot_reloader = None
|
|
38
|
+
env_debug = os.environ.get('MALLO_DEBUG')
|
|
39
|
+
self.debug = config.get('debug', env_debug == '1')
|
|
40
|
+
env_live = os.environ.get('MALLO_LIVE_RELOAD')
|
|
41
|
+
self.live_reload = config.get('live_reload', env_live == '1' if env_live is not None else True)
|
|
42
|
+
self._reload_token = f"{os.getpid()}-{time.time_ns()}"
|
|
43
|
+
self.secret_key = config.get('secret_key')
|
|
44
|
+
self.csrf_protect = config.get('csrf_protect', True)
|
|
45
|
+
self._session_cookie = config.get('session_cookie', 'mallo_session')
|
|
46
|
+
self._session_store = {}
|
|
47
|
+
self.before_request_funcs = []
|
|
48
|
+
self.after_request_funcs = []
|
|
49
|
+
self.error_handlers = {}
|
|
50
|
+
self._enable_logging = config.get('enable_logging', True)
|
|
51
|
+
self._security_headers = config.get('security_headers', True)
|
|
52
|
+
self._static_cache_seconds = config.get('static_cache_seconds')
|
|
53
|
+
if self._static_cache_seconds is None:
|
|
54
|
+
self._static_cache_seconds = 0 if self.debug else 3600
|
|
55
|
+
self.logger = logging.getLogger('mallo')
|
|
56
|
+
self._default_404 = os.path.join(os.path.dirname(__file__), 'defaults', '404.html')
|
|
57
|
+
self._default_500 = os.path.join(os.path.dirname(__file__), 'defaults', '500.html')
|
|
58
|
+
|
|
59
|
+
# Add static route if static folder exists
|
|
60
|
+
self._setup_static_routing()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _setup_static_routing(self):
|
|
64
|
+
""" Setup static file serving if static folder exists """
|
|
65
|
+
static_folder = self.config.get('static_folder', 'static')
|
|
66
|
+
if os.path.exists(static_folder):
|
|
67
|
+
@self.route(f'{self.static_url_path}/<path:filename>')
|
|
68
|
+
def serve_static(request, filename):
|
|
69
|
+
filepath = os.path.join(static_folder, filename)
|
|
70
|
+
if os.path.exists(filepath) and os.path.isfile(filepath):
|
|
71
|
+
with open(filepath, 'rb') as f:
|
|
72
|
+
content = f.read()
|
|
73
|
+
etag = generate_etag(content)
|
|
74
|
+
last_modified = formatdate(os.path.getmtime(filepath), usegmt=True)
|
|
75
|
+
if request.headers.get('If-None-Match') == etag:
|
|
76
|
+
response = Response('', status=304)
|
|
77
|
+
response.headers['ETag'] = etag
|
|
78
|
+
response.headers['Last-Modified'] = last_modified
|
|
79
|
+
return response
|
|
80
|
+
response = Response(content, headers={
|
|
81
|
+
'Content-Type': self._guess_mime_type(filename)
|
|
82
|
+
})
|
|
83
|
+
response.headers['ETag'] = etag
|
|
84
|
+
response.headers['Last-Modified'] = last_modified
|
|
85
|
+
if self._static_cache_seconds > 0:
|
|
86
|
+
response.headers['Cache-Control'] = f'public, max-age={self._static_cache_seconds}'
|
|
87
|
+
else:
|
|
88
|
+
response.headers['Cache-Control'] = 'no-store'
|
|
89
|
+
return response
|
|
90
|
+
return Response('File not found', status = 404)
|
|
91
|
+
|
|
92
|
+
def route(self, path, methods=None):
|
|
93
|
+
"""
|
|
94
|
+
Decorator to register a route
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
path: URL path (e.g., '/', '/user/<name>')
|
|
98
|
+
methods: List of HTTP methods (default: ['GET'])
|
|
99
|
+
|
|
100
|
+
Example:
|
|
101
|
+
@app.route('/hello/<name>')
|
|
102
|
+
def hello(request, name):
|
|
103
|
+
return f'Hello, {name}!'
|
|
104
|
+
|
|
105
|
+
:param path
|
|
106
|
+
:param methods
|
|
107
|
+
:return:
|
|
108
|
+
"""
|
|
109
|
+
if methods is None:
|
|
110
|
+
methods = ['GET']
|
|
111
|
+
|
|
112
|
+
def decorator(handler):
|
|
113
|
+
self.router.add_route(path, handler, methods)
|
|
114
|
+
return handler
|
|
115
|
+
return decorator
|
|
116
|
+
|
|
117
|
+
def get(self, path):
|
|
118
|
+
"""
|
|
119
|
+
Shorthand for the route with GET method
|
|
120
|
+
:param path:
|
|
121
|
+
:return:
|
|
122
|
+
"""
|
|
123
|
+
return self.route(path, methods=['GET'])
|
|
124
|
+
|
|
125
|
+
def post(self, path):
|
|
126
|
+
"""
|
|
127
|
+
Shorthand for route with POST method
|
|
128
|
+
:param path:
|
|
129
|
+
:return:
|
|
130
|
+
"""
|
|
131
|
+
return self.route(path, methods=['POST'])
|
|
132
|
+
|
|
133
|
+
def put(self, path):
|
|
134
|
+
"""
|
|
135
|
+
Shorthand for route with PUT method
|
|
136
|
+
:param path:
|
|
137
|
+
:return:
|
|
138
|
+
"""
|
|
139
|
+
return self.route(path, methods=['PUT'])
|
|
140
|
+
|
|
141
|
+
def delete(self, path):
|
|
142
|
+
"""
|
|
143
|
+
Shorthand for route with DELETE method
|
|
144
|
+
:param path:
|
|
145
|
+
:return:
|
|
146
|
+
"""
|
|
147
|
+
return self.route(path, methods=['DELETE'])
|
|
148
|
+
|
|
149
|
+
def render_template(self, template_path, **context):
|
|
150
|
+
"""
|
|
151
|
+
Render a template from any file path
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
template_path: path to template file (absolute or relative)
|
|
155
|
+
**context: Variable to pass to template
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
Rendered template as a string
|
|
159
|
+
|
|
160
|
+
Example:
|
|
161
|
+
@app.route('/')
|
|
162
|
+
def home(request):
|
|
163
|
+
return app.render_template('templates/home.html', name = 'Mallo User')
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
:param template_path:
|
|
167
|
+
:param context:
|
|
168
|
+
:return:
|
|
169
|
+
"""
|
|
170
|
+
auto_escape = self.config.get('auto_escape', True)
|
|
171
|
+
return render_template_file(template_path, auto_reload=self.debug, auto_escape=auto_escape, **context)
|
|
172
|
+
|
|
173
|
+
def before_request(self, func):
|
|
174
|
+
self.before_request_funcs.append(func)
|
|
175
|
+
return func
|
|
176
|
+
|
|
177
|
+
def after_request(self, func):
|
|
178
|
+
self.after_request_funcs.append(func)
|
|
179
|
+
return func
|
|
180
|
+
|
|
181
|
+
def errorhandler(self, status_code: int):
|
|
182
|
+
def decorator(handler):
|
|
183
|
+
self.error_handlers[status_code] = handler
|
|
184
|
+
return handler
|
|
185
|
+
return decorator
|
|
186
|
+
|
|
187
|
+
def url_for(self, handler_name: str, **kwargs):
|
|
188
|
+
return self.router.url_for(handler_name, **kwargs)
|
|
189
|
+
|
|
190
|
+
def _sign_session_id(self, session_id: str) -> str:
|
|
191
|
+
secret = (self.secret_key or '').encode()
|
|
192
|
+
return hmac.new(secret, session_id.encode(), hashlib.sha256).hexdigest()
|
|
193
|
+
|
|
194
|
+
def _make_session_cookie(self, session_id: str) -> str:
|
|
195
|
+
return f"{session_id}|{self._sign_session_id(session_id)}"
|
|
196
|
+
|
|
197
|
+
def _load_session(self, request):
|
|
198
|
+
if not self.secret_key:
|
|
199
|
+
request.session = None
|
|
200
|
+
request.session_id = None
|
|
201
|
+
request._session_dirty = False
|
|
202
|
+
return
|
|
203
|
+
|
|
204
|
+
raw = request.cookies.get(self._session_cookie, '')
|
|
205
|
+
session_id = None
|
|
206
|
+
if raw and '|' in raw:
|
|
207
|
+
sid, sig = raw.split('|', 1)
|
|
208
|
+
if hmac.compare_digest(sig, self._sign_session_id(sid)):
|
|
209
|
+
session_id = sid
|
|
210
|
+
|
|
211
|
+
if not session_id:
|
|
212
|
+
session_id = secrets.token_urlsafe(24)
|
|
213
|
+
|
|
214
|
+
if session_id not in self._session_store:
|
|
215
|
+
self._session_store[session_id] = {}
|
|
216
|
+
|
|
217
|
+
class SessionData(dict):
|
|
218
|
+
def __init__(self, parent, *args, **kwargs):
|
|
219
|
+
super().__init__(*args, **kwargs)
|
|
220
|
+
self._parent = parent
|
|
221
|
+
def __setitem__(self, key, value):
|
|
222
|
+
self._parent._session_dirty = True
|
|
223
|
+
return super().__setitem__(key, value)
|
|
224
|
+
def __delitem__(self, key):
|
|
225
|
+
self._parent._session_dirty = True
|
|
226
|
+
return super().__delitem__(key)
|
|
227
|
+
|
|
228
|
+
request.session_id = session_id
|
|
229
|
+
request.session = SessionData(request)
|
|
230
|
+
request.session.update(self._session_store[session_id])
|
|
231
|
+
request._session_dirty = False
|
|
232
|
+
|
|
233
|
+
if 'csrf_token' not in request.session:
|
|
234
|
+
request.session['csrf_token'] = secrets.token_urlsafe(32)
|
|
235
|
+
request.csrf_token = request.session.get('csrf_token')
|
|
236
|
+
|
|
237
|
+
def _ensure_session_cookie(self, request, response):
|
|
238
|
+
if not self.secret_key or request.session_id is None:
|
|
239
|
+
return
|
|
240
|
+
if request._session_dirty:
|
|
241
|
+
self._session_store[request.session_id] = dict(request.session)
|
|
242
|
+
cookie_value = self._make_session_cookie(request.session_id)
|
|
243
|
+
response.set_cookie(self._session_cookie, cookie_value, path='/')
|
|
244
|
+
|
|
245
|
+
def _csrf_check(self, request):
|
|
246
|
+
if not self.secret_key or not self.csrf_protect:
|
|
247
|
+
return True
|
|
248
|
+
if request.method in ('GET', 'HEAD', 'OPTIONS'):
|
|
249
|
+
return True
|
|
250
|
+
|
|
251
|
+
token = None
|
|
252
|
+
if request.headers.get('X-Csrf-Token'):
|
|
253
|
+
token = request.headers.get('X-Csrf-Token')
|
|
254
|
+
elif request.form:
|
|
255
|
+
token = request.form.get('csrf_token')
|
|
256
|
+
elif request.json:
|
|
257
|
+
token = request.json.get('csrf_token')
|
|
258
|
+
|
|
259
|
+
return token and token == request.csrf_token
|
|
260
|
+
|
|
261
|
+
def _apply_security_headers(self, response):
|
|
262
|
+
if not self._security_headers:
|
|
263
|
+
return response
|
|
264
|
+
response.headers.setdefault('X-Content-Type-Options', 'nosniff')
|
|
265
|
+
response.headers.setdefault('X-Frame-Options', 'DENY')
|
|
266
|
+
response.headers.setdefault('Referrer-Policy', 'same-origin')
|
|
267
|
+
return response
|
|
268
|
+
|
|
269
|
+
def _handle_error(self, request, status_code, default_path):
|
|
270
|
+
handler = self.error_handlers.get(status_code)
|
|
271
|
+
if handler:
|
|
272
|
+
result = handler(request)
|
|
273
|
+
if not isinstance(result, Response):
|
|
274
|
+
result = Response(result, status=status_code)
|
|
275
|
+
return result
|
|
276
|
+
|
|
277
|
+
custom_path = self.config.get(f'error_page_{status_code}')
|
|
278
|
+
template_path = custom_path or default_path
|
|
279
|
+
content = render_template_file(template_path, auto_reload=self.debug, auto_escape=True)
|
|
280
|
+
return Response(content, status=status_code)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def __call__(self, environ, start_response):
|
|
284
|
+
"""
|
|
285
|
+
WSGI application entry point
|
|
286
|
+
:param environ:
|
|
287
|
+
:param start_response:
|
|
288
|
+
:return:
|
|
289
|
+
"""
|
|
290
|
+
start_time = time.perf_counter()
|
|
291
|
+
request = Request(environ)
|
|
292
|
+
self._load_session(request)
|
|
293
|
+
|
|
294
|
+
# Lightweight endpoint used by browser-side live reload polling.
|
|
295
|
+
if request.path == '/__mallo_reload__':
|
|
296
|
+
response = Response(
|
|
297
|
+
self._reload_token,
|
|
298
|
+
headers={
|
|
299
|
+
'Content-Type': 'text/plain',
|
|
300
|
+
'Cache-Control': 'no-store, no-cache, must-revalidate',
|
|
301
|
+
'Pragma': 'no-cache',
|
|
302
|
+
'Expires': '0'
|
|
303
|
+
}
|
|
304
|
+
)
|
|
305
|
+
response = self._apply_security_headers(response)
|
|
306
|
+
self._ensure_session_cookie(request, response)
|
|
307
|
+
return response.to_wsgi(start_response)
|
|
308
|
+
|
|
309
|
+
for func in self.before_request_funcs:
|
|
310
|
+
result = func(request)
|
|
311
|
+
if isinstance(result, Response):
|
|
312
|
+
if self.debug:
|
|
313
|
+
self._set_no_cache_headers(result)
|
|
314
|
+
result = self._apply_security_headers(result)
|
|
315
|
+
self._ensure_session_cookie(request, result)
|
|
316
|
+
return result.to_wsgi(start_response)
|
|
317
|
+
|
|
318
|
+
# CSRF protection for unsafe methods
|
|
319
|
+
if not self._csrf_check(request):
|
|
320
|
+
response = Response('<h1>403 Forbidden</h1>', status=403)
|
|
321
|
+
if self.debug:
|
|
322
|
+
self._set_no_cache_headers(response)
|
|
323
|
+
response = self._apply_security_headers(response)
|
|
324
|
+
self._ensure_session_cookie(request, response)
|
|
325
|
+
return response.to_wsgi(start_response)
|
|
326
|
+
|
|
327
|
+
#find matching route
|
|
328
|
+
route_match = self.router.match(request.path, request.method)
|
|
329
|
+
|
|
330
|
+
if route_match:
|
|
331
|
+
handler, kwargs = route_match
|
|
332
|
+
try:
|
|
333
|
+
# Call handler with request and capture kwargs
|
|
334
|
+
result = handler(request, **kwargs)
|
|
335
|
+
|
|
336
|
+
# Convert result to Response if needed
|
|
337
|
+
if not isinstance(result, Response):
|
|
338
|
+
result = Response(result)
|
|
339
|
+
|
|
340
|
+
if self.debug:
|
|
341
|
+
self._set_no_cache_headers(result)
|
|
342
|
+
if self.debug and self.live_reload:
|
|
343
|
+
result = self._attach_live_reload(result)
|
|
344
|
+
|
|
345
|
+
result = self._apply_security_headers(result)
|
|
346
|
+
for func in self.after_request_funcs:
|
|
347
|
+
result = func(request, result) or result
|
|
348
|
+
self._ensure_session_cookie(request, result)
|
|
349
|
+
if self._enable_logging:
|
|
350
|
+
elapsed = (time.perf_counter() - start_time) * 1000
|
|
351
|
+
self.logger.info('%s %s -> %s (%.2fms)', request.method, request.path, result.status, elapsed)
|
|
352
|
+
return result.to_wsgi(start_response)
|
|
353
|
+
except Exception as e:
|
|
354
|
+
if self.debug:
|
|
355
|
+
import traceback
|
|
356
|
+
error_response = Response(
|
|
357
|
+
f"<h1>Error</h1><pre>{traceback.format_exc()}</pre>",
|
|
358
|
+
status=500
|
|
359
|
+
)
|
|
360
|
+
self._ensure_session_cookie(request, error_response)
|
|
361
|
+
error_response = self._apply_security_headers(error_response)
|
|
362
|
+
return error_response.to_wsgi(start_response)
|
|
363
|
+
error_response = self._handle_error(request, 500, self._default_500)
|
|
364
|
+
self._ensure_session_cookie(request, error_response)
|
|
365
|
+
error_response = self._apply_security_headers(error_response)
|
|
366
|
+
return error_response.to_wsgi(start_response)
|
|
367
|
+
else:
|
|
368
|
+
# 404 Not Found
|
|
369
|
+
response = self._handle_error(request, 404, self._default_404)
|
|
370
|
+
if self.debug:
|
|
371
|
+
self._set_no_cache_headers(response)
|
|
372
|
+
response = self._apply_security_headers(response)
|
|
373
|
+
self._ensure_session_cookie(request, response)
|
|
374
|
+
if self._enable_logging:
|
|
375
|
+
elapsed = (time.perf_counter() - start_time) * 1000
|
|
376
|
+
self.logger.info('%s %s -> %s (%.2fms)', request.method, request.path, response.status, elapsed)
|
|
377
|
+
return response.to_wsgi(start_response)
|
|
378
|
+
|
|
379
|
+
def _set_no_cache_headers(self, response):
|
|
380
|
+
"""
|
|
381
|
+
Prevent browser caching while debugging.
|
|
382
|
+
"""
|
|
383
|
+
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
|
|
384
|
+
response.headers['Pragma'] = 'no-cache'
|
|
385
|
+
response.headers['Expires'] = '0'
|
|
386
|
+
|
|
387
|
+
def _attach_live_reload(self, response):
|
|
388
|
+
"""
|
|
389
|
+
Inject browser auto-refresh script into HTML responses in debug mode.
|
|
390
|
+
"""
|
|
391
|
+
content_type = response.headers.get('Content-Type', '')
|
|
392
|
+
if not isinstance(response.body, str):
|
|
393
|
+
return response
|
|
394
|
+
if 'text/html' not in content_type.lower():
|
|
395
|
+
return response
|
|
396
|
+
if '__mallo_reload__' in response.body:
|
|
397
|
+
return response
|
|
398
|
+
|
|
399
|
+
script = """
|
|
400
|
+
<script>
|
|
401
|
+
(function () {
|
|
402
|
+
var token = null;
|
|
403
|
+
function checkReload() {
|
|
404
|
+
fetch('/__mallo_reload__?t=' + Date.now(), { cache: 'no-store' })
|
|
405
|
+
.then(function (res) { return res.text(); })
|
|
406
|
+
.then(function (value) {
|
|
407
|
+
value = value.trim();
|
|
408
|
+
if (token === null) {
|
|
409
|
+
token = value;
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
if (token !== value) {
|
|
413
|
+
window.location.reload();
|
|
414
|
+
}
|
|
415
|
+
})
|
|
416
|
+
.catch(function () {});
|
|
417
|
+
}
|
|
418
|
+
setInterval(checkReload, 1000);
|
|
419
|
+
checkReload();
|
|
420
|
+
})();
|
|
421
|
+
</script>
|
|
422
|
+
"""
|
|
423
|
+
|
|
424
|
+
body = response.body
|
|
425
|
+
if '</body>' in body:
|
|
426
|
+
body = body.replace('</body>', script + '\n</body>', 1)
|
|
427
|
+
else:
|
|
428
|
+
body = body + script
|
|
429
|
+
response.body = body
|
|
430
|
+
return response
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def run(self, host='localhost', port=8000, debug=False, use_reloader=True):
|
|
434
|
+
"""
|
|
435
|
+
Run the development server
|
|
436
|
+
|
|
437
|
+
Args:
|
|
438
|
+
host: Host to bind to
|
|
439
|
+
port: Port to listen on
|
|
440
|
+
debug: Enable debug mode
|
|
441
|
+
use_reloader: Enable hot reload
|
|
442
|
+
|
|
443
|
+
:param host:
|
|
444
|
+
:param port:
|
|
445
|
+
:param debug:
|
|
446
|
+
:param use_reloader:
|
|
447
|
+
:return:
|
|
448
|
+
"""
|
|
449
|
+
self.debug = debug
|
|
450
|
+
|
|
451
|
+
in_reloader_subprocess = os.environ.get('MALLO_HOT_RELOAD') == '1'
|
|
452
|
+
if use_reloader and debug and not in_reloader_subprocess:
|
|
453
|
+
# Start with hot reload
|
|
454
|
+
self.hot_reloader = HotReloader(self)
|
|
455
|
+
self.hot_reloader.run(host, port)
|
|
456
|
+
else:
|
|
457
|
+
# Start Normally
|
|
458
|
+
print(f" * Running on http://{host}:{port}/")
|
|
459
|
+
print(f" * Debug Mode: {'on' if debug else 'off'}")
|
|
460
|
+
|
|
461
|
+
class QuietReloadRequestHandler(WSGIRequestHandler):
|
|
462
|
+
def log_message(self, format, *args):
|
|
463
|
+
try:
|
|
464
|
+
request_line = args[0] if args else ""
|
|
465
|
+
if "__mallo_reload__" in str(request_line):
|
|
466
|
+
return
|
|
467
|
+
except Exception:
|
|
468
|
+
pass
|
|
469
|
+
super().log_message(format, *args)
|
|
470
|
+
|
|
471
|
+
server = make_server(host, port, self, handler_class=QuietReloadRequestHandler)
|
|
472
|
+
try:
|
|
473
|
+
server.serve_forever()
|
|
474
|
+
except KeyboardInterrupt:
|
|
475
|
+
print("\n * Server stopped")
|
|
476
|
+
|
|
477
|
+
def _guess_mime_type(self, filename):
|
|
478
|
+
"""
|
|
479
|
+
Guess MIME type from filename extension
|
|
480
|
+
:param filename:
|
|
481
|
+
:return:
|
|
482
|
+
"""
|
|
483
|
+
ext_map ={
|
|
484
|
+
'.html': 'text/html',
|
|
485
|
+
'.css': 'text/css',
|
|
486
|
+
'.js': 'application/javascript',
|
|
487
|
+
'.json': 'application/json',
|
|
488
|
+
'.png': 'image/png',
|
|
489
|
+
'.jpg': 'image/jpeg',
|
|
490
|
+
'.jpeg': 'image/jpeg',
|
|
491
|
+
'.gif': 'image/gif',
|
|
492
|
+
'.svg': 'image/svg+xml',
|
|
493
|
+
'.txt': 'text/plain'
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
ext = os.path.splitext(filename)[1].lower()
|
|
497
|
+
return ext_map.get(ext, 'application/octet-stream')
|