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.
Files changed (41) hide show
  1. bareasgi/__init__.py +61 -0
  2. bareasgi/application.py +174 -0
  3. bareasgi/basic_router/__init__.py +9 -0
  4. bareasgi/basic_router/http_router.py +91 -0
  5. bareasgi/basic_router/path_definition.py +94 -0
  6. bareasgi/basic_router/path_segment.py +92 -0
  7. bareasgi/basic_router/web_socket_router.py +48 -0
  8. bareasgi/core_application.py +143 -0
  9. bareasgi/http/__init__.py +33 -0
  10. bareasgi/http/callbacks.py +17 -0
  11. bareasgi/http/errors.py +9 -0
  12. bareasgi/http/instance.py +318 -0
  13. bareasgi/http/middleware.py +33 -0
  14. bareasgi/http/request.py +90 -0
  15. bareasgi/http/response.py +145 -0
  16. bareasgi/http/router.py +57 -0
  17. bareasgi/http/typing.py +270 -0
  18. bareasgi/lifespan/__init__.py +17 -0
  19. bareasgi/lifespan/instance.py +110 -0
  20. bareasgi/lifespan/request.py +26 -0
  21. bareasgi/lifespan/typing.py +178 -0
  22. bareasgi/middlewares/__init__.py +11 -0
  23. bareasgi/middlewares/compression.py +283 -0
  24. bareasgi/py.typed +0 -0
  25. bareasgi/typing.py +91 -0
  26. bareasgi/utils.py +60 -0
  27. bareasgi/versions.py +16 -0
  28. bareasgi/websockets/__init__.py +30 -0
  29. bareasgi/websockets/callbacks.py +16 -0
  30. bareasgi/websockets/errors.py +5 -0
  31. bareasgi/websockets/instance.py +189 -0
  32. bareasgi/websockets/middleware.py +35 -0
  33. bareasgi/websockets/request.py +34 -0
  34. bareasgi/websockets/router.py +38 -0
  35. bareasgi/websockets/typing.py +247 -0
  36. bareasgi/websockets/websocket.py +87 -0
  37. bareasgi-5.0.0.dist-info/METADATA +97 -0
  38. bareasgi-5.0.0.dist-info/RECORD +41 -0
  39. bareasgi-5.0.0.dist-info/WHEEL +5 -0
  40. bareasgi-5.0.0.dist-info/licenses/LICENSE +201 -0
  41. 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
+ ]
@@ -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,9 @@
1
+ """Basic routing support"""
2
+
3
+ from .http_router import BasicHttpRouter
4
+ from .web_socket_router import BasicWebSocketRouter
5
+
6
+ __all__ = [
7
+ "BasicHttpRouter",
8
+ "BasicWebSocketRouter"
9
+ ]
@@ -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}")