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 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.
@@ -0,0 +1,4 @@
1
+ include README.md
2
+ include LICENSE
3
+ include requirements.txt
4
+ recursive-include mallo/defaults *.html
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
+ ```
@@ -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')