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.
- basic_web_backend/__init__.py +86 -0
- basic_web_backend/adapters.py +39 -0
- basic_web_backend/application.py +217 -0
- basic_web_backend/config.py +214 -0
- basic_web_backend/exceptions.py +176 -0
- basic_web_backend/logging_config.py +61 -0
- basic_web_backend/multipart.py +109 -0
- basic_web_backend/request.py +180 -0
- basic_web_backend/response.py +197 -0
- basic_web_backend/routing.py +343 -0
- basic_web_backend/static.py +29 -0
- basic_web_backend/template/__init__.py +3 -0
- basic_web_backend/template/environment.py +110 -0
- basic_web_backend/template/evaluator.py +114 -0
- basic_web_backend/template/lexer.py +127 -0
- basic_web_backend/template/nodes.py +34 -0
- basic_web_backend/template/parser.py +221 -0
- lema_basic_web_backend-0.1.0.dist-info/METADATA +546 -0
- lema_basic_web_backend-0.1.0.dist-info/RECORD +22 -0
- lema_basic_web_backend-0.1.0.dist-info/WHEEL +5 -0
- lema_basic_web_backend-0.1.0.dist-info/licenses/LICENSE +21 -0
- lema_basic_web_backend-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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,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
|
+
|