whoopapi 0.0.1__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,160 @@
1
+ import re
2
+ from urllib.parse import parse_qs, urlparse
3
+
4
+
5
+ def strip_string(string: str):
6
+ return string.strip()
7
+
8
+
9
+ # def parse_header_line_(header_line):
10
+ # """
11
+ # Parses an HTTP header line with multiple components.
12
+ # Handles headers like:
13
+ # Content-Type: text/html; charset=utf-8
14
+ # Cache-Control: no-cache, no-store, must-revalidate
15
+ # """
16
+ #
17
+ # pattern = r"""
18
+ # ^(:?[^:\s]+) # Header name (group 1) - can start with colon for pseudo-headers
19
+ # \s*:\s* # Colon with optional whitespace
20
+ # ([^\x00]*) # Value (group 2) - no NUL characters allowed
21
+ # ((?:\s*[;,]\s*[^\x00;,]+)*)? # Additional parameters (group 3, optional)
22
+ # \s*$ # Trailing whitespace
23
+ # """
24
+ #
25
+ # match = re.fullmatch(pattern, header_line, re.VERBOSE)
26
+ # if not match:
27
+ # return None
28
+ #
29
+ # header_name = match.group(1).strip().lower()
30
+ # primary_value = match.group(2).strip()
31
+ #
32
+ # is_pseudo = header_name.startswith(":")
33
+ # params = {} if not is_pseudo else None
34
+ #
35
+ # if not is_pseudo and match.group(3):
36
+ # param_pairs = re.split(r"\s*[;,]\s*", match.group(3).strip())
37
+ # for pair in param_pairs:
38
+ # if "=" in pair:
39
+ # key, value = pair.split("=", 1)
40
+ # params[key.strip().lower()] = (
41
+ # value.strip()
42
+ # ) # Param names also lowercase
43
+ # elif pair:
44
+ # params[pair.lower()] = True
45
+ #
46
+ # return {
47
+ # "name": header_name,
48
+ # "value": primary_value,
49
+ # "params": params,
50
+ # "is_pseudo": is_pseudo,
51
+ # }
52
+
53
+
54
+ def parse_header_line(header_line):
55
+ """
56
+ Parses an HTTP header line with multiple components.
57
+ Handles headers like:
58
+ Content-Type: text/html; charset=utf-8
59
+ Cache-Control: no-cache, no-store, must-revalidate
60
+ """
61
+
62
+ pattern = r"""
63
+ ^(:?[^:\s]+) # Header name (group 1) - can start with colon for pseudo-headers
64
+ \s*:\s* # Colon with optional whitespace
65
+ ([^\x00;,]*) # Primary value (group 2) - stops at ; or ,
66
+ \s* # Optional whitespace
67
+ (?: # Non-capturing group for parameters
68
+ [;,]\s* # Parameter separator (; or ,) with optional whitespace
69
+ ([^\x00]*) # All parameters (group 3)
70
+ )?
71
+ \s*$ # Trailing whitespace
72
+ """
73
+
74
+ match = re.fullmatch(pattern, header_line, re.VERBOSE)
75
+ if not match:
76
+ return None
77
+
78
+ header_name = match.group(1).strip().lower()
79
+ primary_value = match.group(2).strip()
80
+ params_string = match.group(3).strip() if match.group(3) else ""
81
+
82
+ is_pseudo = header_name.startswith(":")
83
+ params = {} if not is_pseudo else None
84
+
85
+ # Parse parameters
86
+ if not is_pseudo and params_string:
87
+ # Split on semicolons or commas, but be careful with quoted values
88
+ param_pairs = re.split(r"\s*[;,]\s*", params_string)
89
+ for pair in param_pairs:
90
+ if not pair:
91
+ continue
92
+ if "=" in pair:
93
+ key, value = pair.split("=", 1)
94
+ # Remove quotes if present
95
+ value = value.strip()
96
+ if (value.startswith('"') and value.endswith('"')) or (
97
+ value.startswith("'") and value.endswith("'")
98
+ ):
99
+ value = value[1:-1]
100
+ params[key.strip().lower()] = value
101
+ elif pair:
102
+ params[pair.lower()] = True
103
+
104
+ return {
105
+ "name": header_name,
106
+ "value": primary_value,
107
+ "params": params,
108
+ "is_pseudo": is_pseudo,
109
+ }
110
+
111
+
112
+ def parse_headers(data: bytes):
113
+ ENCODING = "utf-8"
114
+ HEADER_BREAK = "\r\n"
115
+ stringified = str(data, ENCODING)
116
+ entries = stringified.split(HEADER_BREAK)
117
+ start_line = entries[0].strip()
118
+ split_start_line = start_line.split(" ")
119
+ request_info = {}
120
+
121
+ if len(split_start_line) > 2:
122
+ request_info["method"] = strip_string(split_start_line[0])
123
+
124
+ request_path = strip_string(split_start_line[1])
125
+ parsed_path = urlparse(url=request_path)
126
+ request_info["path"] = parsed_path.path
127
+
128
+ query_params = parse_qs(parsed_path.query)
129
+ request_info["query_params"] = {
130
+ k: v[0] if len(v) == 1 else v for k, v in query_params.items()
131
+ }
132
+
133
+ split_version_info = strip_string(split_start_line[2]).split("/")
134
+ request_info["protocol"] = strip_string(strip_string(split_version_info[0]))
135
+ request_info["protocol_version"] = strip_string(split_version_info[1])
136
+
137
+ else:
138
+ raise Exception("Invalid headers.")
139
+
140
+ entries = entries[1:]
141
+ headers = {}
142
+ header_params = {}
143
+
144
+ for entry in entries:
145
+ parsed_line = parse_header_line(header_line=entry)
146
+ if not parsed_line:
147
+ continue
148
+
149
+ key = parsed_line["name"]
150
+ value = parsed_line["value"]
151
+ details = parsed_line["params"]
152
+ header_params[key] = details
153
+
154
+ headers[key] = value
155
+
156
+ return {
157
+ "request_info": request_info,
158
+ "headers": headers,
159
+ "header_params": header_params,
160
+ }
@@ -0,0 +1,3 @@
1
+ # flake8: noqa
2
+ from .http import RequestHandler, StaticFileHandler
3
+ from .websocket import WebsocketHandler
@@ -0,0 +1,162 @@
1
+ import inspect
2
+ import os
3
+ from typing import Any, Callable, Optional
4
+
5
+ from ..constants import HttpContentTypes, HttpHeaders, HttpStatusCodes
6
+ from ..logging import LOG_ERROR, LOG_INFO
7
+ from ..responses import DEFAULT_404_PAGE
8
+ from ..wrappers import HttpRequest, HttpResponse
9
+
10
+
11
+ class RequestHandler:
12
+ def __init__(self):
13
+ self.route = ""
14
+
15
+ def get_handler_for_method_(self, method: str) -> Callable[[HttpRequest], Any]:
16
+ handler_ = {
17
+ "get": self.get,
18
+ "post": self.post,
19
+ "put": self.put,
20
+ "patch": self.patch,
21
+ "delete": self.delete,
22
+ }.get(method, None)
23
+
24
+ if not handler_:
25
+ raise Exception(f"Unable to get handler for method : {method}.")
26
+
27
+ return handler_
28
+
29
+ def get(self, request: HttpRequest) -> Optional[Any]:
30
+ pass
31
+
32
+ def post(self, request: HttpRequest) -> Optional[Any]:
33
+ pass
34
+
35
+ def put(self, request: HttpRequest) -> Optional[Any]:
36
+ pass
37
+
38
+ def patch(self, request: HttpRequest) -> Optional[Any]:
39
+ pass
40
+
41
+ def delete(self, request: HttpRequest) -> Optional[Any]:
42
+ pass
43
+
44
+
45
+ def path_matches_route(path: str, route: str):
46
+ route_ = route.strip("/ ")
47
+ path_ = path.strip("/ ")
48
+ return path_.startswith(route_)
49
+
50
+
51
+ def handle_http_client_request(
52
+ request: HttpRequest,
53
+ middlewares: list[Callable],
54
+ http_routes: list[tuple[str, Callable | RequestHandler]],
55
+ log_handler=True,
56
+ ):
57
+ for action in middlewares:
58
+ action(request)
59
+
60
+ response = None
61
+ wrapped_response = None
62
+ request_method = request.method.lower()
63
+ request_path = request.path
64
+ request_protocol = request.protocol
65
+ response_code = HttpStatusCodes.C_200
66
+
67
+ handler_found = False
68
+ for route, handler_function in http_routes:
69
+ if isinstance(handler_function, RequestHandler):
70
+ handler = handler_function
71
+ handler.route = route
72
+ handler_function_ = handler_function.get_handler_for_method_(
73
+ method=request_method
74
+ )
75
+
76
+ elif inspect.isclass(handler_function) and issubclass(
77
+ handler_function, RequestHandler
78
+ ):
79
+ handler = handler_function()
80
+ handler.route = route
81
+ handler_function_ = handler.get_handler_for_method_(method=request_method)
82
+
83
+ elif inspect.isfunction(handler_function):
84
+ handler_function_ = handler_function
85
+
86
+ else:
87
+ raise Exception(
88
+ "Invalid websocket handler. Must be Class_(RequestHandler), or instance of."
89
+ )
90
+
91
+ if path_matches_route(path=request_path, route=route):
92
+ handler_found = True
93
+
94
+ try:
95
+ response = handler_function_(request)
96
+
97
+ except Exception as e:
98
+ LOG_ERROR(e)
99
+ response = HttpResponse()
100
+ response.set_status_code(HttpStatusCodes.C_500)
101
+ response.set_html(DEFAULT_404_PAGE)
102
+ response_code = response.status_code
103
+
104
+ break
105
+
106
+ if isinstance(response, HttpResponse):
107
+ response_code = response.status_code
108
+ wrapped_response = response
109
+
110
+ elif isinstance(response, str):
111
+ text_response = HttpResponse()
112
+ text_response.set_header(HttpHeaders.CONTENT_TYPE, HttpContentTypes.TEXT_PLAIN)
113
+ text_response.set_body(response)
114
+ wrapped_response = text_response
115
+
116
+ elif isinstance(response, dict) or isinstance(response, list):
117
+ json_response = HttpResponse()
118
+ json_response.set_json(response)
119
+ wrapped_response = json_response
120
+
121
+ elif (not handler_found) or (not response):
122
+ response = HttpResponse()
123
+ response.set_status_code(HttpStatusCodes.C_404)
124
+ response.set_html(DEFAULT_404_PAGE)
125
+ response_code = response.status_code
126
+ wrapped_response = response
127
+
128
+ if log_handler:
129
+ log_message = f"{request_method.upper()} {request_protocol.upper()}://{request.host}{request_path} {response_code}"
130
+ LOG_INFO(log_message)
131
+
132
+ return wrapped_response
133
+
134
+
135
+ class StaticFileHandler(RequestHandler):
136
+ def __init__(self, directories: list[str] = None):
137
+ super().__init__()
138
+ self.directories = directories or []
139
+
140
+ def get_file_path(self, path: str):
141
+ file_path = path[len(self.route) + 1 :]
142
+
143
+ for directory in self.directories:
144
+ file_path = os.path.join(directory, file_path)
145
+ if os.path.exists(file_path):
146
+ return file_path
147
+
148
+ return None
149
+
150
+ def get(self, request: HttpRequest):
151
+ file_path = self.get_file_path(request.path)
152
+ if file_path:
153
+ file = open(file_path, "rb")
154
+ data = file.read()
155
+ file.close()
156
+ response = HttpResponse()
157
+ response.set_file(f"{file_path.split(os.path.sep)[-1]}", data)
158
+
159
+ return response
160
+
161
+ else:
162
+ return None
@@ -0,0 +1,239 @@
1
+ import base64 as BASE64
2
+ import hashlib as HASHLIB
3
+ import inspect
4
+ import socket as SOCKET
5
+ import struct as STRUCT
6
+ from typing import Callable
7
+
8
+ from ..constants import (
9
+ DEFAULT_STRING_ENCODING,
10
+ WEBSOCKET_ACCEPT_SUFFIX,
11
+ HttpHeaders,
12
+ HttpStatusCodes,
13
+ )
14
+ from ..logging import LOG_INFO
15
+ from ..wrappers import HttpRequest, HttpResponse
16
+
17
+
18
+ def mask_data(mask: bytes, data: bytes):
19
+ masked_data = b""
20
+
21
+ for index in range(0, len(data)):
22
+ x = data[index]
23
+ y = mask[index % 4]
24
+ masked_data = masked_data + STRUCT.pack("B", x ^ y)
25
+
26
+ return masked_data
27
+
28
+
29
+ def send_websocket_message(socket: SOCKET.socket, message: bytes | str):
30
+ frame_size = 64
31
+ # mask=RANDOM.randbytes(4)
32
+ payloads = []
33
+ index = 0
34
+
35
+ while True:
36
+ end = index + frame_size
37
+ if end >= len(message):
38
+ end = len(message)
39
+ payloads.append(message[index:end])
40
+
41
+ break
42
+
43
+ else:
44
+ payloads.append(message[index:end])
45
+
46
+ index = end
47
+
48
+ frames = []
49
+ for payload_index in range(0, len(payloads)):
50
+ payload = payloads[payload_index]
51
+ if isinstance(payload, str):
52
+ payload = bytes(payload, DEFAULT_STRING_ENCODING)
53
+ # payload=mask_data(mask,payload)
54
+ fin_bit = 0x8000 if payload_index >= len(payloads) - 1 else 0x0000
55
+ rsv_bits = 0x0000
56
+
57
+ if payload_index == 0:
58
+ opcode_bits = 0x0200 if isinstance(message, bytes) else 0x0100
59
+
60
+ else:
61
+ opcode_bits = 0x0000
62
+ # opcode_bits=0x0200 if payload_index==0 else 0x0000
63
+ # mask_bit=0x0080
64
+
65
+ mask_bit = 0x0000
66
+ header = fin_bit | rsv_bits | opcode_bits | mask_bit | len(payload)
67
+ frame = STRUCT.pack(">H", header)
68
+ # frame=frame+mask+payload
69
+ frame = frame + payload
70
+ frames.append(frame)
71
+
72
+ for frame in frames:
73
+ socket.sendall(frame)
74
+
75
+
76
+ def read_websocket_message(socket: SOCKET.socket):
77
+ buffer = b""
78
+
79
+ while True:
80
+ header = socket.recv(2)
81
+ if header and len(header) == 2:
82
+ header = STRUCT.unpack(">H", header)[0]
83
+ fin = header >> 15
84
+ # opcode = (header << 4) >> 16
85
+ # rsv = (header << 1) >> 13
86
+ mask = (header << 8) >> 15
87
+ payload_length = (header << 9) >> 9
88
+ data_mask = None
89
+ frame_data = None
90
+
91
+ if mask > 0:
92
+ data_mask = socket.recv(4)
93
+ if not data_mask:
94
+ pass
95
+
96
+ if payload_length > 0:
97
+ frame_data = socket.recv(payload_length)
98
+ if frame_data:
99
+ if mask and data_mask:
100
+ decoded_frame_data = mask_data(data_mask, frame_data)
101
+ buffer = buffer + decoded_frame_data
102
+
103
+ else:
104
+ buffer = buffer + frame_data
105
+
106
+ else:
107
+ pass
108
+
109
+ if fin > 0:
110
+ break
111
+
112
+ else:
113
+ pass
114
+
115
+ return buffer
116
+
117
+
118
+ class WebsocketHandler:
119
+ def __init__(self):
120
+ self.route = ""
121
+ self.DEFAULT_ENCODING = "utf-8"
122
+ self.socket = None
123
+ self.running = False
124
+
125
+ def set_socket(self, socket: SOCKET.socket):
126
+ self.socket = socket
127
+
128
+ def close(self):
129
+ try:
130
+ self.socket.close()
131
+ self.on_close()
132
+
133
+ except Exception as e:
134
+ LOG_INFO(e)
135
+
136
+ def run(self, timeout=None):
137
+ if not self.socket:
138
+ raise Exception("Socket not set.")
139
+
140
+ while True:
141
+ try:
142
+ message = read_websocket_message(self.socket)
143
+ if message:
144
+ self.on_message(message)
145
+
146
+ except Exception as e:
147
+ self.on_error(e)
148
+ self.close()
149
+
150
+ def send(self, message: str | bytes):
151
+ try:
152
+ send_websocket_message(self.socket, message)
153
+
154
+ except Exception as e:
155
+ self.on_error(e)
156
+ self.close()
157
+
158
+ def on_connect(self, request: HttpRequest):
159
+ pass
160
+
161
+ def on_message(self, message: bytes):
162
+ pass
163
+
164
+ def on_close(self):
165
+ pass
166
+
167
+ def on_error(self, exception):
168
+ pass
169
+
170
+
171
+ def path_matches_route(path: str, route: str):
172
+ route_ = route.strip("/ ")
173
+ path_ = path.strip("/ ")
174
+ return path_.startswith(route_)
175
+
176
+
177
+ def handle_websocket_client_request(
178
+ socket: SOCKET.socket,
179
+ request: HttpRequest,
180
+ middlewares: list[Callable],
181
+ websocket_routes: list[tuple[str, Callable | WebsocketHandler]],
182
+ ):
183
+ request_path = request.path
184
+ handler_found = False
185
+
186
+ for action in middlewares:
187
+ action(request)
188
+
189
+ for route, handler_function in websocket_routes:
190
+ if isinstance(handler_function, WebsocketHandler):
191
+ handler = handler_function
192
+
193
+ elif inspect.isclass(handler_function) and issubclass(
194
+ handler_function, WebsocketHandler
195
+ ):
196
+ handler = handler_function()
197
+
198
+ else:
199
+ raise Exception(
200
+ "Invalid websocket handler. Must be Class_(WebsocketHandler), or instance of."
201
+ )
202
+
203
+ if path_matches_route(path=request_path, route=route):
204
+ handler_found = True
205
+ handler.route = route
206
+ handler.set_socket(socket)
207
+ handler.on_connect(request)
208
+ handler.run(timeout=1000)
209
+
210
+ if not handler_found:
211
+ socket.close()
212
+
213
+
214
+ def generate_websocket_accept_key(websocket_key: str):
215
+ accept_key = f"{websocket_key}{WEBSOCKET_ACCEPT_SUFFIX}"
216
+ accept_key = HASHLIB.sha1(accept_key.encode()).digest()
217
+ accept_key = BASE64.b64encode(accept_key).decode("utf-8")
218
+
219
+ return accept_key
220
+
221
+
222
+ def perform_websocket_handshake(socket: SOCKET.socket, headers: dict):
223
+ websocket_key = headers.get(HttpHeaders.SEC_WEBSOCKET_KEY, "")
224
+ # websocket_version = headers.get(CONSTANTS.HttpHeaders.SEC_WEBSOCKET_VERSION, 13)
225
+
226
+ if websocket_key:
227
+ accept_key = generate_websocket_accept_key(websocket_key)
228
+ response = HttpResponse()
229
+ response.set_header(HttpHeaders.CONNECTION, "Upgrade")
230
+ response.set_header(HttpHeaders.UPGRADE, "websocket")
231
+ response.set_header(HttpHeaders.SEC_WEBSOCKET_ACCEPT, accept_key)
232
+ response.set_status_code(HttpStatusCodes.C_101)
233
+ result = response.build()
234
+ socket.sendall(result)
235
+
236
+ return True
237
+
238
+ else:
239
+ return False
whoopapi/responses.py ADDED
@@ -0,0 +1,11 @@
1
+ DEFAULT_404_PAGE = """
2
+ <div style="display:flex;flex-direction:row;justify-content:center;align-items:center;margin:50px">
3
+ <h1><b>404 Error! This resource is not available.</b></h1>
4
+ </div>
5
+ """
6
+
7
+ DEFAULT_500_PAGE = """
8
+ <div style="display:flex;flex-direction:row;justify-content:center;align-items:center;margin:50px">
9
+ <h1><b>500 Error! Internal server error.</b></h1>
10
+ </div>
11
+ """