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,197 @@
1
+ import json
2
+ import mimetypes
3
+ from pathlib import Path
4
+ from http.cookies import SimpleCookie
5
+
6
+ MIME_TYPES = {
7
+ #Web
8
+ ".html": "text/html",
9
+ ".htm": "text/html",
10
+ ".css": "text/css",
11
+ ".js": "application/javascript",
12
+ ".json": "application/json",
13
+ ".txt": "text/plain",
14
+ ".xml": "application/xml",
15
+ ".csv": "text/csv",
16
+
17
+ #Images
18
+ ".png": "image/png",
19
+ ".jpg": "image/jpeg",
20
+ ".jpeg": "image/jpeg",
21
+ ".webp": "image/webp",
22
+ ".avif": "image/avif",
23
+ ".svg": "image/svg+xml",
24
+ ".gif": "image/gif",
25
+ ".ico": "image/x-icon",
26
+
27
+ #Fonts
28
+ ".woff": "font/woff",
29
+ ".woff2": "font/woff2",
30
+ ".ttf": "font/ttf",
31
+ ".otf": "font/otf",
32
+
33
+ #Documents and archives
34
+ ".pdf": "application/pdf",
35
+ ".zip": "application/zip",
36
+ ".tar": "application/x-tar",
37
+ ".gz": "application/gzip",
38
+ ".rar": "application/vnd.rar",
39
+ ".zip": "application/zip",
40
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
41
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
42
+ ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
43
+
44
+ #Audio and video
45
+ ".mp3": "audio/mpeg",
46
+ ".wav": "audio/wav",
47
+ ".mp4": "video/mp4",
48
+ ".avi": "video/x-msvideo",
49
+ ".mov": "video/quicktime",
50
+ ".mkv": "video/x-matroska",
51
+ ".flac": "audio/flac",
52
+ ".ogg": "audio/ogg",
53
+ ".webm": "video/webm",
54
+ ".m4a": "audio/mp4",
55
+ ".aac": "audio/aac"
56
+ }
57
+
58
+ REDIRECT_STATUS_CODES = {
59
+ 301: "Moved Permanently",
60
+ 302: "Found",
61
+ 303: "See Other",
62
+ 307: "Temporary Redirect",
63
+ 308: "Permanent Redirect"
64
+ }
65
+
66
+ def _prepare_headers(default_content_type=None, headers=None):
67
+ if headers is None:
68
+ response_headers = {}
69
+ else:
70
+ response_headers = dict(headers)
71
+
72
+ has_content_type = any(
73
+ name.lower() == "content-type" for name in response_headers)
74
+
75
+ if default_content_type and not has_content_type:
76
+ response_headers["Content-Type"] = default_content_type
77
+
78
+ return response_headers
79
+
80
+ def guess_content_type(filename):
81
+ path = Path(filename)
82
+ extension = path.suffix.lower()
83
+
84
+ if extension in MIME_TYPES:
85
+ return MIME_TYPES[extension]
86
+
87
+ guessed_type, encoding = mimetypes.guess_type(path.name)
88
+ if guessed_type is not None:
89
+ return guessed_type
90
+
91
+ return "application/octet-stream"
92
+
93
+ def content_response(body, content_type, status_code=200, headers=None):
94
+ response_headers = _prepare_headers(content_type, headers)
95
+ return body, status_code, response_headers
96
+
97
+ def html_response(body, status_code=200, headers=None, charset="utf-8"):
98
+ encoded_body = body.encode(charset)
99
+ return content_response(body=encoded_body, content_type=f"text/html; charset={charset}",
100
+ status_code=status_code, headers=headers)
101
+
102
+ def text_response(body, status_code=200, headers=None, charset="utf-8"):
103
+ encoded_body = body.encode(charset)
104
+ return content_response(body=encoded_body, content_type=f"text/plain; charset={charset}",
105
+ status_code=status_code, headers=headers)
106
+
107
+ def json_response(data, status_code=200, headers=None, charset="utf-8"):
108
+ body = json.dumps(data, ensure_ascii=False)
109
+ encoded_body = body.encode(charset)
110
+ return content_response(body=encoded_body, content_type=f"application/json; charset={charset}",
111
+ status_code=status_code, headers=headers)
112
+
113
+ def file_response(file_path, status_code=200, headers=None, as_attachment=False, download_name=None):
114
+ path = Path(file_path)
115
+ body = path.read_bytes()
116
+ content_type = guess_content_type(path)
117
+
118
+ response_headers = _prepare_headers(content_type, headers)
119
+
120
+ if as_attachment:
121
+ filename = download_name or path.name
122
+ safe_filename = Path(filename).name.replace('"', '_').replace("\r", '_').replace("\n", '_')
123
+
124
+ response_headers["Content-Disposition"] = f"attachment; filename=\"{safe_filename}\""
125
+
126
+ return body, status_code, response_headers
127
+
128
+ def redirect_response(location, status_code=302, headers=None):
129
+ if status_code not in REDIRECT_STATUS_CODES:
130
+ raise ValueError(f"Invalid redirect status code: {status_code}")
131
+
132
+ response_headers = _prepare_headers(headers=headers)
133
+ response_headers["Location"] = location
134
+ return "", status_code, response_headers
135
+
136
+ def empty_response(status_code=204, headers=None):
137
+ response_headers = _prepare_headers(headers=headers)
138
+ return b"", status_code, response_headers
139
+
140
+ def set_cookie(
141
+ response,
142
+ key,
143
+ value,
144
+ max_age=None,
145
+ expires=None,
146
+ path="/",
147
+ domain=None,
148
+ secure=False,
149
+ http_only=False,
150
+ same_site=None
151
+ ):
152
+
153
+ body, status_code, headers = response
154
+
155
+ if headers is None:
156
+ response_headers = []
157
+ elif isinstance(headers, dict):
158
+ response_headers = list(headers.items())
159
+ else:
160
+ response_headers = list(headers)
161
+
162
+ cookie = SimpleCookie()
163
+ cookie[key] = value
164
+
165
+ morsel = cookie[key]
166
+ if max_age is not None:
167
+ morsel["max-age"] = str(max_age)
168
+ if expires is not None:
169
+ morsel["expires"] = expires
170
+ if path is not None:
171
+ morsel["path"] = path
172
+ if domain is not None:
173
+ morsel["domain"] = domain
174
+ if secure:
175
+ morsel["secure"] = True
176
+ if http_only:
177
+ morsel["httpOnly"] = True
178
+ if same_site is not None:
179
+ morsel["samesite"] = same_site
180
+
181
+ response_headers.append(("Set-Cookie", morsel.OutputString()))
182
+
183
+ return body, status_code, response_headers
184
+
185
+ def delete_cookie(response, key, path="/", domain=None):
186
+ return set_cookie(
187
+ response=response,
188
+ key=key,
189
+ value="",
190
+ max_age=0,
191
+ expires="Thu, 01 Jan 1970 00:00:00 GMT",
192
+ path=path,
193
+ domain=domain
194
+ )
195
+
196
+
197
+
@@ -0,0 +1,343 @@
1
+ import re
2
+ from dataclasses import dataclass
3
+ from collections.abc import Callable
4
+
5
+ from .exceptions import (
6
+ AmbiguousRouteError,
7
+ DuplicateRouteError,
8
+ InvalidRouteError,
9
+ MethodNotAllowed,
10
+ NotFound
11
+ )
12
+
13
+ PARAMETER_PATTERN = re.compile(r"<(?P<converter>string|int|float|path):"
14
+ r"(?P<name>[a-zA-Z_]\w*)>")
15
+
16
+ CONVERTERS = {
17
+ "string": (r"[^/]+", str),
18
+ "int": (r"\d+", int),
19
+ "float": (r"(?:\d+(?:\.\d*)?)|\.\d+", float),
20
+ "path": (r".+", str),
21
+ }
22
+
23
+ CONVERTER_OVERLAPS = {
24
+ "string": {"string", "int", "float", "path"},
25
+ "int": {"string", "int", "float", "path"},
26
+ "float": {"string", "int", "float", "path"},
27
+ "path": {"string", "int", "float", "path"},
28
+ }
29
+
30
+ @dataclass
31
+ class RouteMatch:
32
+ view: Callable
33
+ parameters: dict
34
+
35
+ @dataclass
36
+ class DynamicRoute:
37
+ path_pattern: str
38
+ regex: re.Pattern
39
+ converters: dict
40
+ segments: list
41
+ methods: dict
42
+
43
+ class Router:
44
+ def __init__(self):
45
+ self.static_routes = {}
46
+ self.dynamic_routes = []
47
+
48
+ def add_route(self, path, view, methods=None):
49
+ self._validate_path(path=path)
50
+ self._validate_view(path=path, view=view)
51
+
52
+ methods = self._prepare_methods(path=path, methods=methods)
53
+
54
+ if "<" in path or ">" in path:
55
+ self._add_dynamic_route(path=path, methods=methods, view=view)
56
+ else:
57
+ self._add_static_route(path=path, methods=methods, view=view)
58
+
59
+ def _prepare_methods(self, path, methods):
60
+ if methods is None:
61
+ return ["GET"]
62
+
63
+ if isinstance(methods, str):
64
+ raise InvalidRouteError(
65
+ path=path,
66
+ message="Route methods must be provided as an iterable of methods names, not as a single string."
67
+ )
68
+
69
+ try:
70
+ method_items = list(methods)
71
+ except TypeError as error:
72
+ raise InvalidRouteError(
73
+ path=path,
74
+ message="Route methods must be iterable"
75
+ ) from error
76
+
77
+ if not method_items:
78
+ raise InvalidRouteError(
79
+ path=path,
80
+ message="At least one HTTP method must be provided."
81
+ )
82
+
83
+ normalized_methods = set()
84
+
85
+ for method in method_items:
86
+ if not isinstance(method, str):
87
+ raise InvalidRouteError(
88
+ path=path,
89
+ message="Every HTTP method must be text"
90
+ )
91
+
92
+ normalized_method = method.strip().upper()
93
+
94
+ if not normalized_method:
95
+ raise InvalidRouteError(
96
+ path=path,
97
+ message="HTTP method names cannot be empty or whitespace."
98
+ )
99
+
100
+ normalized_methods.add(normalized_method)
101
+
102
+ return normalized_methods
103
+
104
+ def _validate_path(self, path):
105
+ if not isinstance(path, str):
106
+ raise InvalidRouteError(
107
+ path=path,
108
+ message="The route path must be text."
109
+ )
110
+
111
+ if not path.startswith("/"):
112
+ raise InvalidRouteError(
113
+ path=path,
114
+ message="The route must start with '/'."
115
+ )
116
+
117
+ def _validate_view(self, path, view):
118
+ if not callable(view):
119
+ raise InvalidRouteError(
120
+ path=path,
121
+ message="The route view must be callable."
122
+ )
123
+
124
+ def _add_static_route(self, path, view, methods):
125
+ registered_methods = self.static_routes.setdefault(path, {})
126
+
127
+ for method in methods:
128
+ if method in registered_methods:
129
+ raise DuplicateRouteError(path=path, method=method)
130
+
131
+ for method in methods:
132
+ registered_methods[method] = view
133
+
134
+ def _add_dynamic_route(self, path, view, methods):
135
+ existing_same_route = self._find_dynamic_route(path)
136
+
137
+ if existing_same_route is not None:
138
+ duplicate_methods = methods & existing_same_route.methods.keys()
139
+ if duplicate_methods:
140
+ duplicate_method = sorted(duplicate_methods)[0]
141
+
142
+ raise DuplicateRouteError(path=path, method=duplicate_method)
143
+
144
+ for method in methods:
145
+ existing_same_route.methods[method] = view
146
+
147
+ return
148
+ new_route = self._compile_dynamic_route(path)
149
+
150
+ self._check_dynamic_ambiguity(new_route=new_route, methods=methods)
151
+
152
+ new_route.methods = {method: view for method in methods}
153
+
154
+ self.dynamic_routes.append(new_route)
155
+
156
+ def _find_dynamic_route(self, path):
157
+ for dynamic_route in self.dynamic_routes:
158
+ if dynamic_route.path_pattern == path:
159
+ return dynamic_route
160
+
161
+ return None
162
+
163
+ def _compile_dynamic_route(self, path):
164
+ if not path.startswith("/"):
165
+ raise InvalidRouteError(path=path, message="The route must start with '/'.")
166
+
167
+ raw_segments = path[1:].split("/")
168
+ segments = []
169
+ regex_parts = []
170
+ converters = {}
171
+
172
+ for index, raw_segment in enumerate(raw_segments):
173
+ paramater_match = PARAMETER_PATTERN.fullmatch(raw_segment)
174
+ if paramater_match is None:
175
+ if "<" in raw_segment or ">" in raw_segment:
176
+ raise InvalidRouteError(
177
+ path=path,
178
+ message="A parameter must be in the format <converter:name>."
179
+ )
180
+ segments.append(("static", raw_segment))
181
+ regex_parts.append(re.escape(raw_segment))
182
+ continue
183
+
184
+ converter_name = paramater_match.group("converter")
185
+ parameter_name = paramater_match.group("name")
186
+
187
+ if parameter_name in converters:
188
+ raise InvalidRouteError(
189
+ path=path,
190
+ message=f"Duplicate parameter name: {parameter_name!r}."
191
+
192
+ )
193
+
194
+ if converter_name == "path" and index != len(raw_segments) - 1:
195
+ raise InvalidRouteError(
196
+ path=path,
197
+ message="The 'path' converter must be the final route segment."
198
+ )
199
+
200
+ converter_pattern, converter = CONVERTERS[converter_name]
201
+ segments.append(("dynamic", converter_name))
202
+ regex_parts.append(f"(?P<{parameter_name}>{converter_pattern})")
203
+ converters[parameter_name] = converter
204
+
205
+ regex_source = "^/" + "/".join(regex_parts) + "$"
206
+
207
+ return DynamicRoute(
208
+ path_pattern=path,
209
+ regex=re.compile(regex_source),
210
+ converters=converters,
211
+ segments=segments,
212
+ methods={}
213
+ )
214
+
215
+ def _check_dynamic_ambiguity(self, new_route, methods):
216
+ for existing_route in self.dynamic_routes:
217
+ common_methods = methods & existing_route.methods.keys()
218
+
219
+ if not common_methods:
220
+ continue
221
+
222
+ if self._routes_overlap(new_route.segments, existing_route.segments):
223
+ raise AmbiguousRouteError(
224
+ path=new_route.path_pattern,
225
+ conflicting_path=existing_route.path_pattern,
226
+ methods=common_methods
227
+ )
228
+
229
+ def _routes_overlap(self, first_segments, second_segments):
230
+ if len(first_segments) == len(second_segments):
231
+ return self._prefixes_overlap(first_segments, second_segments)
232
+
233
+ if len(first_segments) < len(second_segments):
234
+ shorter = first_segments
235
+ longer = second_segments
236
+ else:
237
+ shorter = second_segments
238
+ longer = first_segments
239
+
240
+ if not self._ends_with_path(shorter):
241
+ return False
242
+
243
+ shorter_prefix = shorter[:-1]
244
+ longer_prefix = longer[:len(shorter_prefix)]
245
+ return self._prefixes_overlap(shorter_prefix, longer_prefix)
246
+
247
+ def _ends_with_path(self, segments):
248
+ if not segments:
249
+ return False
250
+
251
+ return segments[-1] == ("dynamic", "path")
252
+
253
+ def _prefixes_overlap(self, first_segments, second_segments):
254
+ for first_segment, second_segment in zip(first_segments, second_segments):
255
+ if not self._segments_overlap(first_segment, second_segment):
256
+ return False
257
+
258
+ return True
259
+
260
+ def _segments_overlap(self, first_segment, second_segment):
261
+ first_kind, first_value = first_segment
262
+ second_kind, second_value = second_segment
263
+
264
+ if first_kind == "static" and second_kind == "static":
265
+ return first_value == second_value
266
+
267
+ if first_kind == "static" and second_kind == "dynamic":
268
+ return self._static_matches_converter(first_value, second_value)
269
+
270
+ if first_kind == "dynamic" and second_kind == "static":
271
+ return self._static_matches_converter(second_value, first_value)
272
+
273
+ return (
274
+ second_value
275
+ in CONVERTER_OVERLAPS[first_value]
276
+ )
277
+
278
+ def _static_matches_converter(self, value, converter_name):
279
+ converter_pattern, _ = CONVERTERS[converter_name]
280
+
281
+ return (re.fullmatch(converter_pattern, value) is not None)
282
+
283
+ def match_route(self, path, method):
284
+ normalized_method = method.upper()
285
+
286
+ registered_methods = self.static_routes.get(path)
287
+
288
+ if registered_methods is not None:
289
+ view = registered_methods.get(normalized_method)
290
+
291
+ if view is None:
292
+ raise MethodNotAllowed(
293
+ method=normalized_method,
294
+ path=path,
295
+ allowed_methods=registered_methods.keys()
296
+ )
297
+
298
+ return RouteMatch(view=view, parameters={})
299
+
300
+ return self._match_dynamic_route(path=path, method=normalized_method)
301
+
302
+ def _match_dynamic_route(self, path, method):
303
+ allowed_methods = set()
304
+
305
+ for dynamic_route in self.dynamic_routes:
306
+ regex_match = dynamic_route.regex.fullmatch(path)
307
+
308
+ if regex_match is None:
309
+ continue
310
+
311
+ allowed_methods.update(dynamic_route.methods.keys())
312
+
313
+ view = dynamic_route.methods.get(method)
314
+
315
+ if view is None:
316
+ continue
317
+
318
+ parameters = self._convert_parameters(
319
+ regex_match=regex_match,
320
+ converters=dynamic_route.converters
321
+ )
322
+
323
+ return RouteMatch(view=view, parameters=parameters)
324
+
325
+ if allowed_methods:
326
+ raise MethodNotAllowed(
327
+ method=method,
328
+ path=path,
329
+ allowed_methods=allowed_methods
330
+ )
331
+
332
+ raise NotFound(path=path)
333
+
334
+ def _convert_parameters(self, regex_match, converters):
335
+ parameters = {}
336
+
337
+ for name, value in regex_match.groupdict().items():
338
+ converter = converters[name]
339
+ parameters[name] = converter(value)
340
+
341
+ return parameters
342
+
343
+
@@ -0,0 +1,29 @@
1
+ from pathlib import Path
2
+
3
+ from .exceptions import NotFound
4
+ from .response import file_response
5
+
6
+ class StaticFileHandler:
7
+ def __init__(self, static_folder):
8
+ self.static_folder = Path(static_folder).resolve()
9
+
10
+ def serve(self, filename, status_code=200, headers=None, as_attachment=False, download_name=None):
11
+ try:
12
+ file_path = (self.static_folder / filename).resolve()
13
+
14
+ except (OSError, RuntimeError):
15
+ raise NotFound(path=filename)
16
+
17
+ if not file_path.is_relative_to(self.static_folder):
18
+ raise NotFound(path=filename)
19
+
20
+ if not file_path.is_file():
21
+ raise NotFound(path=filename)
22
+
23
+ try:
24
+ return file_response(file_path=file_path, status_code=status_code, headers=headers, as_attachment=as_attachment, download_name=download_name)
25
+ except (FileNotFoundError, IsADirectoryError) as error:
26
+ raise NotFound(path=filename) from error
27
+
28
+
29
+
@@ -0,0 +1,3 @@
1
+ from .environment import TemplateEnvironment
2
+
3
+ __all__ = ["TemplateEnvironment"]
@@ -0,0 +1,110 @@
1
+ from dataclasses import dataclass
2
+ from pathlib import Path
3
+
4
+ from ..exceptions import TemplateNotFound, TemplateLoadError
5
+ from .evaluator import render as render_nodes
6
+ from .lexer import tokenize
7
+ from .parser import parse
8
+
9
+ @dataclass
10
+ class CachedTemplate:
11
+ nodes: list
12
+ modified_time_ns: int
13
+ file_size: int
14
+
15
+ class TemplateEnvironment:
16
+ def __init__(
17
+ self,
18
+ template_folder,
19
+ encoding="utf-8",
20
+ autoescape=True,
21
+ cache_enabled=True,
22
+ auto_reload=False
23
+ ):
24
+ self.template_folder = Path(template_folder).resolve()
25
+ self.encoding = encoding
26
+ self.autoescape = autoescape
27
+ self.cache_enabled = cache_enabled
28
+ self.auto_reload = auto_reload
29
+
30
+ self._template_cache = {}
31
+
32
+ def render(self, template_name, **context):
33
+ template_name = str(template_name)
34
+ template_path = self._resolve_template_path(template_name)
35
+
36
+ nodes = self._get_template_nodes(template_name=template_name, template_path=template_path)
37
+
38
+ return render_nodes(
39
+ nodes=nodes,
40
+ context=context,
41
+ autoescape=self.autoescape,
42
+ template_name=template_name
43
+ )
44
+
45
+ def clear_cache(self, template_name=None):
46
+ if template_name is None:
47
+ self._template_cache.clear()
48
+ return
49
+
50
+ template_path = self._build_template_path(template_name)
51
+
52
+ self._template_cache.pop(template_path, None)
53
+
54
+ def _resolve_template_path(self, template_name):
55
+ template_path = self._build_template_path(template_name)
56
+
57
+ if not template_path.is_relative_to(self.template_folder):
58
+ raise TemplateNotFound(template_name)
59
+
60
+ if not template_path.is_file():
61
+ raise TemplateNotFound(template_name)
62
+
63
+ return template_path
64
+
65
+ def _build_template_path(self, template_name):
66
+ template_path = self.template_folder / template_name
67
+ return template_path.resolve()
68
+
69
+ def _get_template_nodes(self, template_name, template_path):
70
+ if not self.cache_enabled:
71
+ return self._load_template_nodes(template_name, template_path)
72
+
73
+ cached_template = self._template_cache.get(template_path)
74
+ if cached_template is not None:
75
+ if not self.auto_reload:
76
+ return cached_template.nodes
77
+
78
+ if not self._template_changed(template_name, template_path=template_path, cached_template=cached_template):
79
+ return cached_template.nodes
80
+
81
+ nodes = self._load_template_nodes(template_name=template_name, template_path=template_path)
82
+
83
+ file_status = self._get_file_status(template_name=template_name, template_path=template_path)
84
+ self._template_cache[template_path] = CachedTemplate(nodes=nodes, modified_time_ns=file_status.st_mtime_ns, file_size=file_status.st_size)
85
+
86
+ return nodes
87
+
88
+ def _load_template_nodes(self, template_name, template_path):
89
+ try:
90
+ source = template_path.read_text(encoding=self.encoding)
91
+ except (OSError, UnicodeError) as error:
92
+ raise TemplateLoadError(template_name=template_name, message=str(error)) from error
93
+
94
+ tokens = tokenize(source, template_name=template_name)
95
+ nodes = parse(tokens, template_name=template_name)
96
+ return nodes
97
+
98
+ def _get_file_status(self, template_name, template_path):
99
+ try:
100
+ return template_path.stat()
101
+ except OSError as error:
102
+ raise TemplateLoadError(template_name=template_name, message=str(error)) from error
103
+
104
+ def _template_changed(self, template_name, template_path, cached_template):
105
+ file_status = self._get_file_status(template_name=template_name, template_path=template_path)
106
+ return (
107
+ file_status.st_mtime_ns != cached_template.modified_time_ns or
108
+ file_status.st_size != cached_template.file_size
109
+ )
110
+