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,176 @@
1
+ class BackendError(Exception):
2
+ """Base class for all backend-specific exceptions."""
3
+ pass
4
+
5
+ class RoutingError(BackendError):
6
+ """Base class for route registration errors."""
7
+ pass
8
+
9
+ class DuplicateRouteError(RoutingError):
10
+ """Raised when a route is registered more than once."""
11
+ def __init__(self, path, method):
12
+ self.path = path
13
+ self.method = method
14
+ super().__init__(f"Route alredy registered for path: {path} and method: {method}")
15
+
16
+ class InvalidRouteError(RoutingError):
17
+ """Raised when a route is registered with an invalid path or method."""
18
+ def __init__(self, path, message):
19
+ self.path = path
20
+ self.message = message
21
+ super().__init__(f"Invalid route {path!r}: {message}")
22
+
23
+ class AmbiguousRouteError(RoutingError):
24
+ """Raised when a route is registered that conflicts with an existing route."""
25
+ def __init__(self, path, conflicting_path, methods):
26
+ self.path = path
27
+ self.conflicting_path = conflicting_path
28
+ self.methods = methods
29
+
30
+ method_list = ", ".join(sorted(self.methods))
31
+ super().__init__(
32
+ f"Ambigous routes {path!r} and "
33
+ f"{conflicting_path!r} for methods: {method_list}"
34
+ )
35
+
36
+ class HTTPException(Exception):
37
+ """Base class for all HTTP exceptions."""
38
+ status_code = 500
39
+ default_message = "Internal Server Error"
40
+
41
+ def __init__(self, message=None, headers=None):
42
+ self.message = (
43
+ message
44
+ if message is not None
45
+ else self.default_message
46
+ )
47
+ self.headers = dict(headers or {})
48
+
49
+ super().__init__(self.message)
50
+
51
+
52
+ class BadRequest(HTTPException):
53
+ status_code = 400
54
+ default_message = "Bad Request"
55
+
56
+
57
+ class Unauthorized(HTTPException):
58
+ status_code = 401
59
+ default_message = "Unauthorized"
60
+
61
+
62
+ class Forbidden(HTTPException):
63
+ status_code = 403
64
+ default_message = "Forbidden"
65
+
66
+
67
+ class NotFound(HTTPException):
68
+ status_code = 404
69
+ default_message = "Not Found"
70
+
71
+ def __init__(self, path, message=None):
72
+ self.path = path
73
+ super().__init__(message=message)
74
+
75
+
76
+ class MethodNotAllowed(HTTPException):
77
+ status_code = 405
78
+ default_message = "Method Not Allowed"
79
+
80
+ def __init__(
81
+ self,
82
+ method,
83
+ path,
84
+ allowed_methods,
85
+ message=None,
86
+ ):
87
+ self.method = method.upper()
88
+ self.path = path
89
+ self.allowed_methods = tuple(sorted(method.upper() for method in allowed_methods))
90
+
91
+ allow_header = ", ".join(
92
+ sorted(self.allowed_methods)
93
+ )
94
+
95
+ super().__init__(
96
+ message=message,
97
+ headers={
98
+ "Allow": allow_header,
99
+ },
100
+ )
101
+
102
+
103
+ class Conflict(HTTPException):
104
+ status_code = 409
105
+ default_message = "Conflict"
106
+
107
+
108
+ class PayloadTooLarge(HTTPException):
109
+ status_code = 413
110
+ default_message = "Payload Too Large"
111
+
112
+
113
+ class UnsupportedMediaType(HTTPException):
114
+ status_code = 415
115
+ default_message = "Unsupported Media Type"
116
+
117
+
118
+ class UnprocessableContent(HTTPException):
119
+ status_code = 422
120
+ default_message = "Unprocessable Content"
121
+
122
+ class TemplateError(Exception):
123
+ """Base class for template-releated errors:"""
124
+
125
+ class TemplateNotFound(TemplateError):
126
+ def __init__(self, template_name):
127
+ self.template_name = template_name
128
+
129
+ super().__init__(f"Template not found: {template_name}")
130
+
131
+ class TemplateLoadError(TemplateError):
132
+ """Raised when a template file cannot be loaded."""
133
+
134
+ def __init__(self, template_name, message):
135
+ self.template_name = template_name
136
+ self.message = message
137
+
138
+ super().__init__(f"Could not load template {template_name!r}: {message}")
139
+
140
+ class TemplateSyntaxError(TemplateError):
141
+ def __init__(self, message, template_name=None, line=None, column=None):
142
+ self.message = message
143
+ self.template_name = template_name
144
+ self.line = line
145
+ self.column = column
146
+
147
+ formatted_message = self._format_message()
148
+ super().__init__(formatted_message)
149
+
150
+ def _format_message(self):
151
+ if (self.template_name is not None and self.line is not None and self.column is not None):
152
+ return (
153
+ f"{self.template_name}:{self.line}:{self.column}: {self.message}"
154
+ )
155
+
156
+ if self.template_name is not None and self.line is not None:
157
+ return f"{self.template_name}:{self.line}: {self.message}"
158
+
159
+ if self.template_name is not None:
160
+ return f"{self.template_name}: {self.message}"
161
+
162
+ if self.line is not None and self.column is not None:
163
+ return f"Line {self.line}, Column {self.column}: {self.message}"
164
+
165
+ if self.line is not None:
166
+ return f"Line {self.line}: {self.message}"
167
+
168
+ return self.message
169
+
170
+ class TemplateRenderError(TemplateError):
171
+ """Raised when a template cannot be rendered."""
172
+
173
+ class UndefinedVariableError(TemplateRenderError):
174
+ def __init__(self, variable_name):
175
+ self.variable_name = variable_name
176
+ super().__init__(f"Undefined variable: {variable_name}")
@@ -0,0 +1,61 @@
1
+ import logging
2
+ from logging.handlers import RotatingFileHandler
3
+ from pathlib import Path
4
+
5
+ LOG_LEVELS = {
6
+ "CRITICAL": logging.CRITICAL,
7
+ "ERROR": logging.ERROR,
8
+ "WARNING": logging.WARNING,
9
+ "INFO": logging.INFO,
10
+ "DEBUG": logging.DEBUG,
11
+ "NOTSET": logging.NOTSET,
12
+ }
13
+
14
+ DEFAULT_LOG_FORMAT = (
15
+ "%(asctime)s "
16
+ "%(levelname)s "
17
+ "%(name)s "
18
+ "%(message)s"
19
+ )
20
+
21
+ def create_logger(
22
+ name="basic_web_backend",
23
+ log_file=None,
24
+ log_level="INFO",
25
+ log_max_bytes=1_000_000,
26
+ log_backup_count=5
27
+ ):
28
+ logger = logging.getLogger(name)
29
+
30
+ level = LOG_LEVELS.get(str(log_level).upper())
31
+
32
+ if level is None:
33
+ raise ValueError(f"Invalid log level: {log_level!r}")
34
+
35
+ logger.setLevel(level)
36
+ logger.propagate = False
37
+
38
+ _remove_existing_handlers(logger)
39
+
40
+ if log_file is not None:
41
+ log_path = Path(log_file)
42
+ log_path.parent.mkdir(parents=True, exist_ok=True)
43
+
44
+ handler = RotatingFileHandler(
45
+ filename=log_path,
46
+ maxBytes=log_max_bytes,
47
+ backupCount=log_backup_count,
48
+ encoding="utf-8"
49
+ )
50
+
51
+ handler.setLevel(level)
52
+ handler.setFormatter(logging.Formatter(DEFAULT_LOG_FORMAT))
53
+ logger.addHandler(handler)
54
+
55
+ return logger
56
+
57
+ def _remove_existing_handlers(logger):
58
+ for handler in logger.handlers[:]:
59
+ logger.removeHandler(handler)
60
+ handler.close()
61
+
@@ -0,0 +1,109 @@
1
+ from dataclasses import dataclass
2
+ from email.parser import BytesParser
3
+ from email.policy import default
4
+
5
+ from .exceptions import BadRequest
6
+
7
+ @dataclass
8
+ class UploadedFile:
9
+ filename: str
10
+ content_type: str | None
11
+ body: bytes
12
+ headers: dict
13
+
14
+ def parse_multipart(body, boundary, charset="utf-8"):
15
+ _validate_inputs(body=body, boundary=boundary)
16
+
17
+ message = _parse_multipart_message(body=body, boundary=boundary)
18
+
19
+ if not message.is_multipart():
20
+ raise BadRequest("The request body is not valid multipart data.")
21
+
22
+ if message.defects:
23
+ raise BadRequest("The request body contains malformed multipart data.")
24
+
25
+ form = {}
26
+ files = {}
27
+
28
+ for part in message.iter_parts():
29
+ _process_part(part=part, form=form, files=files, default_charset=charset)
30
+
31
+ return form, files
32
+
33
+ def _validate_inputs(body, boundary):
34
+ if not isinstance(body, bytes):
35
+ raise BadRequest("The multipart body must be bytes.")
36
+ if not isinstance(boundary, str):
37
+ raise BadRequest("The multipart boundary must be text.")
38
+ if not boundary:
39
+ raise BadRequest("The multipart boundary is missing or empty.")
40
+ if "\n" in boundary or "\r" in boundary:
41
+ raise BadRequest("The multipart boundary is invalid.")
42
+
43
+ def _parse_multipart_message(body, boundary):
44
+ try:
45
+ boundary_bytes = boundary.encode("ascii")
46
+ except UnicodeEncodeError:
47
+ raise BadRequest("The multipart boundary must contain only ASCII characters.")
48
+
49
+ excaped_boundary = boundary_bytes.replace(b'"', b'\\"').replace(b'\\', b'\\\\')
50
+
51
+ synthetic_headers = (
52
+ b"Content-Type: multipart/form-data; "
53
+ b'boundary="'
54
+ + excaped_boundary
55
+ + b'"\r\n'
56
+ + b"MIME-Version: 1.0\r\n"
57
+ + b"\r\n"
58
+ )
59
+
60
+ return BytesParser(policy=default).parsebytes(synthetic_headers + body)
61
+
62
+ def _process_part(part, form, files, default_charset):
63
+ if part.get_content_disposition() != "form-data":
64
+ raise BadRequest("Each multipart part must use form-data content disposition.")
65
+
66
+ field_name = part.get_param("name", header="content-disposition")
67
+
68
+ if not field_name:
69
+ raise BadRequest("Each multipart part must have a field name.")
70
+
71
+ payload = part.get_payload(decode=True)
72
+
73
+ if payload is None:
74
+ payload = b""
75
+
76
+ filename = part.get_filename()
77
+
78
+ if filename is not None:
79
+ uploaded_file = UploadedFile(
80
+ filename=filename,
81
+ content_type=_get_part_content_type(part=part),
82
+ body=payload,
83
+ headers=dict(part.items()),
84
+ )
85
+
86
+ files.setdefault(field_name, []).append(uploaded_file)
87
+
88
+ return
89
+
90
+ text = _decode_form_field(part=part, payload=payload, default_charset=default_charset)
91
+
92
+ form.setdefault(field_name, []).append(text)
93
+
94
+ def _get_part_content_type(part):
95
+ if part.get("Content-Type") is None:
96
+ return None
97
+
98
+ return part.get_content_type()
99
+
100
+ def _decode_form_field(part, payload, default_charset):
101
+ charset = part.get_content_charset() or default_charset
102
+
103
+ try:
104
+ return payload.decode(charset)
105
+ except LookupError as error:
106
+ raise BadRequest(f"Unsupported multipart character encoding: {charset}") from error
107
+ except UnicodeDecodeError as error:
108
+ raise BadRequest(f"A multipart form field cannot be decoded using {charset}.") from error
109
+
@@ -0,0 +1,180 @@
1
+ import json
2
+ from urllib.parse import parse_qs
3
+ from http.cookies import CookieError, SimpleCookie
4
+
5
+ from .exceptions import BadRequest, UnsupportedMediaType
6
+ from .multipart import parse_multipart
7
+
8
+ class Request:
9
+ def __init__(self, method, path, query_string="", headers=None, body=b""):
10
+ if not isinstance(body, (str, bytes)):
11
+ raise BadRequest("The request body must be bytes or text")
12
+
13
+ self.method = method.upper()
14
+ self.path = path
15
+ self.query_string = query_string
16
+ self.body = body
17
+
18
+ if headers is None:
19
+ header_items = []
20
+ elif isinstance(headers, dict):
21
+ header_items = headers.items()
22
+ else:
23
+ header_items = headers
24
+
25
+ self.headers = [(k.lower(), v) for k, v in header_items]
26
+ self.query = parse_qs(query_string, keep_blank_values=True)
27
+
28
+ def get_header(self, name, default=None):
29
+ normalized_name = name.lower()
30
+
31
+ for header_name, header_value in self.headers:
32
+ if header_name == normalized_name:
33
+ return header_value
34
+
35
+ return default
36
+
37
+ def get_headers(self, name):
38
+ normalized_name = name.lower()
39
+ return [value for header_name, value in self.headers if header_name == normalized_name]
40
+
41
+ @property
42
+ def content_type(self):
43
+ conetnt_type, parameters = self._parse_content_type()
44
+ return conetnt_type
45
+
46
+ @property
47
+ def charset(self):
48
+ content_type, parameters = self._parse_content_type()
49
+
50
+ charset = parameters.get("charset")
51
+ if charset is None:
52
+ return None
53
+
54
+ return charset.lower()
55
+
56
+ def _parse_content_type(self):
57
+ header_value = self.get_header("content-type")
58
+
59
+ if header_value is None:
60
+ return None, {}
61
+
62
+ parts = header_value.split(";")
63
+ content_type = parts[0].strip().lower()
64
+
65
+ if not content_type:
66
+ content_type = None
67
+
68
+ parameters = {}
69
+
70
+ for part in parts[1:]:
71
+ name, sep, value = part.partition("=")
72
+ if not sep:
73
+ continue
74
+
75
+ name = name.strip().lower()
76
+ value = value.strip()
77
+
78
+ if (
79
+ len(value) >= 2 and value[0] == '"' and value[-1] == '"'
80
+ ):
81
+ value = value[1:-1]
82
+
83
+ if name:
84
+ parameters[name] = value
85
+
86
+ return content_type, parameters
87
+
88
+ def get_json(self):
89
+ content_type = self.content_type
90
+
91
+ is_json = (content_type == "application/json" or (content_type is not None and content_type.endswith("+json")))
92
+
93
+ if not is_json:
94
+ raise UnsupportedMediaType("The request content type must be JSON")
95
+
96
+ charset = self.charset or "utf-8"
97
+
98
+ try:
99
+ if isinstance(self.body, bytes):
100
+ body_text = self.body.decode(charset)
101
+ else:
102
+ body_text = self.body
103
+
104
+ except LookupError:
105
+ raise BadRequest(f"The charset '{charset}' is not supported")
106
+ except UnicodeDecodeError:
107
+ raise BadRequest(f"The request body could not be decoded using the charset '{charset}'")
108
+
109
+ try:
110
+ return json.loads(body_text)
111
+ except json.JSONDecodeError:
112
+ raise BadRequest("The request body is not valid JSON")
113
+
114
+ def get_form(self):
115
+ if self.content_type != "application/x-www-form-urlencoded":
116
+ raise UnsupportedMediaType("The request content type must be application/x-www-form-urlencoded")
117
+
118
+ charset = self.charset or "utf-8"
119
+
120
+ try:
121
+ if isinstance(self.body, bytes):
122
+ body_text = self.body.decode(charset)
123
+ else:
124
+ body_text = self.body
125
+
126
+ return parse_qs(body_text, keep_blank_values=True, encoding=charset, errors="strict")
127
+
128
+ except LookupError:
129
+ raise BadRequest(f"Unsupported character encoding: '{charset}'")
130
+ except UnicodeDecodeError:
131
+ raise BadRequest(f"The request body could not be decoded using the charset '{charset}'")
132
+
133
+ @property
134
+ def cookies(self):
135
+ cookie_headers = self.get_headers("cookie")
136
+
137
+ if cookie_headers is None:
138
+ return {}
139
+
140
+ result = {}
141
+
142
+ for header_value in cookie_headers:
143
+ cookie = SimpleCookie()
144
+ try:
145
+ cookie.load(header_value)
146
+ except CookieError:
147
+ raise BadRequest("The request contains invalid cookie header.")
148
+
149
+ for name, morsel in cookie.items():
150
+ result[name] = morsel.value
151
+
152
+ return result
153
+
154
+ def get_multipart(self):
155
+ if self.content_type != "multipart/form-data":
156
+ raise UnsupportedMediaType(
157
+ "The request Content-Type must be "
158
+ "multipart/form-data."
159
+ )
160
+
161
+ content_type, parameters = (
162
+ self._parse_content_type()
163
+ )
164
+
165
+ boundary = parameters.get("boundary")
166
+
167
+ if not boundary:
168
+ raise BadRequest(
169
+ "The multipart boundary is missing."
170
+ )
171
+
172
+ charset = self.charset or "utf-8"
173
+
174
+ return parse_multipart(
175
+ body=self.body,
176
+ boundary=boundary,
177
+ charset=charset,
178
+ )
179
+
180
+