bareASGI 5.0.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.
- bareasgi/__init__.py +61 -0
- bareasgi/application.py +174 -0
- bareasgi/basic_router/__init__.py +9 -0
- bareasgi/basic_router/http_router.py +91 -0
- bareasgi/basic_router/path_definition.py +94 -0
- bareasgi/basic_router/path_segment.py +92 -0
- bareasgi/basic_router/web_socket_router.py +48 -0
- bareasgi/core_application.py +143 -0
- bareasgi/http/__init__.py +33 -0
- bareasgi/http/callbacks.py +17 -0
- bareasgi/http/errors.py +9 -0
- bareasgi/http/instance.py +318 -0
- bareasgi/http/middleware.py +33 -0
- bareasgi/http/request.py +90 -0
- bareasgi/http/response.py +145 -0
- bareasgi/http/router.py +57 -0
- bareasgi/http/typing.py +270 -0
- bareasgi/lifespan/__init__.py +17 -0
- bareasgi/lifespan/instance.py +110 -0
- bareasgi/lifespan/request.py +26 -0
- bareasgi/lifespan/typing.py +178 -0
- bareasgi/middlewares/__init__.py +11 -0
- bareasgi/middlewares/compression.py +283 -0
- bareasgi/py.typed +0 -0
- bareasgi/typing.py +91 -0
- bareasgi/utils.py +60 -0
- bareasgi/versions.py +16 -0
- bareasgi/websockets/__init__.py +30 -0
- bareasgi/websockets/callbacks.py +16 -0
- bareasgi/websockets/errors.py +5 -0
- bareasgi/websockets/instance.py +189 -0
- bareasgi/websockets/middleware.py +35 -0
- bareasgi/websockets/request.py +34 -0
- bareasgi/websockets/router.py +38 -0
- bareasgi/websockets/typing.py +247 -0
- bareasgi/websockets/websocket.py +87 -0
- bareasgi-5.0.0.dist-info/METADATA +97 -0
- bareasgi-5.0.0.dist-info/RECORD +41 -0
- bareasgi-5.0.0.dist-info/WHEEL +5 -0
- bareasgi-5.0.0.dist-info/licenses/LICENSE +201 -0
- bareasgi-5.0.0.dist-info/top_level.txt +1 -0
bareasgi/__init__.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""bareASGI exports"""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
from bareutils import (
|
|
5
|
+
text_reader,
|
|
6
|
+
text_writer,
|
|
7
|
+
bytes_reader,
|
|
8
|
+
bytes_writer
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
from .application import Application
|
|
12
|
+
from .http import (
|
|
13
|
+
HttpRequest,
|
|
14
|
+
HttpResponse,
|
|
15
|
+
HttpRequestCallback,
|
|
16
|
+
HttpMiddlewareCallback,
|
|
17
|
+
HttpMiddlewares,
|
|
18
|
+
PushResponse,
|
|
19
|
+
make_middleware_chain,
|
|
20
|
+
HttpRouter,
|
|
21
|
+
)
|
|
22
|
+
from .lifespan import LifespanRequest, LifespanRequestHandler
|
|
23
|
+
from .typing import Scope
|
|
24
|
+
from .websockets import (
|
|
25
|
+
WebSocket,
|
|
26
|
+
WebSocketRequest,
|
|
27
|
+
WebSocketRequestCallback,
|
|
28
|
+
WebSocketMiddlewares,
|
|
29
|
+
WebSocketRouter,
|
|
30
|
+
WebSocketState,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"Scope",
|
|
35
|
+
|
|
36
|
+
"text_reader",
|
|
37
|
+
"text_writer",
|
|
38
|
+
"bytes_reader",
|
|
39
|
+
"bytes_writer",
|
|
40
|
+
|
|
41
|
+
"Application",
|
|
42
|
+
|
|
43
|
+
"HttpRequest",
|
|
44
|
+
"HttpResponse",
|
|
45
|
+
"HttpRequestCallback",
|
|
46
|
+
"HttpMiddlewareCallback",
|
|
47
|
+
"HttpMiddlewares",
|
|
48
|
+
"PushResponse",
|
|
49
|
+
"make_middleware_chain",
|
|
50
|
+
"HttpRouter",
|
|
51
|
+
|
|
52
|
+
"LifespanRequest",
|
|
53
|
+
"LifespanRequestHandler",
|
|
54
|
+
|
|
55
|
+
"WebSocket",
|
|
56
|
+
"WebSocketRequest",
|
|
57
|
+
"WebSocketRequestCallback",
|
|
58
|
+
"WebSocketMiddlewares",
|
|
59
|
+
"WebSocketRouter",
|
|
60
|
+
"WebSocketState",
|
|
61
|
+
]
|
bareasgi/application.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""The ASGI application"""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Any, Callable, Final
|
|
5
|
+
|
|
6
|
+
from bareutils import text_writer
|
|
7
|
+
|
|
8
|
+
from .http import (
|
|
9
|
+
HttpRouter,
|
|
10
|
+
HttpResponse,
|
|
11
|
+
HttpMiddlewares,
|
|
12
|
+
HttpRequestCallback
|
|
13
|
+
)
|
|
14
|
+
from .lifespan import LifespanRequestHandler
|
|
15
|
+
from .websockets import (
|
|
16
|
+
WebSocketRouter,
|
|
17
|
+
WebSocketRequestCallback,
|
|
18
|
+
WebSocketMiddlewares
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
from .basic_router import BasicHttpRouter, BasicWebSocketRouter
|
|
22
|
+
from .core_application import CoreApplication
|
|
23
|
+
|
|
24
|
+
LOGGER: Final[logging.Logger] = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
DEFAULT_NOT_FOUND_RESPONSE: Final[HttpResponse] = HttpResponse(
|
|
27
|
+
404,
|
|
28
|
+
[(b'content-type', b'text/plain')],
|
|
29
|
+
text_writer('Not Found')
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Application(CoreApplication):
|
|
34
|
+
"""A class to hold the application."""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
*,
|
|
39
|
+
middlewares: HttpMiddlewares | None = None,
|
|
40
|
+
http_router: HttpRouter | None = None,
|
|
41
|
+
ws_middlewares: WebSocketMiddlewares | None = None,
|
|
42
|
+
ws_router: WebSocketRouter | None = None,
|
|
43
|
+
startup_handlers: list[LifespanRequestHandler] | None = None,
|
|
44
|
+
shutdown_handlers: list[LifespanRequestHandler] | None = None,
|
|
45
|
+
not_found_response: HttpResponse = DEFAULT_NOT_FOUND_RESPONSE,
|
|
46
|
+
info: dict[str, Any] | None = None
|
|
47
|
+
) -> None:
|
|
48
|
+
"""Construct the application
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from bareasgi import (
|
|
52
|
+
Application,
|
|
53
|
+
Scope,
|
|
54
|
+
HttpRequest,
|
|
55
|
+
HttpResponse,
|
|
56
|
+
text_reader,
|
|
57
|
+
text_writer
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
async def http_request_callback(request: HttpRequest) -> HttpResponse:
|
|
61
|
+
text = await text_reader(request.body)
|
|
62
|
+
return HttpResponse(
|
|
63
|
+
200,
|
|
64
|
+
[(b'content-type', b'text/plain')],
|
|
65
|
+
text_writer('This is not a test')
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
import uvicorn
|
|
69
|
+
|
|
70
|
+
app = Application()
|
|
71
|
+
app.http_router.add({'GET', 'POST', 'PUT', 'DELETE'}, '/{path}', http_request_callback)
|
|
72
|
+
|
|
73
|
+
uvicorn.run(app, port=9009)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
middlewares (HttpMiddlewares | None, optional): Optional
|
|
78
|
+
middleware callbacks. Defaults to None.
|
|
79
|
+
http_router (HttpRouter | None, optional): Optional router to for
|
|
80
|
+
http routes. Defaults to None.
|
|
81
|
+
ws_middlewares (WebSocketMiddlewares | None, optional):
|
|
82
|
+
Optional middleware callbacks. Defaults to None.
|
|
83
|
+
ws_router (WebSocketRouter | None, optional): Optional
|
|
84
|
+
router for web routes. Defaults to None.
|
|
85
|
+
startup_handlers (Optional[List[LifespanHandler]], optional): Optional
|
|
86
|
+
handlers to run at startup. Defaults to None.
|
|
87
|
+
shutdown_handlers (Optional[List[LifespanHandler]], optional): Optional
|
|
88
|
+
handlers to run at shutdown. Defaults to None.
|
|
89
|
+
not_found_response (Optional[HttpResponse], optional): Optional not
|
|
90
|
+
found (404) response. Defaults to DEFAULT_NOT_FOUND_RESPONSE.
|
|
91
|
+
info (dict[str, Any] | None, optional): Optional
|
|
92
|
+
dictionary for user data. Defaults to None.
|
|
93
|
+
"""
|
|
94
|
+
super().__init__(
|
|
95
|
+
middlewares or [],
|
|
96
|
+
http_router or BasicHttpRouter(not_found_response),
|
|
97
|
+
ws_middlewares or [],
|
|
98
|
+
ws_router or BasicWebSocketRouter(),
|
|
99
|
+
startup_handlers or [],
|
|
100
|
+
shutdown_handlers or [],
|
|
101
|
+
info or {}
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def on_http_request(
|
|
105
|
+
self,
|
|
106
|
+
methods: set[str],
|
|
107
|
+
path: str
|
|
108
|
+
) -> Callable[[HttpRequestCallback], HttpRequestCallback]:
|
|
109
|
+
"""A decorator to add an http route handler to the application
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
methods (AbstractSet[str]): The http methods, e.g. {{'POST', 'PUT'}
|
|
113
|
+
path (str): The path
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
Callable[[HttpRequestCallback], HttpRequestCallback]: The decorated
|
|
117
|
+
request.
|
|
118
|
+
"""
|
|
119
|
+
def decorator(callback: HttpRequestCallback) -> Callable:
|
|
120
|
+
self.http_router.add(methods, path, callback)
|
|
121
|
+
return callback
|
|
122
|
+
|
|
123
|
+
return decorator
|
|
124
|
+
|
|
125
|
+
def on_ws_request(
|
|
126
|
+
self,
|
|
127
|
+
path: str
|
|
128
|
+
) -> Callable[[WebSocketRequestCallback], WebSocketRequestCallback]:
|
|
129
|
+
"""A decorator to add a websocket route handler to the application
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
path (str): The path
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
Callable[[WebSocketRequestCallback], WebSocketRequestCallback]: The
|
|
136
|
+
decorated handler
|
|
137
|
+
"""
|
|
138
|
+
def decorator(
|
|
139
|
+
callback: WebSocketRequestCallback
|
|
140
|
+
) -> WebSocketRequestCallback:
|
|
141
|
+
self.ws_router.add(path, callback)
|
|
142
|
+
return callback
|
|
143
|
+
|
|
144
|
+
return decorator
|
|
145
|
+
|
|
146
|
+
def on_startup(
|
|
147
|
+
self,
|
|
148
|
+
callback: LifespanRequestHandler
|
|
149
|
+
) -> LifespanRequestHandler:
|
|
150
|
+
"""A decorator to add a startup handler to the application
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
callback (LifespanRequestHandler): The startup handler.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
LifespanRequestHandler: The decorated handler.
|
|
157
|
+
"""
|
|
158
|
+
self.startup_handlers.append(callback)
|
|
159
|
+
return callback
|
|
160
|
+
|
|
161
|
+
def on_shutdown(
|
|
162
|
+
self,
|
|
163
|
+
callback: LifespanRequestHandler
|
|
164
|
+
) -> LifespanRequestHandler:
|
|
165
|
+
"""A decorator to add a startup handler to the application
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
callback (LifespanRequestHandler): The shutdown handler.
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
LifespanRequestHandler: The decorated handler.
|
|
172
|
+
"""
|
|
173
|
+
self.shutdown_handlers.append(callback)
|
|
174
|
+
return callback
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Http Routing
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any, Final, Mapping
|
|
7
|
+
|
|
8
|
+
from ..http import HttpRouter, HttpRequest, HttpResponse, HttpRequestCallback
|
|
9
|
+
|
|
10
|
+
from .path_definition import PathDefinition
|
|
11
|
+
|
|
12
|
+
LOGGER: Final[logging.Logger] = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
type Route = tuple[PathDefinition, HttpRequestCallback]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BasicHttpRouter(HttpRouter):
|
|
18
|
+
"""A basic http routing implementation"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, not_found_response: HttpResponse) -> None:
|
|
21
|
+
self._routes: dict[str, list[Route]] = {}
|
|
22
|
+
self._not_found_response = not_found_response
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def not_found_response(self) -> HttpResponse:
|
|
26
|
+
return self._not_found_response
|
|
27
|
+
|
|
28
|
+
@not_found_response.setter
|
|
29
|
+
def not_found_response(self, value: HttpResponse) -> None:
|
|
30
|
+
self._not_found_response = value
|
|
31
|
+
|
|
32
|
+
def add(
|
|
33
|
+
self,
|
|
34
|
+
methods: set[str],
|
|
35
|
+
path: str,
|
|
36
|
+
callback: HttpRequestCallback
|
|
37
|
+
) -> None:
|
|
38
|
+
LOGGER.debug('Adding route for %s on "%s".', methods, path)
|
|
39
|
+
path_definition = PathDefinition(path)
|
|
40
|
+
for method in methods:
|
|
41
|
+
self.add_route(method, path_definition, callback)
|
|
42
|
+
|
|
43
|
+
def add_route(
|
|
44
|
+
self,
|
|
45
|
+
method: str,
|
|
46
|
+
path_definition: PathDefinition,
|
|
47
|
+
callback: HttpRequestCallback
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Add a route to a callback for a method and path definition
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
method (str): The method.
|
|
53
|
+
path_definition (PathDefinition): The path definition
|
|
54
|
+
callback (HttpRequestCallback): The callback
|
|
55
|
+
"""
|
|
56
|
+
path_definition_list = self._routes.setdefault(method, [])
|
|
57
|
+
path_definition_list.append((path_definition, callback))
|
|
58
|
+
|
|
59
|
+
async def _not_found(
|
|
60
|
+
self,
|
|
61
|
+
_request: HttpRequest
|
|
62
|
+
) -> HttpResponse:
|
|
63
|
+
return self._not_found_response
|
|
64
|
+
|
|
65
|
+
def resolve(
|
|
66
|
+
self,
|
|
67
|
+
method: str,
|
|
68
|
+
path: str
|
|
69
|
+
) -> tuple[HttpRequestCallback, Mapping[str, Any]]:
|
|
70
|
+
path_definition_list = self._routes.get(method)
|
|
71
|
+
if path_definition_list:
|
|
72
|
+
for path_definition, handler in path_definition_list:
|
|
73
|
+
is_match, matches = path_definition.match(path)
|
|
74
|
+
if is_match:
|
|
75
|
+
LOGGER.debug(
|
|
76
|
+
'Matched %s on "%s" for %s matching %s.',
|
|
77
|
+
method,
|
|
78
|
+
path,
|
|
79
|
+
path_definition,
|
|
80
|
+
matches,
|
|
81
|
+
extra={'method': method, 'path': path}
|
|
82
|
+
)
|
|
83
|
+
return handler, matches
|
|
84
|
+
|
|
85
|
+
LOGGER.warning(
|
|
86
|
+
'Failed to find a match for %s on "%s".',
|
|
87
|
+
method,
|
|
88
|
+
path,
|
|
89
|
+
extra={'method': method, 'path': path}
|
|
90
|
+
)
|
|
91
|
+
return self._not_found, {}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Path definitions used by the routers.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Any, Mapping
|
|
6
|
+
|
|
7
|
+
from .path_segment import PathSegment
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PathDefinition:
|
|
11
|
+
"""A class capturing a matchable path"""
|
|
12
|
+
|
|
13
|
+
NO_MATCH: tuple[bool, Mapping[str, Any]] = (False, {})
|
|
14
|
+
|
|
15
|
+
def __init__(self, path: str) -> None:
|
|
16
|
+
"""Create a path definition."""
|
|
17
|
+
# Save for hashing
|
|
18
|
+
self.path = path
|
|
19
|
+
|
|
20
|
+
if not path.startswith('/'):
|
|
21
|
+
raise ValueError('Paths must be absolute')
|
|
22
|
+
# Trim off the leading '/'
|
|
23
|
+
path = path[1:]
|
|
24
|
+
|
|
25
|
+
# Handle paths that end with a '/'
|
|
26
|
+
if path.endswith('/'):
|
|
27
|
+
path = path[:-1]
|
|
28
|
+
self.ends_with_slash = True
|
|
29
|
+
else:
|
|
30
|
+
self.ends_with_slash = False
|
|
31
|
+
|
|
32
|
+
# Parse each path segment.
|
|
33
|
+
self.segments: list[PathSegment] = []
|
|
34
|
+
for segment in path.split('/'):
|
|
35
|
+
self.segments.append(PathSegment(segment))
|
|
36
|
+
|
|
37
|
+
def match(self, path: str) -> tuple[bool, Mapping[str, Any]]:
|
|
38
|
+
"""Try to match the given path with this path definition
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
path (str): The path to match
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
Exception: If the path is not absolute.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
Tuple[bool, Mapping[str, Any]]: A tuple of is_match and matches.
|
|
48
|
+
"""
|
|
49
|
+
if not path.startswith('/'):
|
|
50
|
+
raise ValueError('Paths must be absolute')
|
|
51
|
+
|
|
52
|
+
# Handle trailing slash
|
|
53
|
+
if path[1:].endswith('/') and self.segments[-1].type != 'path':
|
|
54
|
+
if not self.ends_with_slash:
|
|
55
|
+
return self.NO_MATCH
|
|
56
|
+
path = path[:-1]
|
|
57
|
+
elif self.ends_with_slash:
|
|
58
|
+
return self.NO_MATCH
|
|
59
|
+
|
|
60
|
+
parts = path[1:].split('/')
|
|
61
|
+
|
|
62
|
+
# Must have at least the same number of segments.
|
|
63
|
+
if len(parts) < len(self.segments):
|
|
64
|
+
return self.NO_MATCH
|
|
65
|
+
|
|
66
|
+
# Keep the matches we find.
|
|
67
|
+
matches: dict[str, Any | None] = {}
|
|
68
|
+
|
|
69
|
+
# A path with more segments is allowed if the last segment is a variable of type 'path'.
|
|
70
|
+
if len(parts) > len(self.segments):
|
|
71
|
+
last_segment = self.segments[-1]
|
|
72
|
+
if last_segment.type != 'path':
|
|
73
|
+
return self.NO_MATCH
|
|
74
|
+
index = len(self.segments) - 1
|
|
75
|
+
matches[last_segment.name] = '/'.join(parts[index:])
|
|
76
|
+
parts = parts[:index]
|
|
77
|
+
|
|
78
|
+
# Now the path parts and segments are the same length we can check them.
|
|
79
|
+
for part, segment in zip(parts, self.segments):
|
|
80
|
+
is_match, name, value = segment.match(part)
|
|
81
|
+
if not is_match:
|
|
82
|
+
return self.NO_MATCH
|
|
83
|
+
if name:
|
|
84
|
+
matches[name] = value
|
|
85
|
+
|
|
86
|
+
return True, matches
|
|
87
|
+
|
|
88
|
+
def __hash__(self) -> int:
|
|
89
|
+
return hash(self.path)
|
|
90
|
+
|
|
91
|
+
def __str__(self):
|
|
92
|
+
return f'<PathDefinition: segments={self.segments}, ends_with_slash={self.ends_with_slash}>'
|
|
93
|
+
|
|
94
|
+
__repr__ = __str__
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""
|
|
2
|
+
A segment of a path.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Any, Callable, Mapping
|
|
7
|
+
|
|
8
|
+
from ..utils import parse_json_datetime
|
|
9
|
+
|
|
10
|
+
type Converter = Callable[[Any, str | None], Any]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ParseError(Exception):
|
|
14
|
+
"""Exception raised on a parse error"""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _parse_datetime(value, fmt) -> datetime | None:
|
|
18
|
+
return datetime.strptime(value, fmt) if fmt else parse_json_datetime(value)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
CONVERTERS: Mapping[str, Converter] = {
|
|
22
|
+
'str': lambda value, fmt: value,
|
|
23
|
+
'int': lambda value, fmt: int(value),
|
|
24
|
+
'float': lambda value, fmt: float(value),
|
|
25
|
+
'datetime': _parse_datetime,
|
|
26
|
+
'path': lambda value, fmt: value,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PathSegment:
|
|
31
|
+
"""A class representing the segment of a path"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, segment: str) -> None:
|
|
34
|
+
"""Create a path segment
|
|
35
|
+
A path segment can be an absolute name "foo", a variable "{foo}", a
|
|
36
|
+
variable and type "{foo:int}" or a variable, type, and
|
|
37
|
+
format "{foo:datetime:Y-m-dTH:M:S}".
|
|
38
|
+
|
|
39
|
+
Valid types are: int, float, str, datetime, path.
|
|
40
|
+
The 'path' type catches all following segments, so '/foo/{rest:path}'
|
|
41
|
+
would match '/foo/bar/grum'.
|
|
42
|
+
"""
|
|
43
|
+
self.type: str | None = None
|
|
44
|
+
self.format: str | None = None
|
|
45
|
+
|
|
46
|
+
if segment.startswith('{') and segment.endswith('}'):
|
|
47
|
+
self.name, *type_and_format = segment[1:-1].split(':', maxsplit=3)
|
|
48
|
+
if len(type_and_format) == 2:
|
|
49
|
+
self.type, self.format = type_and_format
|
|
50
|
+
elif len(type_and_format) == 1:
|
|
51
|
+
self.type, self.format = type_and_format[0], None
|
|
52
|
+
else:
|
|
53
|
+
self.type, self.format = 'str', None
|
|
54
|
+
if self.type and self.type not in CONVERTERS:
|
|
55
|
+
raise TypeError('Unknown type')
|
|
56
|
+
self.is_variable = True
|
|
57
|
+
elif segment.startswith('{') or segment.endswith('}'):
|
|
58
|
+
raise ParseError("Invalid substitution segment")
|
|
59
|
+
elif '{' in segment or '}' in segment:
|
|
60
|
+
raise ParseError("Literal segment contains invalid characters")
|
|
61
|
+
else:
|
|
62
|
+
self.name = segment
|
|
63
|
+
self.is_variable = False
|
|
64
|
+
self.type = None
|
|
65
|
+
self.format = None
|
|
66
|
+
|
|
67
|
+
def match(self, value: str) -> tuple[bool, str | None, Any | None]:
|
|
68
|
+
"""Try to match a segment.
|
|
69
|
+
|
|
70
|
+
:param value: The path segment to match.
|
|
71
|
+
:return: A tuple of: is_match:bool, variable_name:str, value:any
|
|
72
|
+
"""
|
|
73
|
+
if self.is_variable:
|
|
74
|
+
# noinspection PyBroadException
|
|
75
|
+
try:
|
|
76
|
+
converter = CONVERTERS[self.type or 'str']
|
|
77
|
+
value = converter(value, self.format) if self.type else value
|
|
78
|
+
return True, self.name, value
|
|
79
|
+
except ValueError:
|
|
80
|
+
return False, None, None
|
|
81
|
+
else:
|
|
82
|
+
return value == self.name, None, None
|
|
83
|
+
|
|
84
|
+
def __str__(self):
|
|
85
|
+
return '<PathSegment: ' \
|
|
86
|
+
f'name="{self.name}"' \
|
|
87
|
+
f', is_variable={self.is_variable}' \
|
|
88
|
+
f', type="{self.type}"' \
|
|
89
|
+
f', format="{self.format}"' \
|
|
90
|
+
'>'
|
|
91
|
+
|
|
92
|
+
__repr__ = __str__
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""
|
|
2
|
+
A basic Websocket router.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any, Final, Mapping
|
|
7
|
+
|
|
8
|
+
from ..websockets import WebSocketRouter, WebSocketRequestCallback
|
|
9
|
+
|
|
10
|
+
from .path_definition import PathDefinition
|
|
11
|
+
|
|
12
|
+
LOGGER: Final[logging.Logger] = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
type Route = tuple[PathDefinition, WebSocketRequestCallback]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BasicWebSocketRouter(WebSocketRouter):
|
|
18
|
+
"""The implementation of a basic Websocket router"""
|
|
19
|
+
|
|
20
|
+
def __init__(self) -> None:
|
|
21
|
+
self._routes: list[Route] = []
|
|
22
|
+
|
|
23
|
+
def add(self, path: str, callback: WebSocketRequestCallback) -> None:
|
|
24
|
+
self._routes.append((PathDefinition(path), callback))
|
|
25
|
+
|
|
26
|
+
def resolve(
|
|
27
|
+
self,
|
|
28
|
+
path: str
|
|
29
|
+
) -> tuple[WebSocketRequestCallback, Mapping[str, Any]]:
|
|
30
|
+
for path_definition, handler in self._routes:
|
|
31
|
+
is_match, matches = path_definition.match(path)
|
|
32
|
+
if is_match:
|
|
33
|
+
LOGGER.debug(
|
|
34
|
+
'Matched "%s"" with %s.',
|
|
35
|
+
path,
|
|
36
|
+
path_definition,
|
|
37
|
+
extra={'path': path}
|
|
38
|
+
)
|
|
39
|
+
return handler, matches
|
|
40
|
+
|
|
41
|
+
LOGGER.warning(
|
|
42
|
+
'Failed to find a match for "%s".',
|
|
43
|
+
path,
|
|
44
|
+
extra={'path': path}
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# TODO: Should we have a "route not found" handler?
|
|
48
|
+
raise ValueError(f"Unable to find route for {path}")
|