lema-basic-web-backend 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.
@@ -0,0 +1,86 @@
1
+ from importlib.metadata import version
2
+
3
+ from .adapters import LemaRequestAdapter, LemaResponseAdapter
4
+ from .application import WebApplication
5
+ from .config import ApplicationConfig
6
+ from .exceptions import (
7
+ AmbiguousRouteError,
8
+ BackendError,
9
+ BadRequest,
10
+ Conflict,
11
+ DuplicateRouteError,
12
+ Forbidden,
13
+ HTTPException,
14
+ InvalidRouteError,
15
+ MethodNotAllowed,
16
+ NotFound,
17
+ PayloadTooLarge,
18
+ RoutingError,
19
+ TemplateError,
20
+ TemplateLoadError,
21
+ TemplateNotFound,
22
+ TemplateRenderError,
23
+ TemplateSyntaxError,
24
+ Unauthorized,
25
+ UndefinedVariableError,
26
+ UnprocessableContent,
27
+ UnsupportedMediaType,
28
+ )
29
+
30
+ from .multipart import UploadedFile
31
+ from .request import Request
32
+ from .response import (
33
+ content_response,
34
+ delete_cookie,
35
+ empty_response,
36
+ file_response,
37
+ html_response,
38
+ json_response,
39
+ redirect_response,
40
+ set_cookie,
41
+ text_response,
42
+ )
43
+ from .template.environment import TemplateEnvironment
44
+
45
+ __version__ = version("lema-basic-web-backend")
46
+
47
+ __all__ = [
48
+ "__version__",
49
+ "WebApplication",
50
+ "ApplicationConfig",
51
+ "Request",
52
+ "UploadedFile",
53
+ "LemaRequestAdapter",
54
+ "LemaResponseAdapter",
55
+ "TemplateEnvironment",
56
+ "content_response",
57
+ "html_response",
58
+ "text_response",
59
+ "json_response",
60
+ "file_response",
61
+ "redirect_response",
62
+ "empty_response",
63
+ "set_cookie",
64
+ "delete_cookie",
65
+ "BackendError",
66
+ "RoutingError",
67
+ "DuplicateRouteError",
68
+ "InvalidRouteError",
69
+ "AmbiguousRouteError",
70
+ "HTTPException",
71
+ "BadRequest",
72
+ "Unauthorized",
73
+ "Forbidden",
74
+ "NotFound",
75
+ "MethodNotAllowed",
76
+ "Conflict",
77
+ "PayloadTooLarge",
78
+ "UnsupportedMediaType",
79
+ "UnprocessableContent",
80
+ "TemplateError",
81
+ "TemplateNotFound",
82
+ "TemplateLoadError",
83
+ "TemplateSyntaxError",
84
+ "TemplateRenderError",
85
+ "UndefinedVariableError"
86
+ ]
@@ -0,0 +1,39 @@
1
+ from .request import Request
2
+
3
+ class LemaRequestAdapter:
4
+ def convert(self, server_request):
5
+ method = server_request.method
6
+ path = server_request.path
7
+ query_string = server_request.query_string
8
+ headers = server_request.headers
9
+ body = server_request.body
10
+
11
+ return Request(method, path, query_string, headers, body)
12
+
13
+ class LemaResponseAdapter:
14
+ def convert(self, backend_response):
15
+ if not isinstance(backend_response, tuple):
16
+ return backend_response
17
+ if len(backend_response) != 3:
18
+ return backend_response
19
+
20
+ body, status_code, headers = backend_response
21
+
22
+ filtered_headers = self._remove_content_length(headers)
23
+
24
+ return body, status_code, filtered_headers
25
+
26
+ def _remove_content_length(self, headers):
27
+ if headers is None:
28
+ return None
29
+
30
+ if isinstance(headers, dict):
31
+ return {
32
+ name: value for name, value in headers.items()
33
+ if name.lower() != "content-length"
34
+ }
35
+
36
+ return [
37
+ (name, value) for name, value in headers
38
+ if name.lower() != "content-length"
39
+ ]
@@ -0,0 +1,217 @@
1
+ from html import escape
2
+ from traceback import format_exc
3
+
4
+ from .config import ApplicationConfig
5
+ from .logging_config import create_logger
6
+ from .exceptions import BadRequest, HTTPException, PayloadTooLarge
7
+ from .response import html_response
8
+ from .request import Request
9
+ from .routing import Router
10
+ from .static import StaticFileHandler
11
+ from .template.environment import TemplateEnvironment
12
+
13
+
14
+ class WebApplication:
15
+ def __init__(self, config=None):
16
+ if config is None:
17
+ config = ApplicationConfig()
18
+
19
+ self.config = config
20
+
21
+ self.logger = create_logger(
22
+ name=self.config.logger_name,
23
+ log_file=self.config.log_file,
24
+ log_level=self.config.log_level,
25
+ log_max_bytes=self.config.log_max_bytes,
26
+ log_backup_count=self.config.log_backup_count
27
+ )
28
+
29
+ self.template_environment = TemplateEnvironment(
30
+ template_folder=self.config.template_folder,
31
+ encoding=self.config.template_encoding,
32
+ autoescape=self.config.template_autoescape,
33
+ cache_enabled=self.config.template_cache,
34
+ auto_reload=self.config.template_auto_reload
35
+ )
36
+
37
+ self.router = Router()
38
+ self.error_handlers = {}
39
+ self.static_handler = None
40
+
41
+ if self.config.static_folder is not None:
42
+ self._register_static_route()
43
+
44
+ def _register_static_route(self):
45
+ static_url_path = ("/" + self.config.static_url_path.strip("/"))
46
+
47
+ if static_url_path == "/":
48
+ route_path = "/<path:filename>"
49
+ else:
50
+ route_path = f"{static_url_path}/<path:filename>"
51
+
52
+ self.static_handler = StaticFileHandler(static_folder=self.config.static_folder)
53
+ self.router.add_route(path=route_path, view=self._serve_static_file, methods=["GET"])
54
+
55
+ def _serve_static_file(self, request, filename):
56
+ return self.static_handler.serve(filename=filename)
57
+
58
+ def route(self, path, methods=None):
59
+ def decorator(view):
60
+ self.router.add_route(path=path, view=view, methods=methods)
61
+ return view
62
+ return decorator
63
+
64
+ def errorhandler(self, status_code):
65
+ if not isinstance(status_code, int) or isinstance(status_code, bool):
66
+ raise TypeError("The error handler status code must be an integer.")
67
+
68
+ if not (400 <= status_code <= 599):
69
+ raise ValueError("The error handler status code must be between 400 and 599.")
70
+
71
+ def decorator(handler):
72
+ if not callable(handler):
73
+ raise TypeError("The error handler must be a callable.")
74
+
75
+ if status_code in self.error_handlers:
76
+ raise ValueError(f"An error handler is already registered for status code {status_code}.")
77
+
78
+ self.error_handlers[status_code] = handler
79
+ return handler
80
+
81
+ return decorator
82
+
83
+ def __call__(self, server_request):
84
+ request = None
85
+
86
+ try:
87
+ request = self._convert_request(server_request)
88
+
89
+ self._check_content_length(request)
90
+
91
+ route_match = self.router.match_route(path=request.path, method=request.method)
92
+
93
+ response = route_match.view(request=request, **route_match.parameters)
94
+
95
+ except HTTPException as error:
96
+ response = self._handle_http_exception(error, request=request)
97
+
98
+
99
+ except Exception as error:
100
+ response = self._handle_internal_error(error, request=request)
101
+
102
+ return self._convert_response(response=response, request=request)
103
+
104
+ def _convert_request(self, server_request):
105
+ request = self.config.request_adapter.convert(server_request)
106
+
107
+ if not isinstance(request, Request):
108
+ raise TypeError(
109
+ "The request adapter must return a Request object."
110
+ )
111
+
112
+ return request
113
+
114
+ def _convert_response(self, response, request=None):
115
+ try:
116
+ return self.config.response_adapter.convert(response)
117
+ except Exception:
118
+ if request is None:
119
+ self.logger.exception("The response adapter failed.")
120
+ else:
121
+ self.logger.exception(
122
+ "The response adapter failed while processing a request: %s %s",
123
+ request.method,
124
+ request.path
125
+ )
126
+ raise
127
+
128
+ def _handle_http_exception(self, error, request=None):
129
+ handler = self.error_handlers.get(error.status_code)
130
+
131
+ if handler is not None:
132
+ try:
133
+ return handler(error)
134
+ except Exception as handler_error:
135
+ return self._handle_internal_error(handler_error, request=request)
136
+
137
+ title = f"{error.status_code} {error.default_message}"
138
+
139
+ body = f"<h1>{escape(title)}</h1>"
140
+
141
+ if error.message != error.default_message:
142
+ body += f"<p>{escape(error.message)}</p>"
143
+
144
+ return html_response(
145
+ body=body,
146
+ status_code=error.status_code,
147
+ headers=error.headers
148
+ )
149
+
150
+ def _handle_internal_error(self, error, request=None):
151
+ if request is None:
152
+ self.logger.exception("Unhandled exception while adapting a server request.")
153
+ else:
154
+ self.logger.exception(
155
+ "Unhandled exception while processing a request: %s %s",
156
+ request.method,
157
+ request.path
158
+ )
159
+
160
+ handler = self.error_handlers.get(500)
161
+
162
+ if handler is not None:
163
+ try:
164
+ return handler(error)
165
+ except Exception as handler_error:
166
+ self.logger.exception("The custom 500 error handler failed.")
167
+
168
+ return self._create_internal_error_response(handler_error)
169
+
170
+ return self._create_internal_error_response(error)
171
+
172
+ def _create_internal_error_response(self, error):
173
+ if self.config.debug:
174
+ error_type = type(error).__name__
175
+ error_message = escape(str(error))
176
+ error_traceback = escape(format_exc())
177
+
178
+ return html_response(
179
+ body=(
180
+ f"<h1>500 Internal Server Error</h1>"
181
+ f"<h2>{error_type}: {error_message}</h2>"
182
+ f"<pre>{error_traceback}</pre>"
183
+ ),
184
+ status_code=500
185
+ )
186
+
187
+ return html_response(
188
+ body="<h1>500 Internal Server Error</h1>",
189
+ status_code=500
190
+ )
191
+
192
+ def _check_content_length(self, request):
193
+ limit = self.config.max_content_length
194
+
195
+ if limit is None:
196
+ return
197
+
198
+ body = request.body
199
+
200
+ if isinstance(body, str):
201
+ body_length = len(body.encode("utf-8"))
202
+ else:
203
+ body_length = len(body)
204
+
205
+ if body_length > limit:
206
+ raise PayloadTooLarge(f"The request body exceeds the {limit}-byte limit.")
207
+
208
+ def render_template(self, template_name, status_code=200, headers=None, charset="utf-8", **context):
209
+ rendered_html = self.template_environment.render(template_name, **context)
210
+
211
+ return html_response(
212
+ body=rendered_html,
213
+ status_code=status_code,
214
+ headers=headers,
215
+ charset=charset
216
+ )
217
+
@@ -0,0 +1,214 @@
1
+ import codecs
2
+ import logging
3
+
4
+ from .adapters import LemaRequestAdapter, LemaResponseAdapter
5
+ from .logging_config import LOG_LEVELS
6
+
7
+ def _validate_adapter(adapter, name):
8
+ convert = getattr(adapter, "convert", None)
9
+
10
+ if not callable(convert):
11
+ raise TypeError(f"{name} must have a callable 'convert' method")
12
+
13
+ def _validate_boolean(value, name):
14
+ if not isinstance(value, bool):
15
+ raise TypeError(
16
+ f"{name} must be a boolean."
17
+ )
18
+
19
+
20
+ def _validate_optional_non_negative_integer(
21
+ value,
22
+ name,
23
+ ):
24
+ if value is None:
25
+ return
26
+
27
+ _validate_non_negative_integer(
28
+ value=value,
29
+ name=name,
30
+ )
31
+
32
+
33
+ def _validate_non_negative_integer(
34
+ value,
35
+ name,
36
+ ):
37
+ if (
38
+ not isinstance(value, int)
39
+ or isinstance(value, bool)
40
+ ):
41
+ raise TypeError(
42
+ f"{name} must be an integer."
43
+ )
44
+
45
+ if value < 0:
46
+ raise ValueError(
47
+ f"{name} cannot be negative."
48
+ )
49
+
50
+
51
+ def _validate_static_url_path(value):
52
+ if not isinstance(value, str):
53
+ raise TypeError(
54
+ "static_url_path must be text."
55
+ )
56
+
57
+ if not value:
58
+ raise ValueError(
59
+ "static_url_path cannot be empty."
60
+ )
61
+
62
+ if not value.startswith("/"):
63
+ raise ValueError(
64
+ "static_url_path must start "
65
+ "with '/'."
66
+ )
67
+
68
+
69
+ def _validate_encoding(encoding):
70
+ if not isinstance(encoding, str):
71
+ raise TypeError(
72
+ "template_encoding must be text."
73
+ )
74
+
75
+ try:
76
+ codecs.lookup(encoding)
77
+ except LookupError as error:
78
+ raise ValueError(
79
+ "Unknown template encoding: "
80
+ f"{encoding!r}."
81
+ ) from error
82
+
83
+
84
+ def _validate_logger_name(name):
85
+ if not isinstance(name, str):
86
+ raise TypeError(
87
+ "logger_name must be text."
88
+ )
89
+
90
+ if not name.strip():
91
+ raise ValueError(
92
+ "logger_name cannot be empty."
93
+ )
94
+
95
+
96
+ def _validate_log_level(log_level):
97
+ if not isinstance(log_level, str):
98
+ raise TypeError(
99
+ "log_level must be text."
100
+ )
101
+
102
+ normalized_level = log_level.upper()
103
+
104
+ if normalized_level not in LOG_LEVELS:
105
+ raise ValueError(
106
+ f"Unknown log level: {log_level}"
107
+ )
108
+
109
+ class ApplicationConfig:
110
+ def __init__(
111
+ self,
112
+ request_adapter=None,
113
+ response_adapter=None,
114
+ debug=False,
115
+ static_folder="static",
116
+ static_url_path="/static",
117
+ max_content_length=None,
118
+ template_folder="templates",
119
+ template_encoding="utf-8",
120
+ template_autoescape=True,
121
+ template_cache=True,
122
+ template_auto_reload=False,
123
+ logger_name="basic_web_backend",
124
+ log_file=None,
125
+ log_level="INFO",
126
+ log_max_bytes=1_000_000,
127
+ log_backup_count=5
128
+ ):
129
+ if request_adapter is None:
130
+ request_adapter = LemaRequestAdapter()
131
+
132
+ if response_adapter is None:
133
+ response_adapter = LemaResponseAdapter()
134
+
135
+ _validate_adapter(request_adapter, "request_adapter")
136
+
137
+ _validate_adapter(response_adapter, "response_adapter")
138
+
139
+ _validate_adapter(
140
+ request_adapter,
141
+ "request_adapter",
142
+ )
143
+
144
+ _validate_adapter(
145
+ response_adapter,
146
+ "response_adapter",
147
+ )
148
+
149
+ _validate_boolean(
150
+ debug,
151
+ "debug",
152
+ )
153
+ _validate_boolean(
154
+ template_autoescape,
155
+ "template_autoescape",
156
+ )
157
+ _validate_boolean(
158
+ template_cache,
159
+ "template_cache",
160
+ )
161
+ _validate_boolean(
162
+ template_auto_reload,
163
+ "template_auto_reload",
164
+ )
165
+
166
+ _validate_optional_non_negative_integer(
167
+ value=max_content_length,
168
+ name="max_content_length",
169
+ )
170
+
171
+ _validate_static_url_path(
172
+ static_url_path
173
+ )
174
+
175
+ _validate_encoding(
176
+ template_encoding
177
+ )
178
+
179
+ _validate_logger_name(
180
+ logger_name
181
+ )
182
+
183
+ _validate_log_level(
184
+ log_level
185
+ )
186
+
187
+ _validate_non_negative_integer(
188
+ value=log_max_bytes,
189
+ name="log_max_bytes",
190
+ )
191
+ _validate_non_negative_integer(
192
+ value=log_backup_count,
193
+ name="log_backup_count",
194
+ )
195
+
196
+ self.request_adapter = request_adapter
197
+ self.response_adapter = response_adapter
198
+ self.debug = debug
199
+ self.template_folder = template_folder
200
+ self.template_encoding = template_encoding
201
+ self.template_autoescape = template_autoescape
202
+ self.template_cache = template_cache
203
+ self.template_auto_reload = template_auto_reload
204
+ self.static_folder = static_folder
205
+ self.static_url_path = static_url_path
206
+ self.max_content_length = max_content_length
207
+
208
+ self.logger_name = logger_name
209
+ self.log_file = log_file
210
+ self.log_level = log_level
211
+ self.log_max_bytes = log_max_bytes
212
+ self.log_backup_count = log_backup_count
213
+
214
+